PostgreSQLデッドロック調査プレイブック:再現・可視化・恒久対策までの実践手順

本番運用で厄介なのは、エラーが「たまに」しか出ない障害です。PostgreSQL のデッドロックはその代表で、発生頻度は低くてもビジネス影響が大きいことが多いです。決済や在庫更新で発生すると、リトライが雪だるま式に増え、アプリ全体の遅延を引き起こします。

本記事では、デッドロック発生時に現場でそのまま使える手順を、初動対応・再現・恒久対策の順で整理します。

1. まず理解すべき前提

デッドロックは「どちらかが悪い」ではなく、ロック順序が循環したときに必ず起きる現象です。PostgreSQL は循環を検出すると、どちらか一方のトランザクションを強制中断します。

典型的な症状:

  • ERROR: deadlock detected
  • API の一部がランダムに 500 を返す
  • リトライ実装により DB 負荷が上振れ

ここで重要なのは、単純なタイムアウトと混同しないことです。タイムアウトは待ち時間超過、デッドロックは循環待ちです。対策が違います。

2. 初動でやること(5〜15分)

2-1. エラーログの採取

まず、DB 側ログに詳細を出す設定があるか確認します。

1
2
3
SHOW log_lock_waits;
SHOW deadlock_timeout;
SHOW log_min_error_statement;

推奨設定(本番):

log_lock_waits = on
deadlock_timeout = '1s'
log_min_error_statement = error

deadlock_timeout を短めにすることで、待ちが長引いたケースの追跡がしやすくなります。

2-2. 現在のロック状況を確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
SELECT
  a.pid,
  a.usename,
  a.application_name,
  a.state,
  a.query,
  l.locktype,
  l.mode,
  l.granted,
  a.query_start
FROM pg_stat_activity a
JOIN pg_locks l ON a.pid = l.pid
WHERE a.datname = current_database()
ORDER BY a.query_start;

見るべき点は「長く生きているトランザクション」と「granted = false が連鎖している箇所」です。

3. 再現手順を作る(原因特定の最短ルート)

デッドロックは再現しないと直せません。以下のような単純ケースをまず作ります。

セッション A

1
2
3
4
5
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ここで待機
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

セッション B

1
2
3
4
5
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- ここで待機
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;

更新順序が逆なので、容易に循環が起きます。

この再現が取れたら、アプリコード上でも「同じテーブルを複数行更新する順序」が一定かどうかを調べます。

4. 原因の8割は「更新順序の不一致」

多くのプロダクトで見つかるのは次のパターンです。

  1. バッチ処理は id ASC で更新
  2. API リクエストは受信順で更新
  3. 並行処理時に順序が逆転

この場合、解決策は明確で、全経路でロック取得順序を統一します。

例(Node.js / TypeScript):

1
2
3
4
5
const ids = [fromAccountId, toAccountId].sort((a, b) => a - b);

await tx.query('SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE', [ids]);

// その後に更新

FOR UPDATE を先に順序付きで取得することで、アプリ層の揺らぎを DB で吸収できます。

5. 実装レベルの対策パターン

5-1. トランザクションを短くする

デッドロックは「ロック保持時間」が長いほど発生しやすくなります。トランザクション内で外部 API 呼び出しをしていないか確認してください。これは最優先で排除します。

5-2. 失敗時のリトライを制御する

無制限リトライは障害増幅器です。指数バックオフ + 上限回数で制御します。

1
2
3
4
5
6
7
8
for (let attempt = 1; attempt <= 3; attempt++) {
  try {
    return await runTx();
  } catch (e: any) {
    if (!String(e.message).includes('deadlock detected') || attempt === 3) throw e;
    await sleep(50 * 2 ** attempt);
  }
}

5-3. 楽観ロックの導入

更新競合が多い領域では version カラムを使った楽観ロックが有効です。衝突時にアプリで再計算できます。

6. 監視・運用面の改善

恒久対策を完了させるには、再発を検知できる状態を作る必要があります。

推奨メトリクス:

  • deadlock 発生回数 / 分
  • lock wait 時間 p95
  • 失敗リトライ回数
  • 長時間トランザクション件数

SQL 例(長時間 tx 検知):

1
2
3
4
5
SELECT pid, now() - xact_start AS tx_age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '30 seconds'
ORDER BY tx_age DESC;

この結果を可視化し、閾値超過で通知するだけでも再発時の初動が速くなります。

7. 障害対応時の意思決定フレーム

デッドロックが継続しているとき、現場は次の順序で判断すると迷いません。

  1. 影響範囲(どの API / どの機能か)を確定
  2. 失敗処理を一時停止できるか判断(バッチ停止、機能フラグ)
  3. ロック順序統一のホットフィックス可否
  4. デプロイまでの間、リトライ制御で被害抑制

「根本修正が間に合わない」ケースでも、リトライと負荷制御でユーザー影響を減らせます。

8. よくあるアンチパターン

アンチパターン1: SERIALIZABLE に上げて解決した気になる

隔離レベルを上げるだけでは、設計上の競合は消えません。むしろリトライ増加で負荷が悪化することがあります。

アンチパターン2: SELECT ... FOR UPDATE を乱用する

広範囲ロックは別の待ちを生みます。最小対象だけをロックし、順序を統一することが本質です。

アンチパターン3: アプリログだけ見て DB ログを見ない

デッドロックの循環情報は DB 側ログにしか出ないことが多いです。必ず DB ログを一次情報として扱ってください。

9. すぐ使えるチェックリスト

  • deadlock detected の実例ログを保存した
  • ロック状況を pg_stat_activitypg_locks で取得した
  • 再現 SQL を作成し、原因パターンを確認した
  • 更新順序を全経路で統一した
  • リトライ回数とバックオフを制限した
  • 監視メトリクスを追加した

まとめ

PostgreSQL デッドロック対策は、魔法の設定値を探す作業ではありません。ポイントは一貫しており、

  • ログで事実を取る
  • 再現を作る
  • ロック順序を統一する

この 3 ステップで大半の問題は改善します。障害対応時は焦って設定を増やすより、まず「どの順序で誰がロックを取ったか」を可視化することが最短ルートです。

10. チーム運用に落とし込むためのルール化

技術対策ができても、運用ルールがないと再発します。特に効果が高いのは「PR テンプレートにロック観点を入れる」ことです。たとえば 複数行更新の順序は統一されているかトランザクション内で外部I/Oをしていないか をチェック項目にするだけで、設計時点で多くの問題を防げます。

さらに、負荷試験シナリオに競合ケース(同時更新)を追加してください。通常の性能試験は平均応答時間を見るだけで終わりがちですが、デッドロックは並行競合を作らないと検出できません。QA と開発が協調して「再現しにくい障害を再現するテスト」を用意できると、運用品質が一段上がります。