同時実行に安全なワークフローのためのPostgreSQLアドバイザリロック
承認・請求・スケジューラでの二重処理を防ぐためのPostgreSQLアドバイザリロックを、実践的パターン、SQL例、簡単なチェックとともに学びます。

本当の問題:同じ処理が二度行われること
二重処理とは、異なるアクターがそれぞれ自分が処理すべきだと判断して同じ項目を二度扱ってしまう現象です。実際のアプリでは、顧客に二重課金される、承認が二度適用される、あるいは「請求書が準備できました」メールが二回送られるといった問題として現れます。テストでは問題なく見えても、本番トラフィックで壊れることがあります。
これは、タイミングがシビアになり複数の実行体が同時に動けてしまうと起きます。
- 2つのワーカーが同じジョブを同時に拾う。ネットワーク呼び出しが遅くてリトライが発生し、最初の試行がまだ走っている。ユーザーがUIのフリーズで承認ボタンを二度押す。デプロイ後や時計ずれでスケジューラが重なる。モバイルアプリがタイムアウト後に再送してしまう。\
厄介なのは、各アクターがそれぞれ「合理的」に振る舞っている点です。バグはその間のギャップにあります:どちらも相手が既に同じレコードを処理していることを知らないのです。
目標は単純です:ある単位(注文、承認リクエスト、請求書)について、重大な処理を同時に行えるのは1つの実行体だけにする。他は短時間待つか、諦めて再試行する。
PostgreSQLのアドバイザリロックはこれに役立ちます。既に信頼しているデータベースを使って「今、項目Xを処理中です」と軽量に宣言できる手段を提供します。
ただし期待値は設定しましょう。ロックは完全なキューシステムではありません。ジョブをスケジュールしたり順序を保証したりメッセージを保存したりはしません。二度実行してはいけないワークフロー部分の周りに付ける安全ゲートです。
PostgreSQLアドバイザリロックとは(そして何ではないか)
PostgreSQLのアドバイザリロックは、同時に一つのワーカーだけが特定の処理を行うことを保証する手段です。ロックキー(例:「invoice 123」)を決め、データベースにロックを要求し、作業を行い、解除します。
「アドバイザリ」という語が重要です。Postgresはキーが何を意味するかを知らず、自動的に何かを保護するわけではありません。追跡するのは「このキーがロックされているかどうか」だけです。コード側でキー形式を合意し、危険な部分を実行する前に必ずロックを取る必要があります。
行ロック(SELECT ... FOR UPDATE)と比べるとわかりやすいです。行ロックは実際のテーブル行を保護します。処理が1行にきれいに対応する場合はそれで十分です。アドバイザリロックはあなたが選んだキーを保護するので、ワークフローが複数テーブルにまたがったり外部サービスを呼んだり、そもそも行がまだ存在しない段階から始まるときに便利です。
アドバイザリロックが有用な場面の例:
- エンティティごとに一度だけ実行したい処理(承認リクエスト1件につき1回、請求書1件につき1回)
- 追加のロックサービスを増やさずに複数のアプリサーバ間で調整したいとき
- 単一の行更新より大きなワークフローステップの周りを保護したいとき
ただし他の安全手段の代替ではありません。アドバイザリロックは操作を冪等にしたりビジネスルールを強制したりはしませんし、ロックを取ることを忘れた経路があれば重複は防げません。
「軽量」と呼ばれるのは、スキーマ変更や追加インフラ無しで使えることが多いからです。多くの場合、重要なセクションの周りに1回だけロックを追加するだけで二重処理が止まります。
実際に使うロックの種類
一般に「PostgreSQLのアドバイザリロック」と言うと、いくつかの関数を指します。どれを選ぶかで、エラー・タイムアウト・リトライ時の挙動が変わります。
セッションロック vs トランザクションロック
セッションレベルのロック(pg_advisory_lock)はデータベース接続が続く限り持続します。長時間実行するワーカーには便利ですが、アプリがクラッシュしてプールされた接続が残るとロックが延々と残ることがあります。
トランザクションレベルのロック(pg_advisory_xact_lock)は現在のトランザクションに結びつき、コミットかロールバックで自動的に解放されます。承認や請求のクリック、管理者操作のようなリクエスト/レスポンス系ワークフローでは、解放忘れが起きにくいのでこちらが安全なデフォルトです。
ブロッキング vs try-lock
ブロッキング呼び出しはロックが取れるまで待ちます。単純ですが、他のセッションがロックを持っているとWebリクエストが詰まってしまう可能性があります。
try-lock は即座に戻ります:
pg_try_advisory_lock(セッションレベル)pg_try_advisory_xact_lock(トランザクションレベル)
UI操作では try-lock の方が向くことが多いです。ロックが取れなければ「既に処理中」のような明確なメッセージを返してユーザーに再試行を促せます。
共有ロック vs 排他ロック
排他ロックは「一度に一人」です。共有ロックは複数ホルダを許し、排他ロックをブロックします。二重処理の大多数は排他ロックで解決できます。共有ロックは多数の読み取りが許され、稀に一人の書き手が単独で実行する必要がある場合に便利です。
ロックの解放方法
種類によって異なります:
- セッションロック:切断時に解放されるか、
pg_advisory_unlockで明示的に解放 - トランザクションロック:トランザクション終了時に自動解放
適切なロックキーの選び方
アドバイザリロックは、すべてのワーカーがまったく同じキーをロックしに行くときにのみ機能します。ある経路が「invoice 123」をロックし、別の経路が「customer 45」をロックしていれば、重複は起きます。
保護したい「もの」を具体化してください。1つの請求書、1件の承認リクエスト、1回のスケジュール実行、あるいは1人の顧客の月次請求サイクルなど、その選択が許容する並列度を決めます。
リスクに合うスコープを選ぶ
多くのチームは次のいずれかを選びます:
- レコード単位:承認や請求書に安全(
invoice_idやrequest_idでロック) - 顧客/アカウント単位:顧客ごとに処理を直列化したい場合(請求や与信の変更)
- ワークフローステップ単位:異なるステップは並列で良いが、各ステップは一度に一つであるべき場合
スコープはデータベースの細部ではなくプロダクトの判断です。レコード単位は二重クリックで二重請求されるのを防ぎ、顧客単位はバックグラウンドジョブが重複した明細を作るのを防ぎます。
安定したキー戦略を選ぶ
一般に選択肢は2つの32ビット整数(ネームスペース + id)か、1つの64ビット整数(bigint)で、文字列IDをハッシュして作ることもあります。
2つの整数キーは標準化しやすいです:ワークフローごとに固定のネームスペース番号を決め、レコードIDを2番目に使います。
UUIDを使っている場合はハッシュが便利ですが、衝突の小さなリスクを受け入れ、一貫して使う必要があります。
どちらを選ぶにしても、フォーマットを書き残して中央化してください。ほとんど同じキーが別々の場所にある、というのが重複を再導入するよくある原因です。
ステップバイステップ:一度に1つの処理を安全に行うパターン
良いアドバイザリロックのワークフローはシンプルです:ロック、再確認、実行、記録、コミット。ロック自体がビジネスルールではなく、2つのワーカーが同じレコードに同時に来たときにルールを信頼できるようにするガードレールです。
実践的なパターン:
- 結果を原子的にする必要があるときはトランザクションを開く。
- 処理単位に対してロックを取得する。自動解放のためにトランザクションスコープの
pg_advisory_xact_lockを推奨。 - データベース内の状態を再チェックする。自分が最初だとは仮定しない。
- 作業を行い、データベースに永続的な「完了」マーカーを書き込む(ステータス更新、台帳エントリ、監査行)。
- コミットしてロックを開放する。セッションロックを使った場合は、接続をプールに戻す前に明示的にアンロックする。
例:2つのアプリサーバがほぼ同時に「請求書 #123 を承認」を受け取る。どちらか一方だけが 123 に対してロックを取れます。勝った方は invoice #123 がまだ pending であることを確認し、approved にして監査/支払いレコードを作成しコミットします。二番目のサーバは try-lock なら早めに失敗して「現在処理中」と返すか、ロック取得後に状態を再チェックして何もしないで終了します。こうして二重処理を防ぎつつUIの応答性も保ちます。
デバッグのために、各試行を追跡できるように十分なログを残してください:リクエストID、承認IDと計算されたロックキー、アクターID、結果(lock_busy、already_approved、approved_ok)、および実行時間。
アドバイザリロックが役立つ場面:承認、請求、スケジューラ
アドバイザリロックは「特定のものについて、勝者だけが重大な処理を行う」というルールが明確な場合に最も適しています。既存のデータベースとアプリコードをほとんど変えず、小さなゲートを追加するだけで競合が起きにくくなります。
承認
承認は古典的な競合トラップです。2人のレビュアー(または同じ人の二重クリック)がミリ秒単位で Approve を押すことがあります。リクエストIDをキーにロックすれば、1つのトランザクションだけが状態変更を行います。他は結果を素早く受け取り「既に承認済み」や「既に却下済み」と表示できます。
これは多くの顧客ポータルや管理画面でよくある状況です。
請求
請求は通常より厳格なルールが必要です:リトライがあっても請求書1件につき1回の支払い試行のみ。ネットワークタイムアウトでユーザーが再度支払ボタンを押す、バックグラウンドの再試行が最初の試行と重なる、という状況が典型です。
請求書IDをキーにロックすれば、支払いプロバイダへの呼び出しは同時に1つだけになります。二番目の試行は「支払い中」と返すか最新の支払い状況を読み取るようにして、二重処理や二重請求のリスクを下げます。
スケジューラとバックグラウンドワーカー
マルチインスタンス構成では、スケジューラが同じウィンドウを重複して実行してしまうことがあります。ジョブ名と時間窓(例: daily-settlement:2026-01-29)をキーにすれば、同じウィンドウは一つのインスタンスだけが実行します。
テーブルからアイテムを引いて処理するワーカーでも、アイテムIDでロックすれば一度に一人だけが処理できます。
よく使われるキー例:単一の承認リクエストID、請求書ID、ジョブ名+時間窓、顧客ID(「同時エクスポートを一つだけ」)、あるいはリトライ用の一意の冪等キー。
実例:ポータルでの二重承認を止める
承認リクエストがポータルにあって、2人のマネージャが同じ秒に Approve をクリックした状況を想像してください。保護が無ければ両方とも「pending」を読み取り、両方が「approved」を書き込み、監査エントリや通知が二重に作られたり下流処理が二度走ったりします。
PostgreSQLアドバイザリロックを使えば、承認ごとに一度だけ行われるように単純にできます。
フロー
APIが承認アクションを受け取ったらまず承認IDを元にロックを取ります(別の承認は並列処理できるように)。
一般的なパターンは:承認IDでロックを取り、現在のステータスを読み、更新し、監査レコードを書き、すべてを一つのトランザクションで行います。
BEGIN;
-- One-at-a-time per approval_id
SELECT pg_try_advisory_xact_lock($1) AS got_lock; -- $1 = approval_id
-- If got_lock = false, return "someone else is approving, try again".
SELECT status FROM approvals WHERE id = $1 FOR UPDATE;
-- If status != 'pending', return "already processed".
UPDATE approvals
SET status = 'approved', approved_by = $2, approved_at = now()
WHERE id = $1;
INSERT INTO approval_audit(approval_id, actor_id, action, created_at)
VALUES ($1, $2, 'approved', now());
COMMIT;
二回目のクリックが見るもの
二番目のリクエストはロックが取れず早く「既に処理中」と返るか、最初が終わった後にロックを取ってステータスを見て「すでに承認済み」で何もせず終了します。どちらにしても二重処理は避けられ、UIの応答性も維持されます。
デバッグ用には試行ごとに十分なログを残してください:リクエストID、承認IDと計算したロックキー、アクターID、結果(lock_busy、already_approved、approved_ok)、および所要時間。
待ち時間、タイムアウト、再試行をアプリをフリーズさせずに扱う
ロック待ち自体は無害に聞こえますが、ボタンがぐるぐる回る、ワーカーが詰まる、バックログが解消しないなどの問題に発展することがあります。人が待っている場合は早めに失敗させ、待機して良い場面だけ待つルールにしましょう。
ユーザー操作では:try-lock して明確に応答する
誰かが Approve や Charge をクリックしたら数秒もブロックしないでください。try-lock を使い、ロックが取れなければすぐに「ビジー、後でもう一度」と返すか項目をリフレッシュさせます。ロック保持区間は短く:状態検証、状態変更、コミットの流れにしてください。
バックグラウンドジョブでは:ブロッキング可だが上限を設ける
スケジューラやワーカーではブロッキングが許容されますが、上限が必要です。そうしないと遅いジョブが艦隊全体を停滞させます。
例えば:
SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);
またジョブ自体の最大想定実行時間を決めておいてください。請求が通常10秒以内に終わるなら、2分はインシデント扱いにするなど。開始時間、ジョブID、ロック保持時間をトラックし、ランナーがキャンセルをサポートするなら上限を超えたタスクをキャンセルしてセッションを終わらせロックを解放します。
再試行ポリシーも設計しておきましょう。ロック取得に失敗したらどうするか:少し後にバックオフ付きで再スケジュールする(ランダム性を入れる)、このサイクルはスキップする、繰り返し失敗する場合は対象を「競合中」とマークして人手対応する、など。
詰まったロックや重複を招くよくある失敗
一番多いのはセッションレベルロックが解放されないことです。接続プールは接続を開いたままにするので、セッションロックを取ってアンロックを忘れると、接続がリサイクルされるまでロックが残ります。他のワーカーは待たされるか失敗し、原因の追跡も難しくなります。
またロックを取っているのに状態をチェックしていないパターンもあります。ロックはただ一度に一人しかクリティカルセクションを通さないだけで、レコードがまだ処理対象かどうかを保証しません。必ず同じトランザクション内で pending かどうかなどを再確認してください。
ロックキーもチームを躓かせます。あるサービスが order_id でロックし、別のサービスが同じ実世界のリソースに別の計算済みキーでロックしていると、両方が同時に動けてしまいます。これが安全性の誤認を生みます。
ロックが長時間維持されるのはほとんど自業自得です。ロック中に遅いネットワーク呼び出し(決済プロバイダ、メール/SMS、Webhook)を行うと、短いガードレールがボトルネックになります。ロック中は速く終わるDB中心の作業(状態検証、状態書き込み、次にやるべきことの記録)に絞り、副作用はコミット後にトリガーしてください。
最後に、アドバイザリロックは冪等性やデータベース制約の代わりにはなりません。信号機のような役割に留め、適切な場所では一意制約や外部呼び出しの冪等キーを併用してください。
出荷前の簡単チェックリスト
アドバイザリロックは小さな契約のように扱ってください:チーム全員がロックが何を意味するか、何を保護するか、ロック中に何が許されるかを知っている必要があります。
短いチェックリスト(大抵の問題を避けます):
- リソースごとに一意で明確なロックキーを決め、書き残して再利用する
- 取り返しのつかない処理(支払い、メール、外部API呼び出し)の前にロックを取得する
- ロック取得後に状態を再チェックしてから変更を書く
- ロック中の処理は短く測定可能にする(ロック待ちと実行時間をログ)
lock busyを各経路でどう扱うか決める(UIメッセージ、バックオフ付き再試行、スキップ)
次のステップ:パターンを適用して保守しやすくする
まずは重複が最も問題になる箇所を一つ選んで始めてください。最初のターゲットとしては金銭に関わる、あるいは状態を恒久的に変えるアクション(例:「請求書を課金」「リクエストを承認」)が適切です。その重要なセクションだけをアドバイザリロックで包んで挙動を確認し、安定したら近接するステップへ広げていきます。
早期に基本的な可観測性を追加してください。ワーカーがロックを取得できなかったとき、ロック保持時間がどれくらいかをログに残します。ロック待ちが急増したら、通常はクリティカルセクションが大きすぎるか、遅いクエリが隠れているサインです。
ロックはデータの安全性の上に乗せて使うと最も効果的で、代わりに使うものではありません。明確なステータスフィールド(pending、processing、done、failed)を維持し、可能なら制約で裏付けてください。最悪のタイミングでリトライが起きても、一意制約や冪等キーが第二の防御線になります。
もし AppMaster (appmaster.io) でワークフローを作っているなら、同じパターンを適用できます。重要な状態変更を1つのトランザクション内に保ち、「最終化」ステップの前にトランザクションレベルのアドバイザリロックを取る小さなSQLステップを追加してください。
アドバイザリロックは、優先度、遅延ジョブ、デッドレター処理などの本格的なキュー機能が必要になるまで十分に適しています。高い競合や複雑な並列化が必要になったり、共有Postgresを跨いで調整する必要が出てきたり、より厳格な分離が必要になるまでは、この単純なパターンで「退屈な信頼性(boring reliability)」を目指してください:小さく、一貫して、ログで見えるように、そして制約で裏付ける。
よくある質問
特定の単位(リクエストの承認、請求書の課金、スケジュールウィンドウの実行など)に対して「同時に一人だけ」が必要な場合にアドバイザリロックを使います。特に複数のアプリインスタンスが同じ項目に触れる可能性があり、別のロックサービスを導入したくないときに有効です。
行ロック(SELECT ... FOR UPDATE)は実際のテーブル行を保護し、処理がきれいに1行に対応する場合に優れています。アドバイザリロックはあなたが決めたキーを保護するので、複数テーブルにまたがる処理や外部サービス呼び出し、最終行がまだ存在しない段階からの処理にも使えます。
リクエスト/レスポンス系の操作には、コミットやロールバック時に自動で解放される pg_advisory_xact_lock(トランザクションレベル)をデフォルトにするのが安全です。トランザクションをまたいでロックを保持する必要があり、かつ接続をプールへ返す前に必ず pg_advisory_unlock を呼べる自信がある場合にのみ pg_advisory_lock(セッションレベル)を使ってください。
UI操作では pg_try_advisory_xact_lock のような try-lock を使い、失敗したらすぐに「既に処理中」と返す方が良いです。バッチやバックグラウンドワーカーではブロッキングロックでも構いませんが、lock_timeout などで上限を設けてください。これで一つの遅いジョブが全体を停滞させるのを防げます。
二重に実行してはいけない最小単位をロックしてください。通常は「1件の請求書」や「1件の承認リクエスト」です。顧客単位でロックするとスループットが落ちるかもしれませんし、スコープが狭すぎると重複が残ることがあります。プロダクト判断として決めてください。
一貫したキー形式を選び、それを同じ重要操作を実行するすべての場所で使ってください。よくある方法は2つの整数(ネームスペース + エンティティID)で、異なるワークフローが互いにブロックしないようにします。
いいえ。ロックは同時実行を防ぐだけで、操作が繰り返し安全であることを保証するものではありません。トランザクション内で状態を再確認し、適切な場所で一意制約や冪等性キーを使ってください。
ロック中の処理は短くデータベースに集中するようにしてください:ロック取得 → 対象が有効か再確認 → 新しい状態を書き込む → コミット。決済やメール、Webhookなど遅い外部呼び出しはコミット後に行うか、アウトボックス方式で処理してください。これでロック保持時間を短くできます。
多くの場合、プールされた接続に対してセッションレベルロックを取り、接続を返す際にアンロックされなかったことが原因です。そのため、リクエスト終了後もロックが残ってしまいます。トランザクションレベルを使うか、どうしてもセッションレベルを使う場合は pg_advisory_unlock が確実に呼ばれるようにしてください。
エンティティIDや計算したロックキー、ロック取得の可否、取得にかかった時間、トランザクション実行時間、結果(lock_busy、already_processed、processed_ok など)をログに残すと良いです。これで単なる競合と実際の重複を区別できます。


