PostgreSQL のテーブルをジョブキューにするとき:FOR UPDATE SKIP LOCKED とロックが外れるタイミング

複数のワーカーが PostgreSQL のテーブルからジョブを分け合って取る FOR UPDATE SKIP LOCKED を公式ドキュメントで整理しました。ロックが続く範囲、LIMIT と OFFSET、ORDER BY の順序、WITH 内のロック句を扱います。

開発データベースSQL

jobs テーブルに処理待ちのジョブを積んでおき、複数のワーカーに1件ずつ取らせるなら、2つのワーカーが同じ行を同時に取らないようにする必要があります。PostgreSQL では、ドキュメントがその用途に SELECT ... FOR UPDATE SKIP LOCKED を挙げています。この記事は2026年10月10日時点の最新の正式版である PostgreSQL 18(18.6)のドキュメントをもとにしており、以下で引く SKIP LOCKED の説明は、サポート中で最も古い 14(14.24)のドキュメントにも同じ文で載っています。SKIP LOCKED は 9.5 で追加されました。

基本の形

例に使うテーブルです。

CREATE TABLE jobs (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  status  text NOT NULL DEFAULT 'pending',
  payload jsonb NOT NULL
);

ワーカーはトランザクションを開き、待機中のジョブを1件ロックしながら取ります。

BEGIN;

SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- アプリケーションでジョブを処理したあと、上で受け取った id で
UPDATE jobs SET status = 'done' WHERE id = 42;

COMMIT;

SKIP LOCKED がなければ、2つ目のワーカーは1つ目のワーカーがロックした行の前で、そのトランザクションが終わるまで待ちます。NOWAIT を付けると、待たずにエラーを返します。SKIP LOCKED を付けると、すぐにロックできない行を飛ばすので、2つ目のワーカーは次の待機行を受け取ります。

ドキュメントはこの動作を条件付きで勧めています。ロックされた行を飛ばすとデータの一貫しない姿を見ることになるため汎用的な処理には向かず、複数の消費者がキューのようなテーブルにアクセスするときのロック競合を避けるのに使える、と書かれています。また NOWAIT と SKIP LOCKED は行レベルロックにだけ効き、テーブルレベルの ROW SHARE ロックは通常どおり取得されます。テーブルロックの待ちも避けたいなら、先に LOCK ... NOWAIT を使うよう案内されています。

ロックはトランザクションの終わりに外れる

FOR UPDATE で取った行ロックは、現在のトランザクションが終わるまで、他のトランザクションがその行をロック・更新・削除できないようにします。行レベルロックはトランザクションの終了時か、セーブポイントへのロールバック時に解放されます。

ここでよくある間違いがあります。BEGIN なしで実行した文はオートコミットモードでそれ自体が1つのトランザクションになり、文の終わりにコミットされます。そのため上の SELECT ... FOR UPDATE SKIP LOCKED を BEGIN なしで送ると、結果を受け取った時点でロックはもう外れていて、別のワーカーが同じ行を取れてしまいます。ドライバーや ORM がオートコミットで動いているかを確かめておくべき理由です。

逆にトランザクションを開いたままジョブを処理すれば、処理中に失敗してロールバックしたとき status の変更も取り消され、ロックも外れるので、その行は別のワーカーが取れる pending のまま残ります。その代わり、処理にかかる時間だけトランザクションが開いたままになります。ドキュメントには、デッドロックでない限りロックを待つトランザクションは無期限に待つので、ユーザー入力を待つ場合のようにトランザクションを長く開いておくのはよくない、とあります。

処理に時間のかかるジョブなら、短いトランザクションで状態を running に変えてコミットし、トランザクションの外で処理する構成も考えられます。この場合、ワーカーが処理の途中で落ちるとその行は running のまま残るので、そうした行を戻す仕組みを別に用意する必要があります。この2つの構成の比較はドキュメントの記述ではなく、上の動作から導いたものです。

状態の条件は確かめ直される

2つのワーカーがほぼ同時に同じクエリを実行すると、一方が処理を終えて done でコミットした行が、もう一方のクエリのスナップショットではまだ pending に見えることがあります。ドキュメントによると、トップレベルの SELECT にロック句があるとき、クエリのスナップショット時点で条件に合っていた行はロックされますが、スナップショット後に更新されて条件に合わなくなっていれば結果として返されません。デフォルトの分離レベルである READ COMMITTED で行が評価し直される仕組みは READ COMMITTED とスナップショット で扱いました。

分離レベルを REPEATABLE READ や SERIALIZABLE に上げると話が変わります。これらのレベルでは、ロックしようとする行がトランザクション開始後に変更されていればエラーになります。この記事の例は READ COMMITTED を前提にしています。

LIMIT はよく、OFFSET は要注意

ロック句と LIMIT を一緒に使うと、ドキュメントによれば LIMIT を満たすだけの行を返した時点でロックも止まります。LIMIT 1 なら、返された1行だけがロックされます。ただし OFFSET で読み飛ばした行はロックされます。ワーカーごとに違う OFFSET を与えて仕事を分けようとすると受け取らない行までロックすることになり、SKIP LOCKED を使うならそもそもそうする必要がありません。

ロック句は集約と一緒には使えません。返す行がテーブルの個々の行とはっきり対応しないからです。

ORDER BY は処理順を保証しない

ORDER BY id は、各ワーカーがロックできる行のうち id が最も小さいものを選ばせるだけです。より小さい id が他のワーカーにロックされていれば飛ばすので、ジョブが始まる順番や終わる順番が id 順になる保証はありません。ロックされたジョブを持つワーカーがロールバックすると、そのジョブは後から積まれたジョブより遅れて処理し直されることもあります。厳密な先入れ先出しが必要なら、この方法だけでは足りません。この段落の結論は、上のドキュメントの内容から導いたものです。

それでも ORDER BY は外さないほうがよいでしょう。ドキュメントは、ORDER BY なしで LIMIT を使うと、同じクエリを繰り返してもどの行の集合が返るかは決まっていないと説明しています。

1つの文で取る:WITH の中のロック句

取得と状態の変更を1つの文にまとめることもできます。

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending'
  ORDER BY id
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'running'
WHERE id IN (SELECT id FROM next_job)
RETURNING id, payload;

ロック句の位置が大事です。ドキュメントによると、ロック句は主クエリが参照する WITH クエリには適用されないので、WITH の中で行をロックしたいならロック句を WITH クエリの中に書く必要があります。UPDATE に付けた RETURNING は実際に更新された行の値をクライアントに返すので、ワーカーはこの1文で取ったジョブの id と payload を受け取れます。

この文も BEGIN なしで送ればオートコミットで終わり、ロックは外れます。そのとき行を他のワーカーから守っているのはロックではなく、コミット済みの running 状態です。先に述べた「短いトランザクションで状態を変える」構成と同じになります。

ロックされた行も普通の SELECT には見える

行レベルロックはデータの読み取りには影響せず、同じ行に書き込もうとする側やロックしようとする側だけを止めます。トランザクションの中でロックしたまま処理する構成なら、SELECT count(*) FROM jobs WHERE status = 'pending' のような監視クエリは処理中のジョブも pending として数えます。

他のデータベース

ドキュメントの互換性の節によると、SQL 標準は FOR UPDATE を DECLARE CURSOR のオプションとしてしか認めておらず、NOWAIT と SKIP LOCKED は標準にありません。同じキーワードに対応する他のデータベースがあっても、動作はそれぞれのドキュメントで確かめる必要があります。この記事の内容は PostgreSQL に限ります。

参考にしたドキュメント

リンクと内容は2026年10月10日に確認しました。

ブログへ