Day20 前半のゴール
「“デッドロック=お見合い状態”を具体的なイメージで理解する」
ロック制御の中でも、現場で一番イヤなやつが「デッドロック」です。
エラーとしては一瞬で終わるのに、原因を特定するのが地味に難しいタイプ。
Day20 前半のゴールはこうです。
デッドロックが「バグ」ではなく「ロックの取り方の結果」で起きることを理解する。
“お互いが相手のロック解除待ちで永遠に進めない”状態を、具体的なストーリーで説明できる。
PostgreSQL がデッドロックをどう検出し、どう解決するかのイメージを持つ。
まずは、「怖い言葉」ではなく「よくある交通事故」として捉え直します。
ロックってそもそも何を守っているのか
「“同時に書かれて壊れないようにする安全装置”」
トランザクションとMVCCの話をしてきましたが、
MVCCだけでは守れないものがあります。それが「同時更新の衝突」です。
同じ行を2人が同時に UPDATE しようとしたとき、
どちらを先に通すか、後から来た方はどう扱うか――
これを調整するのがロックです。
ざっくり言うと、
更新系(UPDATE / DELETE / INSERT)は、その行(やテーブル)に対して「排他ロック」を取る。
他のトランザクションは、そのロックが解放されるまで待たされるか、エラーになる。
この「待ち」が、うまく設計されていれば問題になりません。
しかし、ロックの取り方が噛み合わないと、「お互いが相手待ち」の状態に陥ります。
それがデッドロックです。
デッドロックとは何か
「“AはB待ち、BはA待ち”で永遠に進まない状態」
定義をシンプルに言うと、こうです。
トランザクションAが、トランザクションBが持っているロックの解除を待っている。
同時に、トランザクションBも、トランザクションAが持っているロックの解除を待っている。
この「相互待ち」が発生すると、どちらも先に進めません。
誰かが諦めない限り、永遠に待ち続けることになります。
PostgreSQL は、この状態を検出すると、
どちらか一方のトランザクションを強制的にエラーで落とします。
エラーの典型的なメッセージはこれです。
ERROR: deadlock detected
DETAIL: Process XXXX waits for ...; blocked by process YYYY.
SQLつまり、「デッドロック自体はDBが検出してくれる」が、
「なぜそうなったか」はアプリ側のロジックやロックの順番を見ないと分からない、という性質があります。
具体例1:2行を逆順にロックしてしまうパターン
「“A→B”と“B→A”が同時に走ると詰む」
一番典型的なデッドロックの例を、テーブルとSQLで見てみます。
accounts テーブルがあるとします。
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance INTEGER NOT NULL
);
SQLここで、「口座間の振替」を2つのトランザクションが同時に行う状況を考えます。
トランザクション1(T1):
口座 1 から 2 へ 100円移す。
トランザクション2(T2):
口座 2 から 1 へ 50円移す。
それぞれ、こんな感じの処理だとします。
T1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
SQLT2:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
SQLここで、タイミングが悪いとこうなります。
- T1 が
id = 1の行にロックを取る(UPDATE)。 - T2 が
id = 2の行にロックを取る(UPDATE)。 - T1 が次に
id = 2を UPDATE しようとするが、T2 がロック中なので待つ。 - T2 が次に
id = 1を UPDATE しようとするが、T1 がロック中なので待つ。
結果として、
T1 は「T2 が持っている id=2 のロック待ち」。
T2 は「T1 が持っている id=1 のロック待ち」。
という「相互待ち=デッドロック」が完成します。
PostgreSQLはデッドロックをどう処理するか
「“どちらかを犠牲にして”全体を救う」
このままだと、どちらのトランザクションも永遠に終わりません。
そこで PostgreSQL は、「待ちグラフ」を見てデッドロックを検出し、
どちらか一方を強制的にエラーで中断します。
例えば、T2 がこんなエラーを受け取るイメージです。
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 67890.
HINT: See server log for query details.
この瞬間、T2 はロールバックされ、T1 は待ちから解放されて処理を続けられます。
重要なのは、
デッドロックは「完全に防げるもの」ではなく、「起きたらDBがどちらかを落としてくれる」もの。
アプリ側は、「デッドロックエラーが来たらリトライする」などの戦略を持つ必要がある。
という現実です。
具体例2:UPDATEの順番を揃えるだけで防げるケース
「“常に小さいIDからロックする”というシンプルなルール」
さきほどの振替処理のデッドロックは、実は簡単なルールでかなり防げます。
「複数行を更新するときは、必ず同じ順番でロックを取りに行く」
例えば、「id の小さい方から先に UPDATE する」と決めます。
T1(1→2 の振替):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
SQLT2(2→1 の振替)も、こう書き換えます。
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;
COMMIT;
SQLつまり、「処理の意味としては 2→1 だけど、ロックを取る順番は 1→2 に揃える」ということです。
こうすると、
両方のトランザクションが、まず id=1 のロックを取りに行く。
どちらかが先に取る。もう一方は待つ。
先に取った方が id=1, id=2 と順に処理して COMMIT。
終わったあとで、待っていた方が id=1, id=2 を順に処理。
という流れになり、「A→B」「B→A」の相互待ちが起きなくなります。
ここから分かる重要ポイントは、
デッドロックは「ロックの順番がバラバラ」なときに起きやすい。
逆に、「ロックの順番を揃える」だけでかなり防げる。
ということです。
デッドロックと“ただのロック待ち”の違い
「“いつか終わる待ち”と“永遠に終わらない待ち”」
ロックが絡むときに、もう1つ区別しておきたいのが、
単なるロック待ち(ブロッキング)
デッドロック
の違いです。
単なるロック待ちの例:
T1 が行Xをロックして更新中。
T2 が同じ行Xを更新しようとして待たされる。
T1 が COMMIT すれば、T2 はロックを取得して処理を続けられる。
これは「いつか終わる待ち」です。
時間はかかるかもしれないけれど、構造的には詰んでいません。
デッドロックは、「待ちの輪が閉じている」状態です。
T1 は T2 のロック待ち。
T2 は T1 のロック待ち。
この輪が閉じている限り、誰かが強制的にロールバックされないと終わりません。
PostgreSQL は、この「輪が閉じている」状態を検出して、
エラーで片方を落とすことで輪を断ち切ります。
Day20 前半のまとめ
デッドロックは、「トランザクションAがトランザクションBのロック解除待ちをし、同時にBもAのロック解除待ちをしている」という“相互待ち”の状態であり、典型例は「複数行を逆順にロックし合う UPDATE」で、accounts の 1→2 と 2→1 を同時に処理すると「Aはid=2待ち、Bはid=1待ち」というお見合い状態になる。
PostgreSQL はこの状態を検出すると、どちらか一方のトランザクションを ERROR: deadlock detected で強制ロールバックし、もう一方を進めることでシステム全体の停止を防ぐが、アプリ側は「デッドロックは起こり得るもの」としてリトライ戦略や「複数行を必ず同じ順番でロックする」といった設計ルールを持つ必要がある――ここまでのイメージを持てれば、Day20 前半としては十分な着地です。
