デッドロックとは、複数のトランザクションがお互いのロック解放を待ち続けて、どちらも先に進めなくなる状態です。MySQL(InnoDB)は自動的にデッドロックを検知して片方のトランザクションをロールバックしますが、頻発するとユーザー体験や性能に影響します。この記事では、デッドロックが起きる典型シナリオを実行例で確認してから、ロック順序の統一・短いトランザクション・適切なインデックスといった回避策を整理します。MySQLのエラー検出の仕組みとアプリ側のリトライ実装もあわせて解説します。
このシナリオで考える
ECサイトの注文処理を例に使います。ordersテーブルとinventoryテーブルを同時に更新する場面を想定します。
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
);
CREATE TABLE inventory (
product_id INT PRIMARY KEY,
stock INT NOT NULL,
reserved INT NOT NULL DEFAULT 0
);
INSERT INTO orders VALUES (1, 101, 3000.00, 'pending'),
(2, 102, 1500.00, 'pending');
INSERT INTO inventory VALUES (1, 100, 0), (2, 50, 0);
デッドロックが起きる仕組み
デッドロックは「循環待ち」です。トランザクションAとBが異なる順序でロックを取得しようとしたとき、お互いが相手の保持するロックを待ち続ける状態になります。
-- トランザクションA(セッション1)
START TRANSACTION;
UPDATE inventory SET reserved = reserved + 1 WHERE product_id = 1; -- ① 在庫1をロック
-- ここで少し時間がかかる処理...
UPDATE orders SET status = 'processing' WHERE id = 1; -- ③ ordersを待つ
-- トランザクションB(セッション2、同時実行)
START TRANSACTION;
UPDATE orders SET status = 'processing' WHERE id = 2; -- ② ordersをロック
UPDATE inventory SET reserved = reserved + 1 WHERE product_id = 1; -- ④ 在庫1を待つ → 循環!
①→②→③→④の順に進むと、AはBが持つ行を待ち、BはAが持つ行を待つ状態になります。どちらも先に進めないため、InnoDBが介入します。
InnoDBはデッドロックを検知すると、ロールバックのコストが低い方(更新行数が少ない方など)を「被害者(victim)」として選び、自動的にロールバックします。被害者側のクライアントには次のエラーが返ります。
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
デッドロックが起きやすい4つのパターン
| パターン | 原因 |
|---|---|
| 複数テーブルを逆順にアクセスする | AはX→Y、BはY→Xの順で更新する |
| 同じテーブルの行を逆順でロックする | Aはid=1→2、Bはid=2→1の順でUPDATEする |
| インデックス不足による過剰ロック | フルスキャンで余分な行やギャップをロックする |
| 長いトランザクション中にAPI呼び出しを挟む | ロック保持時間が延び、競合確率が上がる |
特に3番目のインデックス不足は見落とされがちです。WHERE条件にインデックスがない場合、MySQLは多くの行にロックをかけます。これが別のトランザクションとの競合を引き起こす原因になります。
回避策1:ロック順序を統一する
最も基本的な回避策は「すべてのトランザクションで同じ順序でリソースをロックする」ことです。アプリケーション全体でテーブルへのアクセス順序を決めておき、例外なく守ります。
-- NG: セッションごとに順序が異なる
-- Session A: inventory → orders の順
-- Session B: orders → inventory の順
-- OK: 常に inventory → orders の順で統一
START TRANSACTION;
UPDATE inventory SET reserved = reserved + 1 WHERE product_id = 1;
UPDATE orders SET status = 'processing' WHERE id = 1;
COMMIT;
同じテーブルの複数行を更新する場合も、主キーの昇順に統一します。
-- NG: バラバラな順序
UPDATE orders SET amount = 3500 WHERE id = 3;
UPDATE orders SET amount = 2000 WHERE id = 1;
-- OK: 主キーの昇順に統一
UPDATE orders SET amount = 2000 WHERE id = 1;
UPDATE orders SET amount = 3500 WHERE id = 3;
回避策2:SELECT … FOR UPDATEで先にロックをまとめる
後で更新する行を、トランザクションの開始直後にまとめてロックしておく方法です。先にすべての対象行を確保するため、途中での循環待ちを防げます。
START TRANSACTION;
-- 後で更新する行を先にまとめてロック(アクセス順序も昇順で統一)
SELECT product_id FROM inventory WHERE product_id = 1 FOR UPDATE;
SELECT id FROM orders WHERE id = 1 FOR UPDATE;
-- この後は安全にUPDATEできる
UPDATE inventory SET reserved = reserved + 1 WHERE product_id = 1;
UPDATE orders SET status = 'processing' WHERE id = 1;
COMMIT;
後からロックを取り直すより、最初にまとめて確保する方がデッドロックリスクを下げられます。WHERE句の条件には必ずインデックスを使うようにしましょう。
回避策3:トランザクションを短くする
トランザクションが長いほど、他のトランザクションと競合する確率が上がります。次の3点を意識します。
- バリデーションや計算などロック不要な前処理はトランザクション外で済ませる
- 外部APIへのリクエストや重い集計処理をトランザクション内に混ぜない
- 読み取り専用の処理は
START TRANSACTION READ ONLYを使い、ロックを最小化する
「データを取得してから画面に表示し、ユーザーの確認を待ってから更新する」といったパターンは特に危険です。ユーザーの操作待ち時間の間ずっとロックを保持することになります。
よくある落とし穴 / 注意点
デッドロックは「エラーが出たのでリトライすれば解決」ではありません。根本原因(ロック順序の不統一やインデックス不足)を放置すると、頻度が増え続けます。
LOCK TABLESでテーブルごと占有するとデッドロックは減りますが、並列処理が完全にブロックされるため、通常のOLTPシステムには不向きです。
調査には次のコマンドが役立ちます。
-- 直近のデッドロック詳細を確認
SHOW ENGINE INNODB STATUS;
頻繁に発生する場合はmy.cnfでinnodb_print_all_deadlocks = 1を設定してエラーログに全デッドロックを記録し、パターンを分析します。
アプリケーション側では、ERROR 1213(SQLState: 40001)を検知したらトランザクション全体を最初からリトライするロジックを実装します。部分的にリトライすると不整合が生じるため、必ずトランザクション全体をやり直します。
まとめ
デッドロックの主な原因は「ロック取得順序の不統一」と「長いトランザクション」です。回避の基本はロック順序の統一・SELECT FOR UPDATEによる先読みロック・適切なインデックス設計の3点です。発生した際はSHOW ENGINE INNODB STATUSでパターンを分析し、根本原因を特定しましょう。
参考リンク
アイキャッチ画像: Photo by Jantine Doornbos on Unsplash
