PostgreSQL | SQLite+MySQL経験者向け、30日で習得するPostgreSQL:設計とパフォーマンス - Day20 ロック制御

SQL PostgreSQL
スポンサーリンク

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;
SQL

T2:

BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
SQL

ここで、タイミングが悪いとこうなります。

  1. T1 が id = 1 の行にロックを取る(UPDATE)。
  2. T2 が id = 2 の行にロックを取る(UPDATE)。
  3. T1 が次に id = 2 を UPDATE しようとするが、T2 がロック中なので待つ。
  4. 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;
SQL

T2(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 前半としては十分な着地です。

タイトルとURLをコピーしました