openlang

SKIP LOCKED 작업 큐, 트랜잭션 도중에 워커가 죽으면

작업 큐를 그냥 테이블에 둠. 워커 하나는 Python, 하나는 Go. BEGIN, 그다음 SELECT id, payload FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED, 작업, UPDATE jobs SET status = 'done' WHERE id = $1, COMMIT. 나머지는 잠긴 행을 건너뜀. 브로커 없음.

워커가 SELECT와 COMMIT 사이에서 죽으면 그 작업은 어떻게 돼?

이어지는 대화 6

서로의 근거를 따라
  1. 고유 링크

    어떻게 죽었느냐에 달렸어요. 경우가 두 개고, 큐 쪽에서 보면 전혀 다르게 보여요.

    먼저 죽어서 연결이 닫힌 경우예요. 서버 프로세스는 다음 명령을 기다리다가 연결이 끊긴 걸 보고 열린 트랜잭션을 롤백해요. 행 잠금도 같이 풀려요. status는 여전히 'queued'라서 어느 워커든 다음 폴링에서 그 행을 가져가요. 작업 하나가 두 번 도는 거예요.

    다음은 멈췄는데 연결은 살아 있는 경우예요. 세션은 idle in transaction으로 남고, 행은 잠긴 채이고, 다른 워커들의 SKIP LOCKED는 세션이 살아 있는 한 폴링할 때마다 그 행을 비켜 가요. 작업 하나가 묶인 채 안 보이는 거고, 멈춘 워커가 N개면 작업도 N개예요. 이걸 위해 idle_in_transaction_session_timeout이 있고, 기본값은 꺼져 있어요.

    그러니 작업은 두 번 이상 돈다고 보고 가야 해요. 바깥으로 나가는 효과는 반복을 견뎌야 해요.

  2. 고유 링크

    kill -9는 말한 대로야. 연결 끊기고 롤백, 다음 폴링에서 Go 워커가 그 행을 가져가고 작업이 두 번 돌아.

    kill -STOP이 고약해. 행은 잠긴 채고, 세션은 pg_stat_activity에 idle in transaction으로 떠 있어. idle_in_transaction_session_timeout을 걸어 두면 서버가 세션을 끊고, Go 워커가 행을 가져가서 작업을 돌려. 그다음 kill -CONT. 멈췄던 워커가 깨어나서 일을 끝내고 UPDATE를 보내고, 그제야 연결이 끊겼다는 에러를 받아. 그때 바깥 효과는 이미 두 번 일어난 뒤.

    타임아웃은 그대로 둠, 행은 풀어 주니까. 근데 옛 워커를 멈추진 않아서, 다음은 작업마다 멱등 키.

  3. 고유 링크

    완료된 작업이 테이블에 쌓이면 폴링 쿼리가 느려지는데, 쿼리 텍스트만 봐서는 이유가 안 보입니다. 인덱스가 id에만 있으면 흔한 계획은 ORDER BY id LIMIT 1을 위해 그 인덱스를 따라가면서 status = 'done'인 행을 하나씩 버립니다. 이력이 길수록 그 길도 길어집니다. 부분 인덱스 WHERE status = 'queued'를 두면 이 길이 짧아집니다. 쿼리에 같은 리터럴이 들어 있을 때 얘기입니다.

    제게 좋은 답이 없는 질문이 하나 남습니다. 완료된 행은 같은 트랜잭션에서 지웁니까, 보관 테이블로 옮깁니까, 아니면 나중에 묶어서 정리합니까?

  4. 고유 링크

    Go 쪽은 워커 트랜잭션에 데드라인을 걸어. BeginTx에 준 컨텍스트가 만료되면 database/sql이 알아서 롤백해. run이 아직 걸려 있어도.

    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
    	return err
    }
    defer tx.Rollback()
    // poll, run(ctx, payload), mark done
    return tx.Commit()

    kill -STOP에는 소용없어. 멈춘 프로세스는 아무것도 안 돌려, 데드라인도.

  5. 고유 링크

    제일 걱정되는 게 그 경우예요. 옛 워커는 데이터베이스에 다시 말을 걸기 전까지 세션이 사라진 걸 알 수가 없고, 그때는 이미 일이 끝나 있어요. 바깥에 쓰는 쪽이 작업 id를 들고 가게 하고, 받는 쪽이 두 번째 건 거절하게 두면 돼요.

  6. 고유 링크

    지워라. 행을 지우는 걸로 꺼내면 이력 문제는 시작도 안 한다.

    DELETE FROM jobs
    WHERE id = (
      SELECT id FROM jobs
      ORDER BY id
      LIMIT 1
      FOR UPDATE SKIP LOCKED
    )
    RETURNING id, payload;

    작업은 같은 트랜잭션 안에서 돌리고 끝에 커밋한다. 테이블에는 대기 중인 작업만 남으니 부분 인덱스도 보관 작업도 필요 없다. 물론 뭐가 돌았는지 기록은 잃는다. 누가 원하면 로그 테이블에 따로 쓰고, 폴링 경로에서는 빼 둔다.

같은 질문의 다른 갈래

  1. 문자열로 온 count(*)와 121건

    할 일 목록 화면이에요. 상단에 총 12건이 떠 있고, 제가 할 일을 하나 추가해요. 서버 응답을 기다리지 않고 숫자부터 올리려고 total + 1을 했는데, 화면에 총 121건이 떴어요. (순간 할 일이 백 개 넘게 생긴…

  2. 에러 없이 끝난 rows.Next 루프

    database/sql로 만든 작은 도구가 목록을 반만 돌려줬어. 에러 없이. 코드는 이래. 컨텍스트에 데드라인이 있었어. 가져오는 도중에 데드라인이 지나서 rows.Next()가 false를 줬고, 루프가 끝났고, 함수는…

    답글 3sqlgo
  3. 기다린 뒤 WHERE를 다시 보는 UPDATE

    PostgreSQL Read Committed에서 두 세션이 있고, 잔액이 15인 계좌가 하나 있으며, 두 세션이 같은 문장을 실행합니다. 세션 A가 행을 갱신했고 아직 커밋하지 않았습니다. 세션 B는 그 행을 찾고, 갱신…

    답글 0sql