背景
ある普通の月曜日の朝、私は最近頻発している MySQL デッドロック例外 の調査を担当することになった。
この例外はすでに2週間続いていた。
業務側にはリトライ機構(失敗時に自動で2回再実行)が整備されていたため、ユーザーには影響がなく、コア処理も正常に動作していた。そのため、長らく「低優先度の問題」として扱われていた。
C向けコアAPIで発生、デッドロック頻度は千分の一程度
このデッドロック問題の解決に着手した。
現場条件と制約
データベース環境:
- AWS RDS for Aurora MySQL 8.0
現状:
- error_log は有効化されておらず、デッドロックログも無効
- デフォルトのデッドロック検出機構のみ有効
Aurora MySQL は InnoDB のロックモデルと基本互換だが、デッドロック検出とロールバックは Aurora の制御層が関与する
つまり:
- デッドロック検出時、MySQL はいずれかのトランザクションを自動でロールバックする
- しかし、デッドロックの詳細(トランザクション情報、SQL、ロック待機関係)は確認できない
手元にあるのは:
- アプリログの例外情報:
Deadlock found when trying to get lock; try restarting transaction - 対象の SQL 文のみ
1日目
「単純な」デッドロック SQL の特定
呼び出しチェーンのトレースとログ集約により、すぐに特定できた:
すべてのデッドロックは同じ SQL で発生していた。
UPDATE user_dyeing
SET country_code = ? , `updated_at` = ?
WHERE user_id = ?
この SQL は一見問題なさそう:
- 単一テーブル更新
- 明示的なトランザクションなし(autocommit モード)
- 条件は
user_idのみ
関連するインデックスは以下の通り:
通常インデックス:idx_user_id (user_id)
ユニーク複合インデックス:uk_user_biz (user_id, platform, country_code, channel, dyeing_at)
最初の可能性のある原因
初期推測:
MySQL オプティマイザが複数インデックス間で不安定にアクセス経路を選択しており、並行リクエスト間でロック順序が異なる → デッドロックが発生。
対応:
- 通常インデックス
idx_user_idを削除 -
WHERE user_id = ?はすべてユニークインデックスuk_user_bizを使用するよう強制
結果:
- デプロイ後、4時間にわたり観測
- デッドロックは依然として発生
結論:実行計画の不安定性は原因ではなく、通常インデックスはそもそも使用されない。
2つ目の可能性のある原因
コードを確認したが、他に怪しい SQL は見当たらなかった。
ただし、更新時に対象行がまだ存在しないケースがあること分かった。
存在しない行に対して、ユニークインデックスの全列を含まない条件で UPDATE を実行すると、InnoDB はインデックス上で Next-Key Lock(レコードロック + ギャップロック) を取得する。
並行リクエストが同じインデックス範囲の挿入意向ロックで競合すると、デッドロックが発生しやすい。
修正案:
SELECT で存在確認
存在する場合 → UPDATE
存在しない場合 → 処理終了
結果:
- デッドロック発生頻度は低下
- しかし完全には解消されず
→ これは誘因であり、根本原因ではないことが判明
3つ目の可能性のある原因
初期調査から根本的な問題に気づいていた:
ユニーク複合インデックス
uk_user_biz (user_id, platform, country_code, channel, dyeing_at)が
user_idのみを条件とする UPDATE に使用されている
InnoDB の観点では:
-
(1001, A, 1, …)と(1001, B, 2, …)はどちらも存在可能 - ユニークインデックスであっても Next-Key Lock が必要になる
ユニークインデックスは、WHERE 条件がインデックスの全列を完全にカバーして初めて Record Lock に退化し、それ以外は Next-Key Lock が使用される
この方向の対応は:
- 既存ユニーク制約の削除
- RDS 上で DDL 実行
- インデックス依存や整合性リスクあり
評価の結果、低コスト修正を優先し、リスクの高い変更は保留とした。
1日目終了時点で、低リスク施策はすべて失敗。
2日目
InnoDB ロック機構の理解に立ち返る
よく誤解される事実:
ユニークインデックス ≠ ギャップロックなし
WHERE 条件が ユニークインデックスの全列を完全にカバー して初めて:
- InnoDB は「最大1行しかヒットしない」と判断
- → Record Lock のみ が取得される
それ以外の場合:
- Next-Key Lock が使用される
本例の SQL:
WHERE user_id = ?
- 複合ユニークインデックス
(user_id, platform, country_code, …)の唯一性を利用できない - 各 UPDATE が user_id 周辺のインデックスギャップをロック
- 並行 UPDATE/INSERT 時、挿入意向ロックで互いに待機 → デッドロック
業務データ上の検証
業務フローとデータモデルを確認したところ、user_id 自体がビジネス上のユニークキーであり、複合ユニークインデックスは過剰設計で、既存業務への影響なしに修正可能と判断。
チームでの合意とリスク評価
短時間の会議を実施し、以下を確認:
根本原因:
- テーブルのユニークインデックス設計と使用方法が不適切
- 並行処理で、完全にカバーされていない複合ユニークインデックスを UPDATE に使用 → InnoDB が Next-Key Lock を使用 → 同一インデックス範囲でロック競合 → デッドロック
修正案:
uk_user_biz を削除
uk_user_id (user_id) を新規作成
リスク管理:
- インデックス定義を事前にバックアップ、先に追加してから削除
- ロールバック手順を明確化
最終的に、当日の夜間低負荷時間帯に実施。
結果:
- 監視 12 時間
- デッドロック発生回数:0
問題は完全に解決。
まとめ
デッドロック調査で押さえるべきポイント
| 条件 / シナリオ | 説明 | デッドロックを引き起こすメカニズム |
|---|---|---|
| 自動コミットモード | 単一 UPDATE でも明示トランザクションなしでも、InnoDB は暗黙トランザクションを開始 | 暗黙トランザクション中に行ロック / ギャップロックが発生、短時間でも相互待機可能 |
| インデックスアクセス経路 | UPDATE がセカンダリインデックスで候補行を特定し、クラスタ化インデックスにアクセス | ロック順序が異なる → 循環待機 → デッドロック |
| WHERE 条件がユニークインデックスを完全カバーしていない | 条件がインデックスの前方部分のみで全列をカバーしていない | InnoDB が Next-Key Lock を使用、高並列時にロック競合 |
| SQL の実行順序が異なる | 並行トランザクションが同一インデックス範囲や行に対して異なる順序でロック | 循環待機 → デッドロック |
| テーブルにレコードが存在しない場合 | UPDATE 対象行が未作成 | インデックス範囲にギャップロック(Gap Lock) → 並行 INSERT/UPDATE がロック競合 |
| 不適切な複合ユニークインデックスの利用 | WHERE 条件がユニークインデックスの先頭列のみを指定し、全列をカバーしていない | InnoDB は Next-Key Lock を取得し、並行トランザクション間でギャップロックが競合 → デッドロック |