Article

🐘 PostgreSQLのSKIP LOCKEDでジョブの二重取得を防ぐ:複数ワーカーと障害復旧

postgresql, sql, backend, database

バックグラウンドで通知送信やデータ集計を行うシステムでは、複数のワーカー(処理を担当するプロセス)が同じジョブ(仕事)を取りに行くことがあります。このとき何も工夫しないと、同じジョブを複数のワーカーが処理してしまう可能性があります。

PostgreSQLのFOR UPDATE SKIP LOCKEDを使うと、ほかのトランザクションがロック中の行を待たずに飛ばせます。PostgreSQL 18の公式ドキュメントをもとに、ジョブ取得から障害復旧までを整理します。

まず何が問題か

-- ワーカーAとワーカーBがほぼ同時に実行
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1;

-- その後、各ワーカーが同じIDを更新してしまう可能性がある
UPDATE jobs SET status = 'running' WHERE id = 1;

SELECT(検索)とUPDATE(更新)を別々に実行すると、その間に別のワーカーが割り込めます。こうした実行順序による不具合を**競合状態(レースコンディション)**と呼びます。

FOR UPDATEは検索した行に行ロックを掛け、競合する更新などを制限します。SKIP LOCKEDを付けると、すでに他のトランザクションにロックされている行は待たずに読み飛ばします。

1. テーブルを用意する

CREATE TABLE jobs (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'running', 'done', 'failed')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  claim_token TEXT,
  claimed_until TIMESTAMPTZ,
  attempts INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX idx_jobs_pending
  ON jobs (created_at, id)
  WHERE status = 'pending';

INSERT INTO jobs (payload)
VALUES ('{"type":"send_notification","user_id":101}');
  • CREATE TABLE:ジョブの情報を保存する表を作成する。
  • JSONB:JSON形式のデータを保存する型。
  • status:pending(待機中)、running(処理中)、done(完了)、failed(失敗)を区別する。
  • claim_token:取得操作ごとにワーカー側で発行する一意の値。
  • claimed_until:処理担当として認める期限。
  • attempts:取得した回数。再試行上限の判定に利用する。
  • CREATE INDEX:検索を効率化するための索引を作る。実際の効果は実行計画で確認する。
  • INSERT:例示用のジョブを登録する。

2. 取得と状態更新を1つのSQLにする

-- $1 には取得操作ごとに生成した一意のトークンを指定
WITH picked AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending'
  ORDER BY created_at, id
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs AS j
SET status = 'running',
    claim_token = $1,
    claimed_until = now() + INTERVAL '5 minutes',
    attempts = j.attempts + 1
FROM picked
WHERE j.id = picked.id
RETURNING j.id, j.payload, j.attempts, j.claim_token;

WITHは検索結果を一時的な名前で参照する構文、RETURNINGは更新後の行をそのまま返す機能です。LIMIT 1は一度に最大1件を選びます。FOR UPDATE SKIP LOCKEDにより、別ワーカーが取得中の行をスキップできます。

SQL全体を1文として実行することで、取り出しとrunningへの状態変更を同じ処理の中で行えます。戻り値が0行でも、ジョブが完全に空とは限りません。ロック中の待機ジョブが飛ばされている可能性があります。

ここで重要なのは、取得したジョブの実処理を始める前にDBのトランザクションをコミットすることです。トランザクションはDB処理を確定・取り消しする単位です。行ロックはトランザクション終了時に解放されますが、runningへの更新はコミット後も残ります。

JDBCでsetAutoCommit(false)を使う場合は、SQL実行後にcommit()で確定し、失敗したらrollback()で取り消します。外部APIの応答を長時間待つ間までロックを保持しないようにします。

3. 完了時はトークンも照合する

-- $1: ジョブID、$2: 取得時のclaim_token
UPDATE jobs
SET status = 'done',
    claimed_until = NULL,
    claim_token = NULL
WHERE id = $1
  AND status = 'running'
  AND claim_token = $2
RETURNING id;

戻り値が0行なら、すでに別ワーカーが担当しているなど、現在の状態が想定と異なる可能性があります。claim_tokenの比較は、期限切れ後に動き出した古いワーカーが新しい担当者の状態を上書きする問題を避けるためです。

4. 処理中にワーカーが落ちたら

取得したまま停止するとrunningのジョブが残ります。これを定期処理で再び待機状態に戻します。

UPDATE jobs
SET status = 'pending',
    claimed_until = NULL,
    claim_token = NULL
WHERE status = 'running'
  AND claimed_until < now()
RETURNING id;

ただし、期限切れは元のワーカーが停止した証拠ではありません。遅延しているだけで、まだ外部APIを呼んでいる可能性があります。このため再取得したワーカーとの間で二重送信が起こり得ます。

claim_tokenが守るのはDB上の完了状態であり、外部システムへの二重リクエストではありません。決済・通知などの副作用には、ジョブIDを使った冪等キー(同じリクエストを再送しても二重処理されないよう識別する値)や外部システム側の重複排除などが別途必要です。リトライ上限を超えたらfailedにする運用も設計します。

よくあるハマりどころ

SKIP LOCKEDはすべてのロックを無視しない

公式ドキュメントでは、NOWAITとSKIP LOCKEDが適用されるのは行ロックであり、必要なテーブルレベルのロックは通常どおり取得すると説明されています。あらゆる待機がなくなるわけではありません。

厳密なFIFOではない

ORDER BY created_at, idで古い仕事を優先しても、先頭の行がロックされていれば、後のジョブを先に取得します。必ず登録順に処理を完了する保証はありません。

一般的な一覧取得には向かない

ロック中の行を飛ばすため、検索結果は表全体の一貫した状態を示すとは限りません。公式ドキュメントでも通常の検索ではなくキューのような用途に適するとされています。

専用のキューサービスとの違い

既存のPostgreSQLを使う小〜中規模のジョブ処理なら構成を増やさずに実装できます。一方、大量のジョブ、高度なリトライ、運用監視などが重要ならAWS SQSなど専用サービスと比較します。SKIP LOCKEDだけで厳密に一度だけ実行を保証できるわけではありません。

まとめ

  • FOR UPDATE SKIP LOCKEDでロック中の行を飛ばす。
  • WITHとUPDATE ... RETURNINGで取得と状態更新をまとめる。
  • 取得トランザクションを短く保ち、外部処理前にコミットする。
  • 期限切れ回収とトークン照合を実装する。
  • 外部副作用の重複は、冪等性を別途設計する。

一次情報・参考資料