Skip to content
isdnetworks
Go back

두 처리기가 같은 작업을 집었다

보상 지급이 두 번 된 계정이 나왔다. 로그를 보니 같은 작업을 두 처리기가 집었다.

Table of contents

Open Table of contents

조회하고 갱신하고 있었다

작업을 꺼내는 코드가 이랬다.

// 1. 대기 중인 작업을 찾는다
$job = $this->db->where('status', 'ready')
                ->order_by('job_no', 'ASC')
                ->limit(1)->get('reward_job')->row();

if (!$job) return;

// 2. 처리 중으로 바꾼다
$this->db->where('job_no', $job->job_no)
         ->update('reward_job', ['status' => 'working']);

// 3. 처리한다
$this->process($job);

1번과 2번 사이에 다른 처리기가 1번을 돌면 같은 행을 본다. 둘 다 ready 상태를 보고 둘 다 집는다.

조회와 갱신이 나뉘어 있으면 그 사이에 끼어들 수 있다. 이 문제는 처리기가 하나일 때는 안 나타나고 두 조회가 정확히 겹칠 때만 나므로 자주 안 나는 대신 났을 때 자료가 잘못되는 종류였다.

갱신으로 집었다

조회 대신 갱신으로 집게 바꿨다.

$workerId = gethostname() . '-' . getmypid();

$this->db->query(
    "UPDATE reward_job
     SET status = 'working', worker_id = ?, start_date = NOW()
     WHERE status = 'ready'
     ORDER BY job_no ASC
     LIMIT 1",
    [$workerId]
);

if ($this->db->affected_rows() === 0) return;   // 집을 게 없음

$job = $this->db->where('worker_id', $workerId)
                ->where('status', 'working')
                ->get('reward_job')->row();

UPDATE 는 한 문장이라 중간에 끼어들 수 없어서 두 처리기가 동시에 실행해도 하나만 성공한다.

affected_rows 가 집었는지 알려 준다. 0이면 다른 쪽이 먼저 집었거나 대기 작업이 없는 것이다. worker_id 를 넣은 것이 중요했는데 그 값이 있어야 내가 집은 것만 다시 조회할 수 있다.

잠금으로 하는 방법도 봤다

트랜잭션 안에서 잠그는 방법도 있었다.

START TRANSACTION;
SELECT * FROM reward_job WHERE status='ready' ORDER BY job_no LIMIT 1 FOR UPDATE;
UPDATE reward_job SET status='working' WHERE job_no = ?;
COMMIT;

FOR UPDATE 가 그 행을 잠그고 다른 쪽은 기다린다. 기다린다는 것이 문제였다.

처리기가 여럿이면 전부 같은 행을 기다리고 앞의 것이 끝나면 그 행은 ready 가 아니라 다시 조회한다. 기다리는 시간이 쌓이고 innodb_lock_wait_timeout 에 걸리는 것도 생긴다. 갱신으로 집는 쪽이 기다림이 없어서 그쪽으로 했다.

죽은 처리기의 작업 회수

집어 놓고 죽으면 그 작업이 working 으로 남고 아무도 안 건드린다. 시작 시각을 보고 회수했다.

UPDATE reward_job
SET status = 'ready', worker_id = NULL
WHERE status = 'working'
  AND start_date < DATE_SUB(NOW(), INTERVAL 10 MINUTE);

10분은 실제 처리 시간을 재서 정했고 가장 오래 걸린 것이 2분쯤이라 여유를 뒀다. 너무 짧으면 아직 처리 중인 것을 회수해서 중복이 나고 너무 길면 복구가 늦다.

회수 시간을 잘못 잡으면 중복이 생길 수 있으니 지급 쪽에도 방어를 뒀다.

CREATE TABLE reward_given (
  job_no  INT NOT NULL,
  uid     VARCHAR(50) NOT NULL,
  reg_date DATETIME NOT NULL,
  PRIMARY KEY (job_no)
);

지급할 때 reward_given 에 먼저 넣는다. 이미 있으면 ER_DUP_ENTRY 로 걸리니 두 번 지급이 안 된다.

$ok = $this->db->query("INSERT IGNORE INTO reward_given (job_no, uid, reg_date) VALUES (?, ?, NOW())",
                       [$job->job_no, $job->uid]);
if ($this->db->affected_rows() === 0) {
    log_message('warning', "이미 지급됨: job {$job->job_no}");
    return;
}
$this->giveItem($job->uid, $job->item_id);

PRIMARY KEY 가 중복을 막는다. 코드로 확인하는 것보다 확실하다.

검증 — 동시에 돌려 보기

이제 여러 개를 띄워도 되는지 확인해 봤다. 세 개를 동시에 돌리고 결과를 봤다.

SELECT worker_id, COUNT(*) FROM reward_job
WHERE status='done' AND start_date >= '2016-02-10' GROUP BY worker_id;
game-01-2841   142
game-01-2842   138
game-01-2843   145

고르게 나뉘었고 중복 지급은 0건이었다.

동시에 돌려 보기 전에는 안전한지 알 수 없었다. 하나만 돌리면 경합이 안 생기므로 그것으로는 확인이 안 된다.

정리


Share this post on:

Previous Post
명령 하나에 서버가 멈췄다
Next Post
화폐인가 자산인가