コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
デッドロックと回避
お互いが、相手の持っているものを待っている
2 つの処理が、口座間の振替をほぼ同時に始めたとします。
プレーンテキスト
T1: UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 行 1 を確保
T2: UPDATE accounts SET balance = balance - 200 WHERE id = 2; -- 行 2 を確保
T1: UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 行 2 が空くのを待つ
T2: UPDATE accounts SET balance = balance + 200 WHERE id = 1; -- 行 1 が空くのを待つT1 が待っている行 2 は T2 が握っていて、T2 が待っている行 1 は T1 が握っています。どちらも相手が手放すのを待っているので、放っておけば永遠に進みません。これがデッドロックです。
タイムアウトまで待つ必要はありません。DBMS は「誰が誰を待っているか」を追っていて、待ちの関係が一周して輪になったことを見つけると、片方を選んで強制的に巻き戻します。巻き戻された側のアプリには、こういうエラーが返ります。
プレーンテキスト
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction輪ができるのは、順番が食い違ったときだけ
上の例で、T1 は 1 番、2 番の順に触り、T2 は 2 番、1 番の順に触っています。もし両方が「番号の小さい行から触る」と決めていたら、後から来た方は最初の行で待たされ、先の 1 本が終わるまで次の行を握ることはありません。輪は作れません。
ですから対策は、ロックの種類を覚えることでも設定を変えることでもなく、まずこの順番を揃えることです。複数行をまとめて押さえるなら ORDER BY id を付けて取る。テーブルをまたぐなら「注文、そのあと明細」のように触る順をコードの規約として決める。全経路で順番が同じなら、待ちは一列に並びます。
握っている時間を短くするのも同じ方向の話です。外部 API の応答待ちをトランザクションの内側に入れると、その数百ミリ秒のあいだ相手を待たせ続け、輪ができる隙が広がります。
起きる前提で、やり直せるようにしておく
順番を揃えても、ゼロにはなりません。同じ行への集中や、想定と違う実行計画で押さえる範囲が広がることもあります。デッドロックは設計ミスの証拠ではなく、たまに起きるものとして扱い、アプリ側で自動的にやり直します。
JavaScript
async function withRetry(operation, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (err) {
if (err.code !== "ER_LOCK_DEADLOCK" || i === maxRetries - 1) throw err;
await sleep(Math.random() * 100 * (i + 1));
}
}
}待ち時間に乱数を混ぜているのは、巻き戻された 2 本が同時にやり直して、また同じ形でぶつかるのを避けるためです。回数の上限は必ず決めます。無限にやり直すと、原因の調査ができないまま負荷だけが増えていきます。