MySQL InnoDBのギャップロック:存在しない行をロックすると何がロックされるのか

InnoDBがREPEATABLE READでインデックスレコード間の隙間までロックする理由、存在しない行をFOR SHAREで確認してからINSERTするとデッドロックになりうる仕組み、READ COMMITTEDに切り替える代償をMySQL 8.4のマニュアルに基づいて整理します。

mysqlinnodbdatabaseconcurrencylocking

MySQLで SELECT ... FOR UPDATE が0行を返したのに、別セッションの INSERT が待たされ続けることがあります。ロックした行は一つもないのに、何を待っているのでしょうか。答えは、InnoDBが行だけでなく**インデックスレコードの間の隙間(ギャップ)**もロックすることにあります。

この記事は MySQL 8.4 リファレンスマニュアル を基準にしています。マニュアルには他のバージョンのページもあり、細かい挙動はバージョンによって異なる場合があります。

ロックの対象は行ではなくインデックス

InnoDBのレコードロックはインデックスレコードにかかります。テーブルにインデックスを定義していなくても、InnoDBは隠しクラスタインデックスを作り、そのレコードをロックします。

マニュアルが区別しているロックは次のとおりです。

  • レコードロック:インデックスレコード一つをロックします。
  • ギャップロック:インデックスレコード間の隙間、または最初のレコードの前や最後のレコードの後の隙間をロックします。
  • ネクストキーロック:レコードロックと、そのレコードの前の隙間に対するギャップロックを組み合わせたものです。
  • 挿入インテンションロック:INSERT が行を挿入する前に設定するギャップロックの一種です。

InnoDBのデフォルトの分離レベルは REPEATABLE READ です。このレベルでの通常の SELECT はスナップショットを読む非ロックの一貫性読み取り(consistent read)なので、ロックを設定しません。ロック読み取り(SELECT ... FOR UPDATE や FOR SHARE)、UPDATE、DELETE は違います。スキャンしたインデックス範囲をロックし、ネクストキーロックやギャップロックでその範囲への挿入を防ぎます。例外は一意インデックスを一意の条件で検索する場合で、このときは見つかったレコードだけをロックします。範囲をロックする目的はファントム行の防止です。ファントムとは、同じトランザクション内で同じクエリを2回実行したとき、2回目にだけ現れる行のことです。

区間で考えるとわかりやすい

マニュアルの例のように、インデックスに10、11、13、20がある場合、ネクストキーロックが覆いうる区間は次のとおりです。

(負の無限大, 10]
(10, 11]
(11, 13]
(13, 20]
(20, 正の無限大)

最後の区間は実際のレコードではなく、supremum 疑似レコードにかかります。supremum はインデックス内のどの値よりも大きい仮想的なレコードなので、このネクストキーロックは実質的に最大値の後ろの隙間だけをロックします。

マニュアルの例をそのまま追ってみます。child テーブルの id には90と102があります。

-- セッションA
START TRANSACTION;
SELECT * FROM child WHERE id > 100 FOR UPDATE;

このクエリは102をロックするだけでは終わりません。102の前の隙間(90と102の間)と102の後ろの隙間までロックするため、セッションBが INSERT INTO child (id) VALUES (101); を実行すると、挿入インテンションロックを取得できずに待機します。マニュアルは、このロックが100より大きいすべての id の挿入を防ぐと説明しています。そのため、Aが同じ SELECT を再実行しても新しい行は現れません。

ギャップロック同士は競合しない

ここで多くの人が混乱する性質が出てきます。マニュアルはギャップロックを「純粋に抑止的(purely inhibitive)」と表現しています。ギャップロックの唯一の目的は、他のトランザクションがその隙間に挿入するのを防ぐことです。

  • あるトランザクションが隙間に共有ギャップロックを持っている状態で、別のトランザクションが同じ隙間に排他ギャップロックを持つことができます。
  • 共有ギャップロックと排他ギャップロックに違いはなく、互いに競合しません。

一方、挿入インテンションロックは他のトランザクションのギャップロックと競合して待機します。挿入インテンションロック同士は、同じ隙間でも位置が異なれば互いを待ちません。4と7の間に二つのトランザクションがそれぞれ5と6を挿入するケースがマニュアルの例です。

この二つのルールを合わせると、互いを待つ循環が起こる材料がそろいます。

「なければ挿入」がデッドロックになりうる過程

マニュアルは、ネクストキーロックを使ってアプリケーション側で一意性チェックを実装できると説明しています。共有モードで読み取って重複がなければ、読み取り中に設定されたネクストキーロックが、その間に他のトランザクションが重複行を挿入するのを防いでくれる、という仕組みです。マニュアルの言葉では「何かが存在しないこと」をロックする方法です。

重複は実際に防がれます。問題が起こりうるのは、二つのトランザクションが同時に同じチェックを行うときです。email に一意でないインデックスがあるテーブルを例にします。

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  KEY idx_email (email)
) ENGINE = InnoDB;

以下の流れはドキュメントのルールからたどったもので、実際のサーバーで取得した結果ではありません。分離レベルが REPEATABLE READ で、SELECT が実際に idx_email を使い(EXPLAIN で確認)、そのメールアドレスの行がまだ存在せず、既存のインデックス値の並びによって両セッションのロックが新しいエントリの入る同じ隙間にかかる、という前提です。

-- セッションA
START TRANSACTION;
SELECT id FROM users WHERE email = 'kim@example.com' FOR SHARE;  -- 0行

-- セッションB
START TRANSACTION;
SELECT id FROM users WHERE email = 'kim@example.com' FOR SHARE;  -- 0行、ギャップロック同士は両立

-- セッションA
INSERT INTO users (email) VALUES ('kim@example.com');  -- Bのギャップロックで待機しうる

-- セッションB
INSERT INTO users (email) VALUES ('kim@example.com');  -- Aのギャップロックで待機しうる → 循環
  1. 二つの SELECT ... FOR SHARE は同じ隙間をロックします。ギャップロック同士は競合しないので、どちらの読み取りも待ちません。
  2. Aの INSERT には挿入インテンションロックが必要ですが、Bが持つギャップロックと競合して待機します。
  3. Bの INSERT も同じ理由でAを待ちます。

ロックがこのように重なると、二つのトランザクションは互いを待つことになります。InnoDBはこの循環を検出して片方をロールバックし、ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction を返します。マニュアルのデッドロックの例に出てくるものと同じ形式のエラーです。アクセスパスや周辺の値、ロック範囲が異なれば、待ち方が変わったり、循環そのものが起きなかったりします。実際の状況は後述の方法で確認してください。

いずれにしても重複行は作られません。その代わり、同時リクエストの一方がデッドロックエラーで失敗することがあります。このパターンを使うコードでは、エラー1213を捕捉してトランザクションがロールバックされたものとして扱い、適切な場合は回数に上限を設けたうえでトランザクション全体を再実行します。

似たデッドロックは一意キーでも起こります。マニュアルのSQL文ごとのロックの説明によると、INSERT が重複キーエラーになると、そのレコードに共有ロックを設定します。三つのセッションが同じ主キー値を挿入し、最初のセッションがロールバックすると、残りの二つがどちらも共有ロックを取得したあと、互いの排他ロックを待ってデッドロックになります。INSERT ... ON DUPLICATE KEY UPDATE は共有ロックではなく排他ロックを設定します。

ギャップロックがかからない場合

すべてのロック読み取りが隙間をロックするわけではありません。

  • 一意インデックスで一意の行を一つ検索する場合は、見つかったインデックスレコードだけをロックし、その前の隙間はロックしません。たとえば id が一意インデックスなら、WHERE id = 100 はレコードロックだけを使います。
  • ただし、複数列の一意インデックスの一部の列だけを検索条件に使う場合は、ギャップロックがかかります。
  • 検索列にインデックスがない、または一意でないインデックスの場合は、前の隙間もロックします。

最後の項目に関連して見落としやすい点があります。ロック読み取り、UPDATE、DELETE は WHERE 条件に関係なく、スキャンしたすべてのインデックスレコードにロックを設定します。適切なインデックスがなくテーブル全体をスキャンすると、テーブルのすべての行がロックされ、他のユーザーの挿入もすべてブロックされます。ギャップロックが想定より広いと感じたら、まず EXPLAIN でどのインデックスが使われているかを確認するとよいでしょう。

READ COMMITTEDに切り替えると

分離レベルのドキュメントによると、READ COMMITTED では検索とインデックススキャンのギャップロックが無効になります。ギャップロックは外部キー制約のチェックと重複キーのチェックにだけ使われます。上の「なければ挿入」の循環は検索段階のギャップロックに依存しているので、このレベルでは同じ形では起こりません。

代償もはっきりしています。

  • 他のセッションが隙間に新しい行を挿入できるため、ファントムが起こりえます。上の FOR SHARE による一意性チェックは、もう重複を防げません。
  • READ COMMITTED では行ベースのバイナリログだけがサポートされます。binlog_format=MIXED の場合、サーバーは自動的に行ベースのログを使います。
  • UPDATE と DELETE は WHERE 条件を評価したあと、条件に合わない行のレコードロックを解放します。

つまり、分離レベルの変更はデッドロックの解決策というより、保証の交換です。まず重複防止の責任を一意制約に移し、そのうえで検討するのが順序です。email に UNIQUE 制約があれば、検索せずにそのまま INSERT して重複キーエラーを処理する方法が使えます。

ロックを実際に見る方法

推測するより観察するほうが早いです。

performance_schema.data_locks は、保持中または要求中のロックを表示します。

SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
       LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
  • LOCK_MODE が X,GAP のように GAP を含んでいれば、ギャップロックです。マニュアルのデッドロックの例には、S,REC_NOT_GAP のように隙間を含まないレコードロックも出てきます。
  • LOCK_STATUS は GRANTED または WAITING です。
  • LOCK_DATA が supremum pseudo-record なら、最大値の後ろの隙間がロックされています。
  • どの要求がどのロックにブロックされているかは、performance_schema.data_lock_waits で確認できます。

SHOW ENGINE INNODB STATUS では、次のような文言で表示されます。lock_mode X locks rec but not gap はレコードロック、修飾のない lock_mode X はネクストキーロック、lock_mode X locks gap before rec insert intention waiting はギャップロックのせいで待機している挿入インテンションロックです。このコマンドは直近のデッドロックの原因も表示します。すべてのデッドロックをエラーログに記録するには innodb_print_all_deadlocks を有効にし、デバッグが終わったら無効にするようマニュアルは勧めています。

まとめ

  • REPEATABLE READ のロック読み取りは、見つかった行だけでなく、スキャンした範囲の隙間もロックします。0行を返したクエリでも挿入をブロックすることがあります。
  • ギャップロック同士は競合せず、挿入だけを防ぎます。そのため、二つのトランザクションが同じ隙間を確認したあと両方とも挿入すると、デッドロックになることがあります。
  • 一意インデックスで行を一つ検索する場合はギャップロックがかかりません。逆にインデックスがなければ、テーブル全体がロックされます。
  • READ COMMITTED は検索のギャップロックをなくしますが、ファントムの防止も一緒になくなります。

マニュアルのデッドロック対処のガイドは、デッドロックで失敗したトランザクションを再実行できるよう常に備えておくよう述べています。ギャップロックを理解しておけば、どのコードパスでそのリトライが必要になりやすいかを前もって把握できます。

ブログへ