MySQL のロック機構とデッドロック対策 入門

概要

Web アプリケーションで同時アクセスが増えると、「Deadlock found when trying to get lock」や「Lock wait timeout exceeded」といったエラーに遭遇します。これらは MySQL(InnoDB)のロック機構を理解していないと原因がつかめません。

この記事では、InnoDB のロックの種類を整理し、デッドロックがなぜ起きるのか、どう対策するのかを入門者向けにまとめます。前提は MySQL 8.0 / InnoDB、分離レベルはデフォルトの REPEATABLE READ です。

ロックの種類を整理する

共有ロックと排他ロック(S / X ロック)

行レベルロックには2種類あります。

種類 記法 用途 他トランザクションの可否
共有ロック (S) SELECT ... FOR SHARE 読み取り中に変更させたくない 他の S は可、X は不可
排他ロック (X) SELECT ... FOR UPDATE / UPDATE / DELETE 書き込みのために確保 S も X も不可
-- 共有ロック(他者の更新をブロックするが、読み取りは許す)
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;

-- 排他ロック(更新前提。他者の読み書きをブロック)
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

インテンションロック(意図ロック)

テーブルレベルのロックで、「この先、行に S/X ロックを取る意図がある」ことを示します。IS(意図共有)と IX(意図排他)があり、InnoDB が自動取得するため通常は意識しません。

レコード・ギャップ・ネクストキーロック

InnoDB のロックで最も誤解されやすい部分です。InnoDB は「行そのもの」だけでなく「行と行の隙間(ギャップ)」もロックします。

種類 ロック対象
レコードロック インデックスの特定の1レコード
ギャップロック レコード間の隙間(存在しない範囲)
ネクストキーロック レコードロック + 直前のギャップ

REPEATABLE READ ではファントムリードを防ぐため、デフォルトでネクストキーロックが使われます。これが「更新していない範囲までロックされて待たされる」原因になります。

-- 該当行だけでなく、id > 100 のギャップもロックされ
-- id=150 の INSERT が待たされうる
START TRANSACTION;
UPDATE orders SET status = 'done' WHERE id > 100;

デッドロックとは

複数のトランザクションが互いに相手の持つロックの解放を待ち、永久に進めなくなる状態です。InnoDB は検知すると片方を自動的にロールバックしてエラーを返します。

ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

典型的な発生パターン: ロック順序の交差

トランザクション A と B が、同じ2行を逆の順序でロックしようとすると発生します。

-- トランザクション A
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- id=1 をロック
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- id=2 待ち

-- トランザクション B(ほぼ同時)
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- id=2 をロック
UPDATE accounts SET balance = balance + 50 WHERE id = 1;   -- id=1 待ち

A は id=2 の解放を待ち、B は id=1 の解放を待つ。互いに相手待ちで進めません。

原因

  1. ロック取得順序が一定でない — 処理によって触る行の順序が違う
  2. トランザクションが長い — 長時間ロックを保持し衝突窓が広がる
  3. インデックス不足による過剰ロック — WHERE にインデックスがないと全行走査でロック範囲が広がる
  4. ギャップロックの衝突 — 範囲更新と INSERT が競合する

対応方法

対策1: ロック取得順序を統一する

複数行を更新するときは、常に同じ順序(例: 主キー昇順)でアクセスします。これだけで交差型デッドロックの多くが消えます。

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 常に id 昇順
COMMIT;

対策2: トランザクションを短く保つ

トランザクション内で外部 API 呼び出しや重い計算をしない。取ったら最小限の更新をして速やかに COMMIT します。

対策3: 適切なインデックスを張る

CREATE INDEX idx_orders_user_id ON orders (user_id);

WHERE 条件のカラムにインデックスを張り、ロック対象を該当行に絞ります。

対策4: リトライ処理を前提にする

デッドロックは完全には防げません。発生したら短い待機後に再実行する仕組みをアプリ側に持たせます。

function runWithRetry(callable $txn, int $maxRetry = 3): mixed
{
    $attempt = 0;
    while (true) {
        try {
            $pdo->beginTransaction();
            $result = $txn($pdo);
            $pdo->commit();
            return $result;
        } catch (PDOException $e) {
            $pdo->rollBack();
            // 1213: Deadlock, 1205: Lock wait timeout
            if (in_array($e->errorInfo[1] ?? 0, [1213, 1205], true)
                && $attempt < $maxRetry) {
                $attempt++;
                usleep(100000 * $attempt); // 線形バックオフ
                continue;
            }
            throw $e;
        }
    }
}

確認方法

-- 直近のデッドロックの詳細(LATEST DETECTED DEADLOCK を見る)
SHOW ENGINE INNODB STATUS\G

-- 現在のロックと待ち依存(MySQL 8.0)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

-- ロック待ちタイムアウト
SELECT @@innodb_lock_wait_timeout;  -- デフォルト 50 秒

注意点

  • 1213(デッドロック)と 1205(ロック待ちタイムアウト)は別物。前者は検知による即ロールバック、後者は待ち時間超過で、原因も対処も異なります。
  • 分離レベルを READ COMMITTED に下げるとギャップロックが基本無効化されデッドロックが減ることがありますが、ファントムリードを許容することになります。要件と相談が必要です。
  • デッドロックを「0 にする」ことを目標にしない。頻度を下げつつリトライで吸収する設計が現実的です。

まとめ

  • InnoDB のロックは S/X ロック・意図ロック・ギャップ系の3層
  • REPEATABLE READ ではギャップロックが働き、更新していない範囲まで待たされることがある
  • デッドロックの主因は「ロック順序の交差」「長いトランザクション」「インデックス不足」
  • 対策は順序統一・短トランザクション・インデックス・リトライの4本柱
  • SHOW ENGINE INNODB STATUS と performance_schema.data_locks で原因を追える

まず現状を可視化し、そのうえで設計で衝突を減らす——この順序が遠回りに見えて一番速い対処です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。