MySQL トランザクションデッドロック解析
MySQL データベースを使っていると、ビジネスや機能モジュールの増加に伴い、データベースのトランザクション数もそれにつれて増えていきます。ビジネス機能を開発するシーンでは、トランザクションを開始してデータを操作したり、分散グローバルトランザクションを開始してデータを操作したりすることが頻繁にあります。トランザクションが多くなると、並行実行のシーンでは一定の確率でトランザクションのデッドロック問題が発生します。
なぜこの問題を取り上げるのか
デッドロックの厄介なところは、安定して再現できるバグではないことです。テスト環境でシングルスレッドで百回実行しても何も起きないのに、本番の並行ピーク時になると突然現れ、ログには Deadlock found when trying to get lock という一文だけが残ります。その成因を理解していないと、偶発的な例外として見過ごしてしまいがちで、ある日ビジネスのピーク時に集中して爆発することになります。この記事では、デッドロックの発生から検出、回避までを一通り整理します。
まず前提となる背景を少し補足します。InnoDB の行ロックはインデックスレコードに対してかけられます。あるトランザクションがある行のデータを更新するとき、その行の排他ロック(X ロック)を取得し、トランザクションがコミットまたはロールバックされるまで保持し続けます——これは二相ロッキングプロトコルの要求です。ロックの保持時間はトランザクションの生存時間に等しく、この点が以降のすべての分析の基礎になります。つまり、トランザクションが長いほど、そのロックに他者がぶつかるウィンドウは大きくなるのです。
デッドロックはどのように発生するのか
それでは、トランザクションデッドロックが発生する原因を分析してみましょう。
2 つの session 接続があるとします。一方の session はトランザクション T1 を、もう一方の session はトランザクション T2 を保持しています。
-
T1 トランザクションが rows1 を更新する
-
T2 トランザクションが rows2 を更新する
-
T2 トランザクションが rows1 を更新しようとする
-
T2 は T1 が rows1 の X ロックを解放するのを待つ
-
T1 トランザクションが rows2 を更新しようとする
-
T1 は T2 が rows2 の X ロックを解放するのを待つ
-
相互に待ち合ってデッドロック
鍵となるのはステップ 3 とステップ 5 です。2 つのトランザクションが逆の順序で、相手がすでにロックしている行に触れようとしています。T1 は rows1 を握ったまま rows2 を待ち、T2 は rows2 を握ったまま rows1 を待ち、どちらも先に手放そうとしません——手放すことはロールバックを意味するからです。待機関係が環を形成する、これがデッドロックの本質です。リソースの循環待機 なのです。
デッドロックは外部からの干渉がない限り、プログラム自身にはこの問題を解決する力がありません。人手、あるいは別のデーモンスレッドの助けを借りて、このデッドロック問題を解決する必要があります。
MySQL はどのように犠牲者を選ぶのか
デッドロックを解決する方法自体はシンプルです。どちらか一方のトランザクションを解放し、ロールバックさせればよいのです。
しかしロールバックには問題があります。どちらのトランザクションをロールバックするのがより合理的か、という問題です。MySQL は undo log の中でどちらのレコード数が多いか、つまりどちらの重みが大きいかに基づき、重みの小さいほうのトランザクションを切り捨ててロールバックします。これによりトランザクションデッドロックをより適切に解決できます。ロールバックするデータ量をできるだけ減らし、サービスのパフォーマンスの犠牲を最小化するためです。
この選択ロジックは難しくありません。undo log に記録されているのはトランザクションがすでに行った変更であり、レコード数が多いほどそのトランザクションはより多くの仕事をしており、それをロールバックするコストは大きくなります。そのため InnoDB は「仕事量の少ない」ほうのトランザクションをロールバックし、やり直しのコストを最小限に抑える傾向があります。選ばれたトランザクションはデッドロックエラーを受け取り、アプリケーション層はこのエラーを受けてトランザクション全体をリトライすることを選択できます——コードを書くときにトランザクションをリトライ可能に設計すべき理由がここにあります。
デッドロック確率の見積もり
システム内の任意のトランザクションがデッドロックを起こす確率 ≈ n2r4/4R2
n: トランザクション内のトランザクション数 n。数が多いほどデッドロック発生の確率が高くなります(トランザクションが内包するトランザクションの数)
r: 各トランザクションの操作数 r。各トランザクションの操作数が多いほど、デッドロック発生の確率が高くなります(トランザクションが操作する行数)
R: 操作対象データの集合 R。小さいほどデッドロック発生の確率が高くなります(異なるトランザクションが同じ集合のデータに触れる——データ集合。更新するデータが分散しているほど、つまりデータ集合が多いほど、X ロックが同じレコード上で衝突する確率は小さくなります)
この式から最適化の方向性を読み取ることができます。r の指数は 4 で、影響が最も激しい——大きなトランザクションを小さく分割し、単一トランザクションが操作する行数を減らすことが、デッドロック確率を下げるうえで最も効果の高い手段です。そして R は分母にあり、これはホットスポットデータがデッドロックの温床であることを意味します。すべてのトランザクションが同じ一握りの行(たとえばカウンター行や在庫行)に殺到して更新すると、デッドロック確率は急激に増幅されます。
能動的検出: wait-for graph
MySQL の能動的なトランザクション検出: wait-for graph
各 session のトランザクション開始前に、アルゴリズムによってトランザクションの閉路(2 つのトランザクションが互いに相手の X ロック対象データに影響し合って閉路を形成する状態)が存在するかを事前に検知し、undo log の重みが小さいほうのトランザクションを先に解放できます。
wait-for graph の原理は、待機関係を有向グラフとしてモデル化することです。各トランザクションをノードとし、「T2 が T1 のロックを待っている」なら T2 から T1 へ向かう辺を 1 本引きます。あるトランザクションがロックを取得できずに待機状態に入るたびに、InnoDB はグラフに辺を追加し、環が発生していないかチェックします——環があればデッドロックであり、直ちに重みの小さいトランザクションを選んでロールバックするため、無駄に待ち続けることはありません。
能動的検出のほかに、InnoDB にはもう一つの安全網があります。ロック待機タイムアウトです。行ロックの待機が innodb_lock_wait_timeout に設定された時間を超えると、待機していたステートメントはエラーを返します。これは本物のデッドロックか単なるロック競合かを区別しませんが、トランザクションが無期限にハングし続けないことを保証します。
-- 直近のデッドロックの詳細情報を確認する(LATEST DETECTED DEADLOCK セクション)
-- 2 つのトランザクションそれぞれが保持するロック、待機中のロック、ロールバックされた側が含まれる
SHOW ENGINE INNODB STATUS;
-- ロック待機タイムアウト時間を確認する(単位: 秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
しかし複雑なビジネスシーンでは、MySQL の能動的なトランザクション検出 wait-for graph が効かないうちに、デッドロックがすでに発生してしまうこともよくあることです。
だからこそ、ビジネスフローとビジネスが影響するデータ範囲をきちんと整理し、デッドロック発生の確率を小さくしておく必要があります。さもないと、人手でデッドロックに対処する事態に直面しかねません。
ハマりどころと注意点
前述の原理を踏まえると、実際の開発で留意すべき経験則がいくつかあります。
1)ロック取得順序を統一する。デッドロックの発生過程を振り返ると、根本原因は 2 つのトランザクションが逆順で同じ行群にアクセスすることでした。すべてのビジネスコードが同じ順序(たとえば主キーの昇順)でデータを更新するよう取り決めれば、循環待機の環はそもそも描けなくなります。
2)トランザクションは短く。トランザクションの中で RPC 呼び出し、メッセージ送信、ユーザー入力待ちといった時間のかかる操作をしてはいけません。ロックの保持時間が引き延ばされることは、デッドロックに窓を開け放つのと同じです。
3)ギャップロックに注意。REPEATABLE READ 分離レベルでは、範囲条件の更新や削除はギャップロックを取得します。2 つのトランザクションが隣接するギャップをロックし合ってから互いに挿入しようとすると、同様にデッドロックになります——この種のデッドロックは行データだけを見ても原因が分からないことが多く、SHOW ENGINE INNODB STATUS のロック情報を読む必要があります。
4)アプリケーション層でリトライを用意する。デッドロックが検出されると必ずどちらか一方がロールバックされます。ビジネスコードはデッドロックエラーを捕捉してトランザクション全体をリトライできるようにすべきで、最後の 1 文だけをリトライしてはいけません。
デッドロック検出自体にもコストがあります。ホットスポット行に大量のトランザクションが並ぶと、新しく待機に入るたびに待機グラフを走査する必要があり、並行度が高いほど検出のオーバーヘッドは大きくなります。ホットスポット更新のシーンでは、データベースに力ずくで耐えさせるのではなく、ビジネス側でホットスポットを分散させる方法を考えるべきです。
まとめ
デッドロックの本質は、ロック取得順序が交錯して形成される循環待機です。InnoDB は wait-for graph で能動的に閉路を発見し、undo log の重みに基づいてコストの小さいトランザクションを選んでロールバックするという安全網を備えています。しかし検出もロールバックもあくまで事後の補救にすぎず、本当に効くのは事前の工夫です。トランザクションを小さく分割し、ロック取得順序を統一し、ホットスポットデータを分散させることで、デッドロックの確率を無視できる水準まで抑え込むことができるのです。
COMMENTS