エラーの内容
ログやアプリケーションの出力に以下のような内容が表示されます:
ERROR: deadlock detected
DETAIL: プロセス 12345 はトランザクション 7890 の ShareLock を待機中; プロセス 67890 によりブロック。
プロセス 67890 はトランザクション 12345 の ShareLock を待機中; プロセス 12345 によりブロック。
HINT: クエリの詳細についてはサーバーログを参照してください。
PostgreSQL は一方のトランザクション(「被害者」)を強制終了してサイクルを解消します。もう一方は正常にコミットされ、被害者はロールバックされます。アプリケーションは結果の代わりにこのエラーを受け取ります。
デッドロックの原因
典型的なシナリオ:2つのトランザクションが同じ行を逆の順序でロックする場合です。
-- トランザクション A -- トランザクション B
BEGIN; BEGIN;
UPDATE accounts UPDATE accounts
SET balance = balance - 50 SET balance = balance - 30
WHERE id = 1; WHERE id = 2; -- 行2をロック
-- 次に行1を試みる → Aによりブロック
UPDATE accounts
SET balance = balance + 30
WHERE id = 2; -- 行2を試みる → Bによりブロック
-- デッドロック
どちらのトランザクションも前進できません。PostgreSQL は deadlock_timeout(デフォルト1秒)後にサイクルを検出し、一方を被害者として選択して終了させます。
応急処置:失敗したトランザクションをリトライする
PostgreSQL はすでに被害者をロールバックしています。あとは再実行するだけです。リトライループを追加してください——短いバックオフを挟んだ3回の試行で、ほぼすべての実際のケースに対応できます。
Python (psycopg2)
import psycopg2
from psycopg2 import errors
import time
def run_with_retry(conn, fn, max_retries=3):
for attempt in range(max_retries):
try:
with conn.cursor() as cur:
fn(cur)
conn.commit()
return
except errors.DeadlockDetected:
conn.rollback()
if attempt setTimeout(r, 100 * (attempt + 1)));
} else {
throw err;
}
}
}
}
根本的な修正:一貫した順序で行をロックする
リトライは症状を修正します。デッドロックの発生そのものを防ぐには、常に同じ順序でロックを取得してください——行を操作する前に主キーでソートします。
-- 悪い例: トランザクション A が id=1 → id=2 の順でロック
-- トランザクション B が id=2 → id=1 の順でロック
-- 良い例: 昇順 id で事前に両方の行をロック
BEGIN;
SELECT * FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE; -- 行1、次に行2をロック — 常に同じ順序
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;
COMMIT;
SELECT ... FOR UPDATE ORDER BY id は必要なすべてのロックを予測可能な順序で一度に取得します。このパターンに従う2つのトランザクションは互いにデッドロックしません——一方がもう一方の完了を待つだけです。
トランザクションを短く保つ
長時間実行されるトランザクションはロックを長く保持し、衝突の可能性が高まります。いくつかの簡単なルールでデッドロックの頻度を大幅に削減できます:
- すべての計算はトランザクションを開く前に実行してください——トランザクション内ではなく。
- トランザクション内でネットワーク呼び出しやユーザー入力の待機を行わないでください。
- ジョブキューには
FOR UPDATE SKIP LOCKEDを使用し、他のワーカーがすでに取得した行をスキップするようにしてください。
-- ジョブキュー: 他のワーカーをブロックせずに保留中の行を1件取得
SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
デッドロックが発生しているクエリを特定する
何かを書き換える前に、デッドロックが実際にどこで発生しているか確認してください。2つのアプローチがあります:
方法1 — ロギングを有効にする(再起動後も持続、本番環境の監視に最適):
-- postgresql.conf または ALTER SYSTEM SET
deadlock_timeout = 200ms -- デフォルトは1s; より多く検出するには低く設定
log_lock_waits = on
log_min_duration_statement = 1000 -- 1秒より遅いクエリをログ
方法2 — ライブクエリ(再起動不要、現在ブロックされているものを表示):
SELECT
blocked.pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
修正を確認する
- まず再現する:古い順序で2つの並行トランザクションを実行し、
ERROR: deadlock detectedが発生することを確認します。 - 修正を適用する——一貫したロック順序または事前の
FOR UPDATE。 - テストを再実行する:今度はデッドロックエラーが発生しないことを確認します。
- 本番ログを監視する:デプロイ後、
deadlock detectedを grep します。表示されなくなれば、根本原因に対処できています。
-- スモークテスト: 2つの psql タブを開いて同時に実行
-- タブ 1:
BEGIN;
SELECT * FROM accounts WHERE id IN (1,2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;
COMMIT;
-- タブ 2 (同じロック順序 = デッドロックなし):
BEGIN;
SELECT * FROM accounts WHERE id IN (1,2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance + 30 WHERE id = 1;
UPDATE accounts SET balance = balance - 30 WHERE id = 2;
COMMIT;
タブ2はタブ1がロックを保持している間一時停止し、その後正常に続行します。エラーも被害者もなく——予想される待機して続行する動作だけです。

