
こんにちは、バックエンド基盤チームの徳森です。
バックエンド基盤チームでは、バックエンドエンジニアの生産性向上やコスト削減を目的に、エンジニア主導で課題の発見や解決を行っています。
今回は、MySQL(InnoDB) で発生したデッドロックをチームで調査した事例についてご紹介します。
要約
- ユニークインデックスを持つレコードに対し、同一内容の DELETE 文が3つ以上同時に実行された際にデッドロックが発生する現象に遭遇しました。
- 原因は、3つのトランザクションの相互作用によって、自身が保持するロックの解放を自分自身が待つ状態が発生することでした。
- これは MySQL の既知のバグ (Bug #82127) で、MySQL 8.0.18 で修正済みですが、5.7 にはバックポートされていません。
- アプリケーション側で「デッドロック発生時に削除対象のレコードが既に存在しなければ正常とみなす」対策を行いました。
背景
table_a という以下のクエリで作成できるテーブルがあり、これには PRIMARY KEY の他に column_a と column_b の複合ユニークインデックスが設定されています。
CREATE TABLE table_a ( id int, column_a int, column_b int, PRIMARY KEY (id), UNIQUE KEY index_table_a_on_column_a_and_column_b (column_a, column_b) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
あるAPIにおいて、このテーブルのレコードを以下の DELETE 文で削除する処理がありました。
このAPIが短時間に多重に呼び出されたことで、デッドロックが発生しました。
なお、このトランザクション内で発行されるのは、この DELETE 文のみです。
DELETE FROM table_a WHERE column_a = 123 AND column_b IN (456)
発生した事象
MySQL では SHOW ENGINE INNODB STATUS を使うことで、直近に発生したデッドロックの詳細を確認できます。
以下が、今回のデッドロックのログです。
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2025-10-30 15:40:21 7feb3c097700 *** (1) TRANSACTION: TRANSACTION 20949198867, ACTIVE 0 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 360, 1 row lock(s) /* snip */ delete from table_a where column_a = 123 and column_b in (456) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 8 page no 354436 n bits 528 index `index_table_a_on_column_a_and_column_b` of table `my_db`.`table_a` trx id 20949198867 lock_mode X waiting *** (2) TRANSACTION: TRANSACTION 20949198865, ACTIVE 0 sec starting index read, thread declared inside InnoDB 1 mysql tables in use 1, locked 1 3 lock struct(s), heap size 1184, 2 row lock(s) /* snip */ delete from table_a where column_a = 123 and column_b in (456) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 8 page no 354436 n bits 528 index `index_table_a_on_column_a_and_column_b` of table `my_db`.`table_a` trx id 20949198865 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 8 page no 354436 n bits 528 index `index_table_a_on_column_a_and_column_b` of table `my_db`.`table_a` trx id 20949198865 lock_mode X waiting *** WE ROLL BACK TRANSACTION (1)
このログから、二つのトランザクションの状況を読み解くと以下のようになります。
- トランザクション(2) が、ユニークインデックス
index_table_a_on_column_a_and_column_bに対してlock_mode X locks rec but not gap(レコードロック)を保持している。 - トランザクション(1) が、トランザクション(2)の持つロックの解放を待機している (
WAITING FOR THIS LOCK)。 - トランザクション(2) が、自身が既に保持しているロックに対して、
lock_mode X(ネクストキーロック) の獲得を試み、待機状態に陥っている。
ここで注目してほしいのはトランザクション(2)の挙動です。
自分がすでに持っているロックの解放を、自分自身が待つという不思議な状態に陥っています。
発生原因
デッドロックログとソースコードを突き合わせてチームで議論したところ、3つのトランザクションが関わっていることが分かりました。デッドロック検出ログには2つのトランザクションしか現れませんが、実際にはもう1つのトランザクションが特定のタイミングで競合することでデッドロックが発生します。以下で、その相互作用を順に見ていきます。
InnoDB のロック種別
DELETE 文は内部的に「行の検索」と「削除マークの付与」の2フェーズで実行され、検索フェーズでロックを獲得します。 ユニークインデックスの検索でロックを獲得する際、対象レコードの状態によってロックの種類が変わります。
LOCK_REC_NOT_GAP(レコードロック): 対象行のみをロック。レコードが削除マークされていない場合に選択される。LOCK_ORDINARY(ネクストキーロック): 対象行 + 直前のギャップをロック。レコードが削除マークされている場合などに選択される。
この、対象レコードに削除マークが付いているかどうかでロックの種類が変わるという挙動が、今回のデッドロックの鍵になります。
3つのトランザクションの相互作用
実際に発生していた状況を、txn1, txn2, txn3 の3つのトランザクションを使って説明します。
| 時刻 | txn1 | txn2 | txn3 |
|---|---|---|---|
| T1 | LOCK_REC_NOT_GAP 取得 |
||
| T2 | ロック保持 | LOCK_REC_NOT_GAP 要求 → txn1 のロック待ち |
|
| T3 | 削除マーク付与 | (待機中) | |
| T4 | ロック保持 | (待機中) | LOCK_ORDINARY 要求 → txn1 のロック待ち |
| T5 | COMMIT (ロック解放) | (待機中) | (待機中) |
| T6 | LOCK_REC_NOT_GAP を取得して再開 |
txn2 のロック待ち | |
| T7 | LOCK_ORDINARY を要求 |
txn2 のロック待ち | |
| T8 | txn3 のロック待ち → デッドロック | txn2 のロック待ち → デッドロック |
各ステップを順に解説します。
T1: txn1 が LOCK_REC_NOT_GAP を取得
txn1 が DELETE を発行します。レコードはまだ削除マークが付いていない状態なので、LOCK_REC_NOT_GAP が選択されます。
T2: txn2 が LOCK_REC_NOT_GAP を要求してロック待ちに
txn2 が DELETE を発行します。レコードにはまだ削除マークが付いていないため、txn2 も LOCK_REC_NOT_GAP を要求します。しかし txn1 が既に保持しているため、ロック待ちに入ります。
T3: txn1 が削除マークを付与
txn1 が削除フェーズに進み、レコードに削除マークを付与します。物理削除はまだされませんが、以降このレコードは「削除済み」として扱われます。
T4: txn3 が LOCK_ORDINARY を要求してロック待ちに
このタイミングで txn3 が DELETE を発行します。レコードには既に削除マークが付いているため、txn3 は LOCK_ORDINARY を要求します。txn1 のロックを待機します。
T5: txn1 が COMMIT してロックを解放
txn1 が COMMIT し、保持していた LOCK_REC_NOT_GAP を解放します。
T6: txn2 が LOCK_REC_NOT_GAP を取得して再開
txn1 が COMMIT したことで txn2 の待機が解除され、LOCK_REC_NOT_GAP を取得します。txn2 は検索処理を最初からやり直すため、再度ロックの種類を判定します。
T7: txn2 が LOCK_ORDINARY を要求
レコードには T3 で削除マークが付いているため、txn2 は最初に要求していた LOCK_REC_NOT_GAP ではなく LOCK_ORDINARY を要求します。ここでロックの種類が変わる点が、後の議論につながる重要なポイントです。
T8: txn2 と txn3 でデッドロックが成立
しかし、LOCK_ORDINARY は txn3 が先に取得を待っているため、txn2 はその後ろに並ぶことになり、デッドロックが成立します。
なぜ「自分が保持するロックの解放を自分自身が待つ」ように見えるのか
デッドロック検出ログでは、txn2 (= ログ中のトランザクション(2)) が LOCK_REC_NOT_GAP を保持したまま LOCK_ORDINARY の取得を待機しているように見えます。これは、InnoDB が同じトランザクションの異なる範囲のロックを別物として扱っているためです。
txn2 から見ると、すでに LOCK_REC_NOT_GAP を保持しているのに、なぜか今度は LOCK_ORDINARY を新規取得しようとして他のトランザクションと競合する、という不可解な状態に陥っているわけです。
本来、トランザクションが既に同等のロックを保持している場合は再取得をスキップするロジックが存在します (lock_rec_has_expl())。しかし、LOCK_REC_NOT_GAP は LOCK_ORDINARY よりも狭い範囲のロックのため、「保有しているロックでは不十分」と判定されてしまい、新規ロック要求 → デッドロックという流れになります。
なお、この問題は Bug #82127 として報告されており、MySQL 8.0.18 で修正済みです。この現象の再現は、MySQL の DEBUG_SYNC 機能を使うことで行えます1。
対策
根本的な対策は MySQL 8.0 以降への移行ですが、すぐにアップグレードすることはできないので、今回はアプリケーション側で対策を行いました。
今回のデッドロックは、同一レコードの削除が競合することで発生しています。 つまり、デッドロックで処理が失敗したとしても、競合したもう一方のトランザクションによって目的が達成されている可能性は高いです。
そこで、アプリケーション側でデッドロックエラーを検知した際に、単純にリトライやエラーを返すのではなく、削除対象のレコードが既に存在しないことを確認し、存在しなければ正常として処理を継続する対応を行いました。
_, err := db.Exec("DELETE FROM table_a WHERE column_a = ? AND column_b IN (?)", columnA, columnB) if err != nil { // デッドロックエラーか判定 if errors.Is(err, &mysql.MySQLError{Number: 1213}) { // レコードが存在しないことを確認 rows, err := db.Exec("SELECT * FROM table_a WHERE column_a = ? AND column_b IN (?)", columnA, columnB) if err != nil { return err } if len(rows) == 0 { // 目的は達成されているのでエラーとしない return nil } } return err } return nil
まとめ
本記事では、MySQL で発生した同時 DELETE のデッドロックについて、原因と実際に行った対策をご紹介しました。
MySQL はバージョン間で様々な改善が行われており、知らないうちに過去のバージョン固有のバグを踏んでいることがあります。問題が発生した際には、公式バグトラッカーやリリースノートを一つの情報源として利用することで、原因の特定につなげられることがあります。
ミラティブでは、この記事で紹介したバックエンド基盤チームをはじめ、様々なポジションで新しい仲間を募集しています。
もし私たちのチームや取り組みに興味を持っていただけましたら、ぜひ採用ページを覗いてみてください。
-
MySQL のデバッグビルドが提供する
DEBUG_SYNC機能を使うと、コード内に埋め込まれた sync point でスレッドを停止・再開できるため、3つのトランザクションのタイミングを決定的に制御できます。今回は InnoDB のinnodb_row_update_for_mysql_beginという sync point を使い、txn1 が削除マークを付与する直前で停止させることで、txn2 のロック待ち → txn3 の発行 → txn1 の COMMIT というシーケンスを再現しました。↩