Postgres: FOR UPDATE SKIP LOCKED でテーブルをジョブキューにする
ワーカーが SELECT ... FOR UPDATE SKIP LOCKED LIMIT n でジョブを取ると、ほかのワーカーがロック中の行は飛ばして次の行を取る。ワーカー同士が互いの行で待たされず、同じジョブを二重に取ることもない。
使う場面
Redis や専用のキューを足すほどではない規模のバックグラウンドジョブ(メール送信、画像変換、外部 API の呼び出しなど)。業務データと同じ DB に置けるので、ジョブの登録を業務データの更新と同じトランザクションで書ける。
やり方
sql
-- ワーカーごとに 1 トランザクション
BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'ready'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10;
-- 取れた行を処理中にして、すぐコミットする
UPDATE jobs SET status = 'running', started_at = now()
WHERE id = ANY($1);
COMMIT;
-- 実際の処理はトランザクションの外で行い、終わったら status = 'done' に更新する1 文で書くなら次の形もよく使われる。
sql
UPDATE jobs SET status = 'running', started_at = now()
WHERE id IN (
SELECT id FROM jobs
WHERE status = 'ready'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
RETURNING id, payload;インデックスは「ready の行を id 順に探す」ために付ける。
sql
CREATE INDEX jobs_ready_idx ON jobs (id) WHERE status = 'ready';注意点
- ジョブを取るトランザクションは短くする。処理の間ずっと行ロックを持つと、接続を占有し、VACUUM も遅れる。上の例のように「取って running にしてコミット」まででトランザクションを閉じる。
- その代わり、ワーカーが途中で落ちると running のまま残る。
started_atが古い running の行を ready に戻す見回り処理を用意する。処理は二回実行されても壊れない(冪等な)形にしておく。 SKIP LOCKEDを付けると、ロック中の行は結果から黙って消える。取り出しの順序は厳密ではなくなるので、順序が重要な処理には向かない。- 終わった行を残し続けるとテーブルが膨らむ。done の行は定期的に消すか別テーブルへ移す。
- PgBouncer の transaction モードでも使える(行ロックはトランザクションの中で完結するため)。セッション単位の機能との違いは Postgres: PgBouncer の transaction モードではセッション状態が別の接続に漏れる。SET LOCAL と xact 系ロックを使う を参照。イベントを外部へ確実に送る用途は Transactional outbox: DB の更新とイベント送信を同じトランザクションに入れる が同じ仕組みを使う。
- キューのテーブルを作るほどでもなく「夜間バッチは同時に 1 つだけ」としたいだけなら、アドバイザリーロックで足りる。
SELECT pg_try_advisory_lock(hashtext('nightly-report'))が false なら、ほかのワーカーが実行中なのでそのまま終了する(@james_ailton)。PgBouncer の transaction モードではセッション単位のロックが使えないので、トランザクションの中でpg_try_advisory_xact_lockを使う。