こんにちは、Yuriです。
最近、複数のリソースをロックする処理では、「どのデータをロックするか」だけでなく、「どの順番でロックするか」も重要だと知りました。
たとえば、API・バッチ・Webhookが同じデータを更新する場合です。それぞれの処理でロックを取っていても、取得する順番が異なると、互いに相手のロックが解放されるのを待つ状況が起こりえます。
この記事では、その仕組みと、「リソースの種類ごとの順序」「同じ種類の中でのID順」をそろえる考え方を整理します。具体例にはPostgreSQLの行ロックを使います。
ロックしていても処理が進まなくなる理由
ここでは、注文と在庫を更新する架空のシステムを考えます。
APIでは注文を確認してから在庫を更新し、バッチでは在庫を確認してから注文を更新するとします。業務の流れに合わせて、そのままロックを取得すると次の順番になります。
| 処理 | 最初にロックする行 | 次にロックする行 |
|---|---|---|
| API | 注文ID=10 | 在庫ID=20 |
| バッチ | 在庫ID=20 | 注文ID=10 |
それぞれ単独で実行すれば問題なく終わっても、同時に実行すると次の状況が起こりえます。
| 順番 | API | バッチ |
|---|---|---|
| 1 | 注文ID=10のロックを取得 | |
| 2 | 在庫ID=20のロックを取得 | |
| 3 | 在庫ID=20をロックしようとして待機 | |
| 4 | 注文ID=10をロックしようとして待機 |
APIが待っている在庫をバッチが持ち、バッチが待っている注文をAPIが持っています。このように待ち関係が循環する状態がデッドロックです。
PostgreSQLではデッドロックを検出すると、関係するトランザクションのうち1つを中止します。つまり、DBが検出してくれても、アプリケーションの処理がすべて成功するわけではありません。
すべての処理で同じ取得順序を使う
この例では、APIもバッチもWebhookも、次の順序でロックするルールにします。
注文 → 在庫
先ほどと同じ行に対してAPIが注文のロックを先に取得した場合、バッチは最初の注文のロックで待ちます。バッチはまだ在庫をロックしていないため、APIは在庫のロックを取得して処理を進められます。
APIがコミットしてロックを解放したあと、バッチが続けて処理します。待機そのものは発生しますが、先ほどのような待ち関係の循環は作られません。
PostgreSQLの公式ドキュメントでも、複数の対象をロックする際は、すべてのアプリケーションで一貫した順序を使うことがデッドロック対策として挙げられています。
「注文から取得する」こと自体に特別な意味があるわけではありません。大切なのは、同じリソースを扱うすべての処理が、同じルールを守ることです。
同じ種類のリソースではID順もそろえる
種類ごとの順序だけでは、まだ足りません。1つの処理で複数の在庫をロックする場合を考えます。
API: 在庫ID=20 → 在庫ID=30
Webhook: 在庫ID=30 → 在庫ID=20
この場合も、互いに最初のロックを取得するとデッドロックになりえます。
そこで、同じ種類のリソースではIDの昇順でロックするルールを追加します。
1. 注文をID昇順でロックする
2. 在庫をID昇順でロックする
例:
注文ID=10 → 注文ID=11 → 在庫ID=20 → 在庫ID=30
考え方としては、各リソースに次の比較キーを持たせるイメージです。
(リソースの種類に付けた順位, ID)
注文ID=10: (1, 10)
注文ID=11: (1, 11)
在庫ID=20: (2, 20)
在庫ID=30: (2, 30)
このキーで全体の順序を決め、必要な対象を前から取得します。同じIDでも、注文と在庫は別のリソースとして扱います。
ID昇順は、共通の順序を作るための方法の1つです。UUIDや複合キーを使う場合も、処理ごとに比較結果が変わらないルールを決めます。
PostgreSQLで取得順序を明示する例
以下は、必要な注文IDと在庫IDが事前に分かっている場合の、ロック取得部分の例です。各IDは主キーで、対象の行は存在するものとします。
BEGIN;
-- まず注文をID昇順でロックする
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
SELECT * FROM orders WHERE id = 11 FOR UPDATE;
-- 次に在庫をID昇順でロックする
SELECT * FROM inventories WHERE id = 20 FOR UPDATE;
SELECT * FROM inventories WHERE id = 30 FOR UPDATE;
-- ロック取得後の値で、注文状態や在庫数を確認する
-- 条件を満たす場合に、必要なUPDATEを実行する
-- 条件を満たさない場合はROLLBACKする
COMMIT;
説明のため、1行ずつ順番に取得しています。FOR UPDATE の行ロックは通常トランザクション終了まで保持されるため、取得と更新を同じトランザクション内で行います。
実装では、対象IDの重複を取り除き、決めた順番に並べてから、逐次ロックを取得する流れにします。入力の配列順やWebhookのペイロード内の順序を、そのまま取得順序にしないことがポイントです。
また、WHERE id IN (20, 30) のようにIDを並べるだけでは、ロックの取得順序を指定したことにはなりません。複数行をまとめて取得する実装では、使用するDBの仕様と、ORMが発行するSQLを確認する必要があります。
この例は既存行を対象にしています。検索結果が0件の場合、存在しない行をロックできたことにはならないため、存在確認や新規作成の競合対策は別途必要です。
ルールは関数単位ではなく、トランザクション全体で守る
共通のロック取得関数を作っても、呼び出す前に別のロックを取得していると順序が崩れます。
Webhookの処理:
在庫ID=30を更新する
→ 共通関数を呼ぶ
→ 注文ID=10をロックする
→ 在庫ID=20をロックする
共通関数の中が「注文 → 在庫」でも、トランザクション全体では「在庫 → 注文」になっています。明示的なロックだけでなく、UPDATE によって取得する行ロックも含めて考えます。
バッチでも、1件ずつの処理が正しい順番だからといって安心はできません。
1つのトランザクションで複数件を処理:
注文ID=10 → 在庫ID=20 → 注文ID=11 → 在庫ID=30
この順番では、在庫のあとに再び注文を取得しています。全件を同じトランザクションで扱う必要があるなら、必要な注文を先に、次に必要な在庫をそれぞれID順で取得する設計にします。1件ごとにトランザクションを分けられるかは、業務上必要な原子性に合わせて判断します。
途中で追加のロック対象が判明する場合も同様です。すでに取得した対象より前の順番になるなら、そのまま取り足すとルールが崩れます。対象の決め方を見直すか、ロールバックして対象を再確認し、正しい順序で取得し直す設計が必要です。
取得順序以外にも考えること
ロックの強さを途中で変更する場合
取得するリソースが同じでも、複数の処理が共有ロックを持ってから、互いに競合する強いロックへ変更しようとすると、デッドロックになりえます。
更新が必要な対象には、最初からその処理で必要な強さのロックを取得することも考えます。リソースの順序だけでは、ロックの強さを変更する際の競合までは解決できません。
失敗時はトランザクション全体を再実行する
取得順序を統一しても、ほかの更新経路や暗黙に取得されるロックまで含めて、デッドロックが絶対に起きないと言い切ることはできません。
PostgreSQLのデッドロックエラーはSQLSTATE 40P01 です。再試行する場合は、失敗したSQLだけでなく、読み取りや更新内容の判断を含めたトランザクション全体をやり直します。
アプリケーション側では、再試行の回数に上限を設け、待ち時間を入れるなど、競合が続いたときの扱いも決めておきます。
ロックを持つ時間を短くする
ロックの順序をそろえても、競合する処理の待ち時間は残ります。
外部APIの応答待ちなどをロック取得後に挟むと、ほかの処理も長く待つことになります。ロック中に必要な確認・更新を整理し、トランザクションを長時間開いたままにしない設計にします。
Webhookの重複処理は別に対策する
ロックを取得できるのは一度に1つの処理でも、同じイベントが順番に2回処理されることはあります。
そのため、ロックだけではWebhookの重複処理を防げません。イベントIDの一意制約や処理済み記録などを使い、重複の判定と業務データの更新を原子的に行う設計が必要です。
また、DBのロールバックでは外部APIへの送信は取り消せません。トランザクションを再試行するなら、外部への副作用が重複しないようにすることも別途考えます。
まとめ
複数リソースをロックするときは、ロックする対象に加えて、取得する順番を設計する必要があります。
- リソースの種類ごとに取得順序を決める
- 同じ種類のリソースはID昇順など、共通のルールで並べる
- API・バッチ・Webhookのすべてで同じルールを守る
- 関数の中だけでなく、トランザクション全体の取得順序を見る
- リトライや重複処理への対策も用意する
今回学んだのは、ロックの取得順序は、個別の処理ではなく、同じリソースを扱う処理全体で共有するルールになるということです。
新しいAPIやバッチを追加するときも、「この処理だけで正しく動くか」に加えて、「既存の処理と同時に動いても、ロックの順序がそろっているか」を確認していきたいと思います。


