Skip to content
Published on

デッドロックの診断と予防 — ログから二つのクエリを特定する方法

シェア
Authors

はじめに — deadlock detectedのログに初めて出会ったら

運用ログにこんな行が出ます。

ERROR:  deadlock detected
DETAIL:  Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
         Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (128,17) in relation "orders"

初めて見ると深刻な障害のように見えますが、このログが出たということは、データベースが問題を検知してすでに解決したという意味です。片方のトランザクションがロールバックされ、もう片方は正常に進みました。データは一貫しています。

本当の問題は二つです。第一に、アプリケーションがこのエラーを再試行せずユーザーにそのまま見せている可能性。第二に、デッドロックが繰り返し発生するならトランザクション設計に構造的な欠陥があるというシグナルである、という点です。

この記事はログからどの二つのクエリが絡んだのかを特定する方法から始めて、繰り返される三つのパターンと予防の原則、そしてデッドロックと単純なロック待ちを混同しない方法を扱います。

デッドロックは障害ではなく正常な動作である

デッドロックは二つのトランザクションが互いに握っているロックを待つときに発生します。

時間 ──────────────────────────────────────────▶

トランザクションA:  row 1 のロック獲得 ─────── row 2 のロック要求 ─── 待機
トランザクションB:          row 2 のロック獲得 ────── row 1 のロック要求 ─── 待機
                                                       両方とも永遠に待機

この状態でデータベースが何もしなければ、二つのトランザクションは永遠に止まります。ですからPostgreSQLもMySQLも、ロック待ちグラフの中に循環を検知すると片方を選んで中断させます。これが正常な設計であり、ほかの選択肢はありません。

核心はここから出てきます。デッドロックを完全になくすことは目標になりえません。どんな設計であれ、並行性がある限り発生確率をゼロにはできません。目標は二つです。頻度を実用的な水準まで下げること、そして発生したときにアプリケーションが静かに再試行することです。

# PostgreSQL: 40P01 (deadlock_detected)
# MySQL:      1213 (ER_LOCK_DEADLOCK)
RETRYABLE = {"40001", "40P01"}

再試行するときは二つを守らなければなりません。トランザクション全体を最初からやり直すこと、そして遅延にジッターを混ぜることです。固定遅延で再試行すると、二つのトランザクションが同じ間隔で再び衝突します。

PostgreSQLのログでデッドロックレポートを読む

デフォルト設定では、先ほどのログのように「サーバーログを見よ」というヒントしか出ません。実際のクエリを見るにはログ設定がされている必要があります。

-- デッドロックと長引いたロック待ちをログに残す
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '1s';
ALTER SYSTEM SET log_min_error_statement = 'error';
ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d app=%a ';
SELECT pg_reload_conf();

log_line_prefixにプロセスIDとアプリケーション名を入れることが決定的です。デッドロックレポートはプロセス番号で相手を指すので、その番号でほかのログ行を探さないとクエリを復元できません。アプリケーション側で接続文字列にapplication_nameを設定しておけば、どのサービスが関与したのかがすぐ分かります。

設定後のログはこう出ます。

2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service ERROR:  deadlock detected
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service DETAIL:  Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
	Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
	Process 24188: UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
	Process 24193: UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service HINT:  See server log for query details.
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service CONTEXT:  while updating tuple (128,17) in relation "inventory"
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service STATEMENT:  UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';

読む順序はこうです。

  1. Process 24188 waits ... blocked by process 24193 — 循環の向きを把握します。二行なら2者の循環で、三行以上なら三つ以上のトランザクションが絡んでいます。
  2. Process 24188:Process 24193:の行 — 各プロセスが最後に待っていた文です。
  3. CONTEXT — どのテーブルのどのタプルで詰まったのかを教えてくれます。

ここで最も重要な誤解を押さえておきます。レポートに出た二つの文はデッドロックの最後の断片にすぎず、原因ではありません。24188はB-200を待っていますが、彼がすでに握っていたロックはA-100です。そのA-100のロックを作った文はレポートには出てきません。つまり原因究明には、二つのトランザクションがそれ以前に実行した文の全体が必要です。

そこで実務ではこういう組み合わせを使います。

-- 関連プロセスがその時刻に実行した別の文をログから逆追跡する
-- (log_line_prefixに%pがあってこそ可能)
grep -E '\[24188\]|\[24193\]' /var/log/postgresql/postgresql.log \
  | awk '$1 " " $2 >= "2026-07-26 14:01:50"' \
  | head -60

デッドロックがすでに過ぎたあとなら、ログが唯一の証拠です。しかしいま進行中のロック待ちを見たいなら、カタログを検索すればよいのです。

SELECT a.pid,
       a.application_name,
       a.state,
       now() - a.xact_start        AS tx_age,
       now() - a.state_change      AS state_age,
       pg_blocking_pids(a.pid)     AS blocked_by,
       left(a.query, 80)           AS query
FROM pg_stat_activity a
WHERE a.backend_type = 'client backend'
  AND (cardinality(pg_blocking_pids(a.pid)) > 0
       OR a.state = 'idle in transaction')
ORDER BY tx_age DESC;
  pid  | application_name |        state        |    tx_age    |  state_age   | blocked_by |              query
-------+------------------+---------------------+--------------+--------------+------------+--------------------------------
 24193 | order-service    | idle in transaction | 00:04:12.881 | 00:04:10.220 | {}         | SELECT ... FOR UPDATE
 24188 | order-service    | active              | 00:00:08.114 | 00:00:08.101 | {24193}    | UPDATE inventory SET stock ...

pg_blocking_pidsが空なのに状態がidle in transactionのプロセスがあれば、それが根源です。トランザクションを開いたままアプリケーションが別のことをしているという意味です。

MySQLのSHOW ENGINE INNODB STATUSを読む

MySQLは最後のデッドロック一つだけをステータス出力に保持します。

SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-26 14:02:11 0x7f2a1c0d5700
*** (1) TRANSACTION:
TRANSACTION 4821094, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8812, query id 2210934 10.0.3.21 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200'

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; ...  0: len 5; hex 412d313030; asc A-100;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; ...  0: len 5; hex 422d323030; asc B-200;

*** (2) TRANSACTION:
TRANSACTION 4821096, ACTIVE 2 sec starting index read
MySQL thread id 8815, query id 2210941 10.0.3.22 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100'

*** (2) HOLDS THE LOCK(S):
... asc B-200;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... asc A-100;

*** WE ROLL BACK TRANSACTION (2)

PostgreSQLのレポートより情報が多いです。特にHOLDS THE LOCK(S)の節があるので、各トランザクションがすでに握っていたロックまで見せてくれます。上の出力だけで循環が完全に再構成できます。トランザクション1はA-100を握ってB-200を待ち、トランザクション2はB-200を握ってA-100を待っています。

読むときに注目すべき項目はこうです。

  • ascのあとの文字列 — ロックされたレコードのインデックスキーの値です。どの行なのかがすぐ分かります。
  • index PRIMARY of table — どのインデックスにロックがかかったかが出ます。セカンダリインデックスの名前が見えたなら、そのインデックス経路でアクセスしたという意味です。
  • lock_mode X locks rec but not gap — レコードだけをロックした状態です。locks gap before reclock_mode Xだけがあればギャップロックまたはネクストキーロックです。ギャップロックが見えたならロック範囲が広いというシグナルです。
  • WE ROLL BACK TRANSACTION (2) — InnoDBは戻す作業量が少ないほう、つまり変更した行数が少ないほうを犠牲にします。

問題はこの出力が最後の一つしか残らないことです。運用ではすべてログに残さなければなりません。

SET GLOBAL innodb_print_all_deadlocks = ON;

この設定をオンにすると、すべてのデッドロックがエラーログに記録されます。負荷が心配になる水準でデッドロックが発生しているなら、それ自体がすでに直すべき問題です。

実務で繰り返される三つのパターン

数多くのデッドロック事例を分解してみると、おおむね三つに収束します。

パターンログで見える姿解法
複数行の更新順序の不一致二つのトランザクションが同じテーブルの異なるキーを交差して待つ常に同じ基準で整列した順序でロック
インデックス不在でロック範囲が拡大ロックされた行数が実際の対象よりはるかに多くギャップロックが見える条件カラムにインデックスを追加
外部キーによる親行のロック子テーブルへのINSERTなのに親テーブルのレコードで待機親を更新するトランザクションの縮小、必要なら制約検証の遅延

パターン1 — 更新順序の不一致

最もよくあります。二つの要求が同じ二つの行を逆順に触ります。

-- 要求A
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
COMMIT;

-- 要求B (同時に)
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;

アプリケーションのコードではこの順序がたいてい買い物かごに入れた順、つまりユーザーごとに違う順序です。解決はロック対象をあらかじめ整列することです。

-- 一つの文で処理し、順序を明示する
UPDATE inventory
SET stock = stock - v.qty
FROM (VALUES ('B-200', 1), ('A-100', 2)) AS v(sku, qty)
WHERE inventory.sku = v.sku;

注意すべき点があります。単一のUPDATE文のロック獲得順序は実行計画によって決まるので、SQLだけでは完全には保証されません。確実にするならロックを先に整列された順序で取ります。

BEGIN;
SELECT sku FROM inventory
WHERE sku IN ('B-200', 'A-100')
ORDER BY sku
FOR UPDATE;
-- 以降の更新は順序に関係なく安全である
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;

バッチ処理ならアプリケーション側でキーを整列して渡すのが原則です。整列の基準は何でも構わず、すべてのコード経路で同じ基準を使うことだけが重要です。

パターン2 — インデックス不在でロック範囲が広がった場合

MySQLで特に致命的です。条件カラムにインデックスがないと、InnoDBはなめるすべてのレコードにロックをかけます。

-- order_noにインデックスがないなら
UPDATE orders SET status = 'cancelled' WHERE order_no = 'ORD-20260726-001';
-- 実際にはテーブル全体スキャン + スキャンしたすべての行にロック

対象は一行なのに百万行がロックされます。この状態では無関係な二つの要求も互いに衝突します。デッドロックのログに登場する二つのクエリが論理的に何の関係もなさそうに見えるなら、このパターンを疑ってください。

CREATE INDEX idx_orders_order_no ON orders (order_no);

PostgreSQLはインデックスなしでスキャンした行に無条件でロックをかけるわけではありませんが、更新対象になる行にはかけます。それでもインデックスがなければ更新対象を探す時間が長くなりトランザクションも長くなるので、結局は衝突確率が上がります。

パターン3 — 外部キーが引き起こす親行のロック

子テーブルにINSERTすると、参照整合性を守るために親行をロックします。PostgreSQLは親行にFOR KEY SHAREレベルのロックをかけ、InnoDBは共有ロックをかけます。

CREATE TABLE users (id bigserial PRIMARY KEY, name text);
CREATE TABLE orders (
  id      bigserial PRIMARY KEY,
  user_id bigint REFERENCES users(id),
  total   numeric
);
-- セッションA
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 1000);   -- users(7)にKEY SHARE
UPDATE users SET name = 'Kim' WHERE id = 7;             -- 自分のロックなので通過
-- セッションB (同時)
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 2000);   -- KEY SHARE獲得 (共有なので可能)
UPDATE users SET name = 'Lee' WHERE id = 7;             -- セッションAのロックを待機

二つのセッションが同じ親行を参照しながらその親行を更新しようとすると、デッドロックになります。共有ロックは複数のトランザクションが同時に握れるので、その状態から排他ロックへ昇格しようとした瞬間に互いをブロックします。

実務でこのパターンは「注文を入れながらユーザーの最終注文時刻を更新する」コードでよく出てきます。解法は親の更新をなくすか、別テーブルに分離するか、順序を強制して親を先に排他的にロックすることです。

-- 親を先に排他的にロックして昇格の競合をなくす
BEGIN;
SELECT id FROM users WHERE id = 7 FOR UPDATE;
INSERT INTO orders (user_id, total) VALUES (7, 1000);
UPDATE users SET last_ordered_at = now() WHERE id = 7;
COMMIT;

PostgreSQLでは参照カラムを更新しないUPDATEならFOR KEY SHAREと衝突しないので、この問題は軽くなります。それでも同じ親行をめぐって排他ロックを争う構造なら同じことです。

予防の原則 — 順序、長さ、インデックス

三つのパターンから予防の原則がそのまま導かれます。

常に同じ順序でアクセスします。複数の行をロックするなら、主キーの昇順のような決定的な基準で整列します。複数のテーブルをロックするなら、テーブルの順序もコーディング規約として固定します。

トランザクションを短く保ちます。デッドロックの確率はロック保有時間に比例します。トランザクションの中に外部API呼び出し、ファイル入出力、ユーザー入力待ちが入ってはいけません。この規則一つを守るだけで大半のデッドロックが消えます。

-- コネクションがトランザクションを開いたまま放置するのを強制的に断ち切る
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
SELECT pg_reload_conf();

インデックスでロック範囲を狭めます。更新条件とロック読み取りの条件は必ずインデックスで処理されなければなりません。検索性能の問題ではなく並行性の問題です。

バッチは整列して細かく分けます。10万件を一つのトランザクションで更新すると、その時間ずっと10万行がロックされます。

-- 整列された順序で、小さな塊で
WITH batch AS (
  SELECT id FROM jobs
  WHERE status = 'queued'
  ORDER BY id
  LIMIT 1000
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'processing'
WHERE id IN (SELECT id FROM batch);

SKIP LOCKEDは複数のワーカーが同じキューを消費するときデッドロックを構造的になくしてくれます。ロックされた行を待たずに飛ばすので、循環そのものが生じません。

deadlock_timeoutとlock_timeout、そしてロック待ちとの区別

三つのタイムアウトがあり、役割がまったく違います。

-- デッドロック検知を開始するまで待つ時間。検知コストが大きいのですぐには行わない。
SHOW deadlock_timeout;              -- デフォルト 1s

-- ロック獲得を諦めるまでの時間。デフォルトは無制限。
SHOW lock_timeout;                  -- デフォルト 0 (無制限)

-- 文全体の実行時間の上限。
SHOW statement_timeout;             -- デフォルト 0 (無制限)

deadlock_timeoutを縮めるとデッドロックはより速く解消されますが、循環のない平凡なロック待ちに対しても毎回検知アルゴリズムが回ってCPUを使います。デフォルト値の1秒は大半の環境で合理的です。この値を100msのような値に下げろという助言をときどき見かけますが、デッドロックが頻繁で応答遅延が実際に問題になっている状況でなければ、利得より費用のほうが大きいです。

本当に設定すべきなのはlock_timeoutです。

-- ユーザー応答の経路ではロックを長く待たない
SET lock_timeout = '3s';

-- DDLは特に短く。待機中に後続のすべてのクエリが一緒に詰まるからだ。
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN memo text;

MySQLで対応する設定はinnodb_lock_wait_timeoutで、デフォルト値は50秒です。ウェブ要求の経路には長すぎます。

SET SESSION innodb_lock_wait_timeout = 5;

最後に、最もよくある誤診を一つ整理します。ロック待ちとデッドロックは別の問題であり、対応も違います

  • デッドロックは循環です。データベースが1秒以内に検知して片方を殺します。エラーコードはPostgreSQLが40P01、MySQLが1213です。症状は間欠的なエラーであって遅延ではありません。
  • ロック待ちは循環ではありません。誰かがロックを長く握っていて残りが列を作ります。誰も死なず、代わりに応答時間が延びていってコネクションプールが枯渇し、サービス全体が止まります。エラーコードは55P03かタイムアウトです。

「デッドロックのせいでサービスが遅くなった」という診断はたいてい間違いです。デッドロックは速く解消されるので遅延を作りません。遅くなったのならロック待ち、そしてその裏にある長いトランザクションや放置されたidle in transactionのコネクションを見るべきです。

-- ロック待ちの連鎖の根を探す
WITH RECURSIVE chain AS (
  SELECT pid, unnest(pg_blocking_pids(pid)) AS blocker, 1 AS depth
  FROM pg_stat_activity
  WHERE cardinality(pg_blocking_pids(pid)) > 0
  UNION ALL
  SELECT c.blocker, unnest(pg_blocking_pids(c.blocker)), c.depth + 1
  FROM chain c
  WHERE cardinality(pg_blocking_pids(c.blocker)) > 0 AND c.depth < 10
)
SELECT DISTINCT a.pid, a.state, now() - a.xact_start AS tx_age, left(a.query, 60)
FROM chain c
JOIN pg_stat_activity a ON a.pid = c.blocker
WHERE cardinality(pg_blocking_pids(c.blocker)) = 0;

おわりに — デッドロックはなくすものではなく再試行可能にするもの

整理すると次のとおりです。

ログを見るときは、レポートに出た二つの文が原因ではないという点をまず覚えておいてください。それは各トランザクションが最後に待った文にすぎず、本当の原因はそれ以前にすでに取っておいたロックです。MySQLならHOLDS THE LOCK(S)の節がその情報をくれますし、PostgreSQLなら同じプロセス番号の先行するログを探す必要があります。

予防は順序、長さ、インデックスの三語に要約されます。同じ順序でロックし、トランザクションを短く保ち、ロック条件にインデックスを置いてください。ここにlock_timeoutidle_in_transaction_session_timeoutを設定しておけば、事故が全面障害に広がることはありません。

そしてこれらをすべてやったとしてもデッドロックは発生します。40P01と1213を捕まえてバックオフとジッターを混ぜて再試行するコードがなければなりません。そのコードがなければ、予防作業の成果はユーザーに届きません。