처리할 것을 테이블에 쌓고 워커 여러 대가 꺼내 가게 만들었다. 워커를 늘려도 처리량이 안 늘었다.
Table of contents
Open Table of contents
증상 — 워커를 늘려도 그대로였다
꺼내는 방식은 이랬다.
SELECT * FROM job_queue WHERE state = 'READY' ORDER BY no LIMIT 10 FOR UPDATE;
FOR UPDATE 로 잠그고 가져와서 처리한 뒤 state 를 바꾼다.
워커를 두 대로 늘렸는데 처리량이 그대로였고 세 대로 늘려도 같아서 늘린 것이 일을 안 하는 것처럼 보였다.
원인 — 서로 기다리고 있었다
모든 워커가 state = 'READY' 로 찾으니 같은 행을 먼저 만난다.
워커1 10건 잠금 → 처리(30초)
워커2 같은 10건 요청 → 30초 기다림 → 그다음 10건
워커3 ... 계속 밀림
FOR UPDATE 는 잠긴 행을 만나면 기다리므로 두 번째 워커는 첫 워커가 끝날 때까지 아무것도 못 한다.
늘어난 것은 대기하는 워커 수였고 동시에 처리되는 건수는 그대로라 셋을 띄우나 하나를 띄우나 순서대로 도는 것과 같았다.
조치 — 잠긴 행을 건너뛰게
SKIP LOCKED 를 붙였다.
SELECT * FROM job_queue WHERE state = 'READY'
ORDER BY no LIMIT 10
FOR UPDATE SKIP LOCKED;
SKIP LOCKED 가 잠긴 행을 건너뛰고 다음 것을 가져온다.
워커1 1~10 가져감
워커2 11~20 가져감
워커3 21~30 가져감
SKIP LOCKED 를 붙인 뒤로는 워커를 늘린 만큼 처리량이 함께 늘었다.
이 구문을 쓸 수 있는 판인지 먼저 확인해야 했고 옛 판에서는 안 되므로 그 환경에는 다른 방법이 필요했다.
대안 — 판이 낮을 때
다른 환경에 옛 판이 있어서 대안도 준비했다.
UPDATE job_queue SET state = 'TAKEN', worker = ?, taken_at = NOW()
WHERE state = 'READY' ORDER BY no LIMIT 10;
SELECT * FROM job_queue WHERE state = 'TAKEN' AND worker = ?;
TAKEN 으로 먼저 표시하고 그 worker 것만 가져오는 방식이다.
UPDATE 가 짧게 잠그므로 서로 오래 안 기다린다. 잠그고 처리하는 대신 TAKEN 이라는 표시를 남기고 잠금을 바로 푸는 셈이다.
정렬이 붙은 갱신은 판에 따라 동작이 다를 수 있어서 쓰기 전에 실제로 확인했다.
대응 — 죽은 워커와 중복 처리
워커가 처리 중에 죽으면 그 행이 TAKEN 인 채로 남는다.
UPDATE job_queue SET state = 'READY', worker = NULL
WHERE state = 'TAKEN' AND taken_at < NOW() - INTERVAL 10 MINUTE;
taken_at 이 10분을 넘으면 READY 로 되돌린다.
기준 시간은 실제 처리 시간을 보고 정했는데 평소 30초에 가끔 5분 걸리는 것이 있어 10분이면 살아 있는 것을 안 뺏는다.
READY 로 되돌린 횟수도 셌는데 그 수가 늘면 워커가 자주 죽는다는 뜻이다.
되돌리는 장치가 있으니 같은 건이 두 번 처리될 수 있다.
$done = $db->count("SELECT COUNT(*) FROM job_result WHERE job_no = ?", [$job->no]);
if ($done > 0) { return; }
job_result 에 결과를 남기고 이미 있으면 넘어간다.
job_result 를 남기는 것과 처리를 한 트랜잭션에 넣어야 이 확인이 성립하고 되돌리는 구조를 넣으면 여러 번 해도 되는 처리가 따라온다.
검증 — 쌓이는 속도와 처리 속도
state 별로 세어 봤다.
SELECT state, COUNT(*) FROM job_queue GROUP BY state;
READY 12,441
TAKEN 30
DONE 882,104
READY 가 12,441이라는 것만으로는 밀리고 있는지 알 수 없다.
14:00 READY 8,200
15:00 READY 10,100
16:00 READY 12,441
READY 를 시간별로 남기니 시간당 2,000씩 늘고 있다는 것이 보였다.
지금 값은 상태이고 시간별 값은 방향이라 방향을 봐야 워커를 늘려야 하는지가 정해진다.
DONE 이 88만 건이라 READY 조회가 느려진 것도 함께 걸렸다.
DELETE FROM job_queue WHERE state = 'DONE' AND done_at < NOW() - INTERVAL 7 DAY LIMIT 5000;
LIMIT 5000 없이 지우면 오래 잠그므로 나눠서 지웠다.
ALTER TABLE job_queue ADD INDEX ix_state_no (state, no);
인덱스도 상태와 번호로 다시 걸었다. 큐로 쓰는 표는 넣는 것만큼 지우는 것이 중요했다.
정리
- 테이블을 큐로 쓰면 워커끼리 같은 행을 원해 서로 기다린다
- 늘어나는 것은 대기하는 워커 수이고 처리량은 그대로다
- 잠긴 행을 건너뛰게 해야 워커를 늘린 만큼 처리량이 는다
- 그 구문을 쓸 수 있는 판인지 먼저 확인한다
- 안 되면 먼저 표시하고 가져오는 방식을 쓴다
- 정렬이 붙은 갱신은 판에 따라 다를 수 있어 실제로 확인한다
- 죽은 워커가 잡은 것을 시간으로 되돌린다
- 기준은 실제 처리 시간의 분포를 보고 정한다
- 되돌리면 두 번 처리될 수 있으므로 여러 번 해도 되게 만든다
- 지금 값은 상태이고 시간별 값은 방향이다
- 끝난 것을 나눠서 지우고 인덱스를 조건에 맞춘다