データベースのデッドロックとは、2つ以上のトランザクションが、お互いに相手の持っているロックの解放を待ち続け、どちらも先に進めなくなる状態です。 多くのDBは自動で検知し、片方のトランザクションをエラーで終わらせて解消します。原因の多くは「複数の行やテーブルを、処理ごとに違う順番で更新している」ことです。

この記事では、PostgreSQL 16で2つのセッションを使ってデッドロックを実際に再現し、アプリに返るエラー(SQLSTATE 40P01)、サーバーログのDETAIL、pg_locksの出力をそのまま掲載します。さらに、更新順を揃える前と後で発生件数と処理時間を比較し、Pythonでのリトライ実装、MySQLでの確認方法、予防チェックリストまでまとめました。

デッドロックの原因と対策の早見表

原因 起きる例 対策
更新の順番が処理ごとに違う 注文Aは商品1→2、注文Bは商品2→1の順に在庫を減らす ID順など、ロックを取る順番を統一する
トランザクションが長い 更新のあとで外部APIを呼び、COMMITが遅れる ロック区間を短くし、重い処理はロックの外で
必要以上の行をロックする インデックスがなく、広い範囲をスキャンして更新 WHERE句の列にインデックスを用意する
外部キー・一意制約の確認で暗黙のロックが入る 親行の更新と子行の追加が同時に走る 親→子など、テーブル間の順番も統一する
待ち行列がまとめてぶつかる バッチとオンライン処理が同じ行を逆順に更新 実行時間帯や更新単位を分ける
(発生は完全には防げない) どれだけ注意しても低い確率で起きうる エラーを捕まえてトランザクションごと再試行する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「デッドロックの原因と対策|PostgreSQLで再現した実ログで解説」

デッドロックが起きるしくみ

典型パターン:逆順の更新

在庫テーブルstockの商品1と商品2を、2つの注文が逆の順番で更新すると、次のように待ちが輪(循環)になります。

sequenceDiagram
    participant A as セッションA
    participant DB as stock
    participant B as セッションB
    A->>DB: UPDATE item_id=1(行1をロック)
    B->>DB: UPDATE item_id=2(行2をロック)
    A->>DB: UPDATE item_id=2 → Bの解放待ち
    B->>DB: UPDATE item_id=1 → Aの解放待ち
    Note over A,B: お互いに待ち続ける=デッドロック
    DB-->>A: 検知して片方をエラーで終了(40P01)

発生の4条件

デッドロックは、次の4つがそろったときに起きます(コフマンの条件)。

条件 意味 DBでの状況
相互排他 1つの資源を同時に1人しか使えない 行ロックは1トランザクションだけが持てる
保持と待機 何かを持ったまま、別のものを待つ 行1を持ったまま行2を待つ
横取りなし 相手のロックを奪えない COMMIT/ROLLBACKまで解放されない
循環待ち 待ちが輪になる A→B→Aの待ち

実務で崩しやすいのは「循環待ち」です。 ロックを取る順番をすべての処理で揃えれば、輪ができなくなります。

PostgreSQL 16でデッドロックを再現する

準備

CREATE TABLE stock (item_id int PRIMARY KEY, qty int NOT NULL);
INSERT INTO stock VALUES (1, 100), (2, 100);

手順(2つのセッションで交互に実行)

順番 セッションA セッションB
1 BEGIN; BEGIN;
2 UPDATE stock SET qty = qty - 1 WHERE item_id = 1;
3 UPDATE stock SET qty = qty - 1 WHERE item_id = 2;
4 UPDATE stock SET qty = qty - 1 WHERE item_id = 2;(待つ)
5 UPDATE stock SET qty = qty - 1 WHERE item_id = 1;(待つ → 約1秒後にどちらかがエラー)

今回はタイミングを正確にそろえるため、この手順をPython(psycopg 3)の2スレッドで実行しました。psqlの画面を2つ開いて、同じ順番で入力しても再現できます。

待ちの状態を pg_locks で見る

手順5の直後(検知される前)に、3つ目の接続からpg_locksを確認しました。

SELECT l.pid, l.locktype, l.mode, l.granted,
       l.transactionid::text AS xid,
       pg_blocking_pids(l.pid) AS blocked_by
  FROM pg_locks l JOIN pg_stat_activity a USING (pid)
 WHERE a.datname = current_database() AND l.locktype = 'transactionid'
 ORDER BY l.pid, l.granted DESC;
pid   | locktype      | mode          | granted | xid  | blocked_by
4921  | transactionid | ExclusiveLock | True    | 932  | [4922]
4921  | transactionid | ShareLock     | False   | 933  | [4922]
4922  | transactionid | ExclusiveLock | True    | 933  | [4921]
4922  | transactionid | ShareLock     | False   | 932  | [4921]

読み方のポイントは次のとおりです。

  • 各セッションは、自分のトランザクションID(xid)に ExclusiveLock を持っています(granted = True)。
  • 4921は、4922のトランザクション933の終了を待っています(ShareLock、granted = False)。
  • 4922は、4921のトランザクション932の終了を待っています。
  • pg_blocking_pids() がお互いを指しているので、待ちが輪になっていることが一目でわかります。

PostgreSQLでは、行の更新待ちは「相手のトランザクションIDの終了待ち」としてpg_locksに表れます。

アプリに返ったエラー

A: DeadlockDetected sqlstate=40P01
   message: deadlock detected
   detail : Process 4921 waits for ShareLock on transaction 933; blocked by process 4922.
Process 4922 waits for ShareLock on transaction 932; blocked by process 4921.
   hint   : See server log for query details.
   context: while updating tuple (0,2) in relation "stock"
B: COMMIT
final: [(1, 99), (2, 99)]

AがSQLSTATE 40P01で終了(ロールバック)し、Bは待ちが解けてCOMMITできました。 最終的な在庫は、Bの分だけ減っています。どちらが終了させられるかはDBが決めるため、アプリはどちら側になっても対応できるようにしておく必要があります。

サーバーログ

サーバーログには、待ち合っていたSQL文まで記録されます。原因の特定には、まずここを見るのが近道です。

ERROR:  deadlock detected
DETAIL:  Process 4921 waits for ShareLock on transaction 933; blocked by process 4922.
    Process 4922 waits for ShareLock on transaction 932; blocked by process 4921.
    Process 4921: UPDATE stock SET qty = qty - 1 WHERE item_id = $1
    Process 4922: UPDATE stock SET qty = qty - 1 WHERE item_id = $1
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,2) in relation "stock"
STATEMENT:  UPDATE stock SET qty = qty - 1 WHERE item_id = $1

(行頭のタイムスタンプ等は省略)

関係する設定

設定 今回の値(既定) 意味
deadlock_timeout 1s ロック待ちがこの時間を超えたら、デッドロックかどうかを調べる
log_lock_waits off onにすると、deadlock_timeoutを超えたロック待ちをログに出す
lock_timeout 0(無効) 1文がロックを待つ上限。超えるとSQLSTATE 55P03

deadlock_timeoutは「検知までの待ち時間」でもあるため、デッドロックが起きると少なくとも約1秒の待ちが発生します。これが後の実測で処理時間に表れます。

調査中は log_lock_waits = on にしておくと、デッドロックに至らない長いロック待ちも見つけられます。lock_timeout を設定した場合のエラーは次のとおりでした。

LockNotAvailable 55P03 | canceling statement due to lock timeout | while updating tuple (0,169) in relation "stock"

対策1:ロックを取る順番を統一する(前後比較)

実測の条件

  • 商品20件、8スレッドが各50件の注文(計400注文)を同時に処理
  • 1注文で2〜3商品の在庫を1ずつ減らす。更新の間に2msのアプリ処理を挟む
  • 次の3パターンで比較
def place_order(conn, items, sort_items):
    targets = sorted(items) if sort_items else items   # ← 対策:ID順に並べる
    with conn.transaction():
        for item_id in targets:
            conn.execute("UPDATE stock SET qty = qty - 1 WHERE item_id = %s", (item_id,))
            time.sleep(0.002)  # アプリ側の処理時間を模擬

実行結果

random order / no retry    orders=400 ok=370 deadlock=30 failed=30 time=21.7s
random order / retry       orders=400 ok=400 deadlock=20 failed=0 time=14.8s
sorted order / no retry    orders=400 ok=400 deadlock=0 failed=0 time=0.8s
パターン 成功 デッドロック発生 最終的な失敗 処理時間
順番ばらばら・リトライなし 370 30回 30件 21.7秒
順番ばらばら・リトライあり 400 20回 0件 14.8秒
ID順に統一・リトライなし 400 0回 0件 0.8秒

更新の順番をID順に揃えただけで、デッドロックは0回になり、処理時間は21.7秒から0.8秒に短くなりました。 リトライでも最終的な失敗は0件にできますが、デッドロックのたびに検知待ち(約1秒)と再実行が発生するため、時間はかかります。根本対策は順番の統一、リトライはそれを補う備えと考えるとよいです。

※1回ずつの実行結果です。発生回数は実行ごとに変わります。

SELECT ... FOR UPDATE で先にまとめてロックする

更新の前に、対象行をID順でまとめてロックする書き方も有効です。

BEGIN;
SELECT item_id FROM stock
 WHERE item_id IN (2, 1, 5)
 ORDER BY item_id
 FOR UPDATE;               -- ID順にロックを取る
UPDATE stock SET qty = qty - 1 WHERE item_id IN (2, 1, 5);
COMMIT;

テーブルが複数ある場合も同じ考え方で、「親テーブル→子テーブル」「stock→order_detail」のように、全処理で順番を決めておきます。

対策2:リトライを実装する

順番を揃えても、外部キーの確認や別経路の処理などで、デッドロックを完全にゼロにできない場合があります。40P01を捕まえて、トランザクション全体をやり直す実装を用意しておきましょう。

import random, time, psycopg
from psycopg import errors

def run_with_retry(conn, work, max_attempts=5):
    for attempt in range(1, max_attempts + 1):
        try:
            with conn.transaction():      # 例外なら自動でROLLBACK
                return work(conn)
        except (errors.DeadlockDetected, errors.SerializationFailure):
            if attempt == max_attempts:
                raise                     # 上限に達したら呼び出し元へ
            # 少しずつ間隔を広げ、ランダムな揺らぎ(ジッター)を加える
            time.sleep(random.uniform(0.01, 0.05) * attempt)

実装のポイントは次のとおりです。

ポイント 理由
トランザクション全体をやり直す ロールバックされたのはトランザクション全体。途中の文だけ再実行しても整合しない
回数に上限をつける 競合が続くときに処理が終わらなくなるのを防ぐ
待ち時間にジッターを入れる 同じタイミングで再試行して再びぶつかるのを避ける
外部への副作用(メール送信など)はCOMMIT後に 再試行で二重に実行されないようにする
発生回数をログやメトリクスに残す 増えてきたら順番の統一など根本対策の合図になる

先ほどの実測(順番ばらばら・リトライあり)でも、この形で400件すべてを成功させられました。

MySQL(InnoDB)での確認方法

MySQLでも考え方は同じです。確認方法とエラーは次のとおりです(MySQL 8.4 リファレンスマニュアルに準拠。本記事では未実行)。

項目 MySQL(InnoDB) PostgreSQL
デッドロックのエラー エラー番号 1213(ER_LOCK_DEADLOCK)、SQLSTATE 40001 SQLSTATE 40P01
メッセージ Deadlock found when trying to get lock; try restarting transaction deadlock detected
ロールバックの範囲 トランザクション全体 トランザクション全体
直近の発生状況の確認 SHOW ENGINE INNODB STATUS の LATEST DETECTED DEADLOCK サーバーログのDETAIL
すべての発生をログに残す innodb_print_all_deadlocks = ON 既定でサーバーログに出力
ロック待ちのタイムアウト innodb_lock_wait_timeout(エラー1205、文だけがロールバック) lock_timeout(55P03)
-- MySQL:直近のデッドロックを確認
SHOW ENGINE INNODB STATUS\G

-- すべてのデッドロックをエラーログに記録(調査が終わったら戻す)
SET GLOBAL innodb_print_all_deadlocks = ON;

MySQLの公式ドキュメントも、トランザクションを短く保つ、操作の順番を揃える、適切なインデックスを用意する、デッドロック時は再実行することを推奨しています。InnoDBはインデックスの範囲にロックをかけるため、WHERE句に合うインデックスがないと、ロックする範囲が広がりやすい点に注意します。

デッドロック予防チェックリスト

チェック項目 確認方法
□ 複数行を更新するとき、ID順など決まった順番でロックしている 更新処理のコードで並べ替えているか
□ 複数テーブルの更新順が全処理で同じ 設計書・コードレビューで確認
□ トランザクション内で外部APIや重い計算をしていない ロック区間の処理を見直す
□ WHERE句の列にインデックスがある EXPLAIN でSeq Scanになっていないか
□ 40P01(MySQLは1213)を捕まえてトランザクション全体を再試行している リトライ処理のテスト
□ リトライに上限とジッターがある 設定値を確認
□ 発生時にサーバーログのDETAILを確認できる ログ設定、保存先
□ 長いロック待ちを把握できる(log_lock_waits、lock_timeout) 設定値を確認
□ バッチとオンライン処理が同じ行を逆順で更新していない 処理の流れを図にして確認

よくある質問

Q. デッドロックとロック待ち(ブロッキング)は何が違いますか? A. ロック待ちは、相手がCOMMITすれば解消する一方向の待ちです。デッドロックは待ちが輪になっているため、どちらかを終わらせない限り解消しません。そのためDBが自動で検知して片方をエラーにします。

Q. デッドロックが起きたら、データは壊れますか? A. 終了させられた側のトランザクションは全体がロールバックされるため、中途半端な状態は残りません。今回の再現でも、在庫はCOMMITできた側の分だけが正しく減っていました。アプリがエラーを捕まえて再実行すれば、処理を完了できます。

Q. deadlock_timeoutを短くすれば速く解消できますか? A. 検知は早くなりますが、デッドロックでない通常のロック待ちにも検査が走り、負荷が増えます。PostgreSQLのドキュメントも、既定の1秒は「実用上ほぼ最小の値」と説明しています。まずは順番の統一で発生自体を減らすほうが効果的です。

Q. SELECTだけの処理でもデッドロックは起きますか? A. PostgreSQLの通常のSELECTは行ロックを取らないため、基本的には関係しません。ただしSELECT ... FOR UPDATEやFOR SHAREはロックを取るので、更新と同じく順番を揃える必要があります。

まとめ

  • デッドロックは、ロック待ちが輪になって進めなくなる状態。DBが検知して片方をロールバックする
  • 最大の原因は更新順の不一致。実測では順番を揃えるだけで30回→0回、21.7秒→0.8秒
  • PostgreSQLのエラーはSQLSTATE 40P01。原因の特定はサーバーログのDETAILとpg_locksで
  • リトライはトランザクション全体を、上限とジッターつきで。根本対策を補う備えとして用意する
  • MySQLはエラー1213(SQLSTATE 40001)、確認はSHOW ENGINE INNODB STATUS
  • ロック区間を短く、インデックスを適切に。チェックリストで予防する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「デッドロックの原因と対策|PostgreSQLで再現した実ログで解説」

株式会社RJCでは、障害につながりやすいポイントを実際に再現して確かめ、その学びをプロジェクトの仲間と共有しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?