🔒

InnoDB のロックアーキテクチャを理解してデッドロックを説明できるようにする

2024/08/26に公開
2

InnoDB のロックアーキテクチャを解説し、デッドロックの原因を説明できるようにします。開発時に実際に起きたデッドロックを題材にし、インデックス、ロックアーキテクチャ、実際のロック例、デッドロックの解明という順で説明します。記事中の SQL は実際に試すことができるので手を動かしてみてください。

できる限り他の記事やドキュメントを見なくても良いように書いています。そのため「参考リンク」は折りたたみにしています。より詳しい情報を知りたい場合は参照してください。

※この記事は株式会社 Finatext のテックブログの一つとして書いています。 株式会社 Finatext のテックブログに関しては以下をご覧ください

https://techblog.finatext.com/

実行環境

  • MySQL 8.4.0
  • InnoDB
  • トランザクション分離レベル: REPEATABLE READ

実際に起きたデッドロック

まずは実際に起きたデッドロックを再現します。最終的にこのデッドロックの原因を説明できるようにします。

再現用のテーブル定義 scores は以下です。 id, name, score, created_at というシンプルなテーブルです。 インデックスは namescore の複合インデックスがあります。

CREATE TABLE `scores` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    `score` int unsigned NOT NULL,
    `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    PRIMARY KEY (`id`),
    KEY `idx_name_score` (`name`, `score`)
) ENGINE = InnoDB AUTO_INCREMENT = 0;

データを insert します。

insert into scores
    (id, name, score)
values
    (10, 'a', 10),
    (20, 'b', 20),
    (30, 'c', 30);
mysql> select * from scores order by name,score;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 10 | a    |    10 | 2024-08-14 21:29:31.490074 |
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
| 30 | c    |    30 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
3 rows in set (0.00 sec)

この状態でトランザクション1(以下 Tx1 )と Tx2 にて、以下の順序でクエリを投げます。 Tx1 はユーザー c の操作、 Tx2 はユーザー b の操作をしています。

-- Tx1
-- ユーザー 'c' に score = 25 を insert

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into scores (name,score) values ('c', 25);
Query OK, 1 row affected (0.00 sec)
-- Tx2
-- ユーザー 'b' の score < 22 を select for update

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where name = 'b' and score < 22 for update;
-- ここでロック待ちになる
-- Tx1
-- ユーザー 'c' に score = 23 を insert

mysql> insert into scores (name,score) values ('c', 23);
Query OK, 1 row affected (0.00 sec)
-- Tx2

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
-- ロック待ちしていたクエリが Deadlock となる

図に表すと以下になります。

それぞれの Tx で別々のユーザーを操作しているのにデッドロックが起きています。これはなぜでしょうか。以下、 InnoDB のロックアーキテクチャを説明した上で、このデッドロックの原因を解明します。

インデックスの仕組み

まずはインデックスについてです。インデックスを理解しないと InnoDB のロックを理解することはできません。最初に押さえておきます。

B+Tree

InnoDB のインデックス構造のデフォルトは B+Tree です。基本的な構造は多分岐の平衡木(バランス木)ですが、 B+Tree は以下の特徴を持っています。

  • ① リーフノード以外のノードには、インデックスのキー値を格納(探索部)
  • ② データ値はリーフノードのみに格納(データ格納部)
  • ③ リーフノードの隣接間がポインタで接続されている
  • ④ リーフノードはデータ値で常にソートされている

この構造のおかげで、一意検索にも範囲検索にも強くなっています。

参考リンク

クラスタインデックス、セカンダリインデックス

インデックスには「クラスタインデックス」と「セカンダリインデックス」があります。

クラスタインデックスは、主キーをキーとするインデックスです。各テーブルに必ずひとつ存在し、かつ、キーによって全ての行を一意に特定できます。リーフノードには全てのカラム値が格納されています。イメージは以下です。

セカンダリインデックスは、主キー以外のカラムにつけられるインデックスです。インデックスの各ノードには、インデックス作成時に指定されたキーの値が格納されています。リーフノードには主キーが格納されています。イメージは以下です。

セカンダリインデックスで全カラムの値を取得するには、セカンダリインデックスの走査で得た主キーを元に、クラスタインデックスを辿って全カラムデータを取得します。

参考リンク

複合インデックス

インデックスは複数のカラムを指定できます。この場合、複合キーの左から優先度高くリーフノードがソートされます。

このような構造なので、複合キーの2番目のカラムを指定しても、ツリーを辿ることができません。複合インデックスを利用するには、指定する順序が非常に重要です。

参考リンク

InnoDB のロックアーキテクチャ

インデックスの仕組みを押さえた上で、 InnoDB のロックアーキテクチャについて説明します。まず、一番大事なことを先に言います。

「 InnoDB のロックは、走査したインデックスに対して行われる」

テーブルの各行をロックするのではありません。インデックスに対してロックが行われます。また、「走査したインデックス」が対象です。 MySQL の行のフィルタリングは「インデックス走査」と「 Executer によるフィルタリング」があります。場合によってはこの両方が行われます。例えば、以下のような結果を得る SQL があったとします。

  • インデックス走査で 1000 行を走査した
  • インデックス走査で得た 1000 行を、 Executer で 10 行にフィルタリングした
  • よって、返ってきた結果は 10 行だった

この場合、ロックされるインデックスレコードは 1000 行になります。結果に対してかなり広範囲がロックされています。つまり、インデックスをうまく使わないと思わぬ箇所がロックされてしまうのです。

ロックの可視化方法

始めにロックの可視化方法を説明します。 InnoDB にはロック状況を確認する方法がいくつかあります。

performance_schema.data_locks

performance_schema.data_locks テーブルを見ることで、現在のロック状況がわかります。全カラム表示してもよいのですが、自分がいつも見ているカラムは以下です。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| TABLE     |                  9080 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                  9080 | idx_name_score | X             | GRANTED     | 'b', 20, 20 |
| RECORD    |                  9080 | PRIMARY        | X,REC_NOT_GAP | GRANTED     | 20          |
| RECORD    |                  9080 | idx_name_score | X,GAP         | GRANTED     | 'c', 30, 30 |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
4 rows in set (0.00 sec)
  • LOCK_TYPE
    • テーブルのロックかレコードのロックか
  • ENGINE_TRANSACTION_ID
    • そのロックを取得しているトランザクションの ID
  • INDEX_NAME
    • ロック対象のインデックス名
  • LOCK_MODE
    • ロックの種類(共有(S)ロック、排他(X)ロック、レコードロック、ギャップロック、挿入インテンションロック、ネクストキーロック)
  • LOCK_STATUS
    • 付与済み(GRANTED) or 待機中(WAITING
  • LOCK_DATA
    • ロックしているインデックスレコードに関する、行の値の情報

sys.innodb_lock_waits

sys.innodb_lock_waits を見ることで、待機中のロックの情報を見ることができます。

mysql> SELECT * FROM  sys.innodb_lock_waits\G
*************************** 1. row ***************************
                wait_started: 2024-08-12 22:32:40
                    wait_age: 00:00:16
               wait_age_secs: 16
                locked_table: `test`.`users`
         locked_table_schema: test
           locked_table_name: scores
      locked_table_partition: NULL
   locked_table_subpartition: NULL
                locked_index: PRIMARY
                 locked_type: RECORD
              waiting_trx_id: 8582
         waiting_trx_started: 2024-08-12 22:31:09
             waiting_trx_age: 00:01:47
     waiting_trx_rows_locked: 1
   waiting_trx_rows_modified: 2
                 waiting_pid: 18
               waiting_query: insert into users (id,name,score) values (200,'b',40)
             waiting_lock_id: 5257631584:6:4:5:5117150744
           waiting_lock_mode: S,REC_NOT_GAP
             blocking_trx_id: 8580
                blocking_pid: 17
              blocking_query: NULL
            blocking_lock_id: 5257630592:6:4:5:5117146488
          blocking_lock_mode: X,REC_NOT_GAP
        blocking_trx_started: 2024-08-12 22:27:26
            blocking_trx_age: 00:05:30
    blocking_trx_rows_locked: 2
  blocking_trx_rows_modified: 1
     sql_kill_blocking_query: KILL QUERY 17
sql_kill_blocking_connection: KILL 17
1 row in set (0.03 sec)

大体はカラム名からわかると思います。特に見ているのは以下のカラムです。

  • waiting_trx_id
  • waiting_query
  • waiting_lock_mode
  • blocking_trx_id
  • blocking_query
  • blocking_lock_mode

これで「どのロックがどのロックにブロックされているか」を確認できます。

次はこれらの可視化方法を実際に使いつつ、ロックタイプとロックの種類を見ていきます。

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

ロックの「タイプ」は2種類あります。共有(S)ロックと排他(X)ロックです。

ロックを取得するときは、特定のインデックスレコードに対して「ロックリクエスト」を送ります。ロックリクエストは共有(S)ロック or 排他(X)ロックのタイプを持ちます。先行するロックのタイプに応じて挙動が変わります。

  • 先行するロック(S) に (S)のロックリクエスト → 即座にロック付与
  • 先行するロック(S) に (X)のロックリクエスト → waiting
  • 先行するロック(X) に (S)のロックリクエスト → waiting
  • 先行するロック(X) に (X)のロックリクエスト → waiting

つまり、共有(S)ロック同士は複数トランザクションで取得可能ですが、排他(X)ロックが関わるものはすべて waiting (ロック待ち)になります。

共有(S)ロックと排他(X)ロックは、 performance_schema.data_locks.LOCK_MODE では SX といった文字で表されています。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| TABLE     |                  9080 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                  9080 | idx_name_score | X             | GRANTED     | 'b', 20, 20 |
| RECORD    |                  9080 | PRIMARY        | X,REC_NOT_GAP | GRANTED     | 20          |
| RECORD    |                  9080 | idx_name_score | X,GAP         | GRANTED     | 'c', 30, 30 |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
4 rows in set (0.00 sec)
参考リンク

レコードロック

次は「ロックされる範囲」に関するロックの分類です。

レコードロックは「特定のインデックスレコード」をロックします。一意な行をロックするためのものなので、 B+Tree の「1つのリーフノード」をロックします。

レコードロックは、 performance_schema.data_locks.LOCK_MODEX,REC_NOT_GAP と表示されます。ロックタイプの後ろに REC_NOT_GAP とついています。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+--------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA    |
+-----------+-----------------------+----------------+---------------+-------------+--------------+
...
| RECORD    |                  8571 | PRIMARY        | X,REC_NOT_GAP | GRANTED     | 2            |
...
+-----------+-----------------------+----------------+---------------+-------------+--------------+
参考リンク

ギャップロック

ギャップロックは、「インデックスレコード間のギャップ」をロックします。

ギャップロックの目的は「ギャップへの insert のブロック」です。そもそもギャップにはインデックスレコードが存在しないので、「特定の行の update や delete 」といった操作はあり得ません。故に「 insert のブロック」のみを目的としたロックであり、以下のような特徴があります。

  • 同一のギャップに対して、複数のトランザクションがギャップロックを取得できる
  • ブロックするのは insert 時の特殊なロック「挿入インテンションロック(後述)」のみ

ギャップロックは、 performance_schema.data_locks.LOCK_MODEX,GAP と表示されます。ロックタイプの後ろに GAP とついています。 LOCK_DATA にはインデックスのキーが表示されています。このキーは「ギャップのすぐ後ろのインデックスレコード」を表しています。以下の例では、ロックしているギャップの後ろの 'c', 30 というインデックスレコードが表示されています。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+--------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA    |
+-----------+-----------------------+----------------+---------------+-------------+--------------+
...
| RECORD    |                  8571 | idx_name_score | X,GAP         | GRANTED     | 'c', 30, 3   |
...
+-----------+-----------------------+----------------+---------------+-------------+--------------+

insert のブロックの仕組みは、次の「挿入インテンションロック」で説明します。

参考リンク

挿入インテンションロック

挿入インテンションロックはギャップロックと共に「 insert のブロック」を目的としたロックです。 GitHub のコードコメントを読むと、特徴は以下のように説明されています。

不思議な特徴です。他のロックをブロックしないロックです。詳細なコードを読んでいないので私の推測ですが、このロックが必要になった理由は以下だと考えています。

  • ロックによるブロックは「競合するロックをブロックする」という仕組みである
  • つまり、 insert をブロックしたいなら、「ギャップロックと競合するロック」が必要
  • しかし、 insert のときには対象のインデックスレコードはまだ作成されていないので、レコードロックやネクストキーロックは利用できない
  • ギャップロックも「複数トランザクションで取得できる」という特徴から、 insert のブロックには使えない
  • よって、「ギャップロックと競合するロック」として特殊なロックが必要であり、それが「挿入インテンションロック」である

これにより「 insert をブロックする」という挙動を「競合するロックをブロックする」というロック本来の仕組みを利用して実現しています。イメージ図を描くと以下になります。

挿入インテンションロックは、 performance_schema.data_locks.LOCK_MODEX,GAP,INSERT_INTENTION のように表示されます。ギャップロックの一種だということがわかりますね。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+------------------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE              | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+------------------------+-------------+-------------+
| TABLE     |                 11061 | NULL           | IX                     | GRANTED     | NULL        |
| RECORD    |                 11061 | idx_name_score | X,GAP,INSERT_INTENTION | WAITING     | 'c', 25, 36 |
...
+-----------+-----------------------+----------------+------------------------+-------------+-------------+
参考リンク

ネクストキーロック

ネクストキーロックは、特定のインデックスレコードのレコードロックと、そのインデックスレコードの前のギャップロックの組み合わせです。「前のギャップ」というのが重要です。

ネクストキーロックの目的はファントム行の阻止です。ファントム行とは「トランザクション内において、1回目の select 結果に無かった行が、2回目の select 結果に出現する」というものです。 InnoDB の REPEATABLE READ の仕組みだけではファントム行が発生します。しかしそれを「インデックス走査+ネクストキーロック」という仕組みで回避しています。インデックスの走査範囲をすべてネクストキーロックでロックすることで、ファントム行を起こし得る insert や update, delete をすべてブロックしています。

ネクストキーロックは、 performance_schema.data_locks.LOCK_MODE では X のように表示されます。 X だけだと最初は「レコードロックなのでは?」と思いますが、これはネクストキーロックです。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| TABLE     |                 11060 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                 11060 | idx_name_score | X             | GRANTED     | 'b', 20, 20 |
...
+-----------+-----------------------+----------------+---------------+-------------+-------------+
参考リンク

ロックキュー

InnoDB のロックアーキテクチャでもう一つ重要なものが「ロックキュー」です。ロックキューをイメージすることで「この SQL がなぜブロックされるのか」を理解しやすくなります。

InnoDB のロックアーキテクチャでは、各行を「リソース」として扱い、トランザクションはそのリソースに対してアクセス権を要求する(ロックをリクエストする)という形を取っています。各リソースにはそれぞれロックキューがあり、リクエストされたロックはキューに格納されていきます。 LOCK_STATUSGRANTED でも WAITING でも同一のキューに格納されます。

そして、一番大事なことは「あるリソースに新しいロックリクエストが来ると、既にキューにある全てのロック( GRANTED および WAITING )と競合チェックする」という点です。

WAITING のロックとも競合チェックするのは意外でした。より詳しい説明は、参考リンクから GitHub のコードコメントを参照してください。

参考リンク

実はロックキューは特殊な形をしています。ロックは GRANTEDWAITING の2つのグループに分けられます。この2つとも同じキューで扱われますが、

  • GRANTED ロックはキューの先頭へ追加される
  • WAITING ロックはキューの末尾に追加される

という扱いになっています。図で表すと以下になります。 GitHub のコメントにあるテキストを抜粋します。

Grows <---- [HEAD] [G7 -- G3 -- G2 -- G1] -|- [W4 -- W5 -- W6] [TAIL] ---> Grows
                         Grant Group       |         Wait Group

        G - Granted,
        W - waiting,
        suffix number is the chronological order of requests.

これは複雑なロックスケジューリングアルゴリズムの影響です。詳しくは上記のコードコメントを参照してください。

以上が InnoDB のロックアーキテクチャの説明です。次は実際のロックを見ていきます。

実際のロック例

冒頭のテストテーブルを用いて実際のロックを見ていきます。テーブル定義とデータを再掲します。

CREATE TABLE `scores` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    `score` int unsigned NOT NULL,
    `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    PRIMARY KEY (`id`),
    KEY `idx_name_score` (`name`, `score`)
) ENGINE = InnoDB AUTO_INCREMENT = 0;
insert into scores
    (id, name, score)
values
    (10, 'a', 10),
    (20, 'b', 20),
    (30, 'c', 30);
mysql> select * from scores order by name,score;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 10 | a    |    10 | 2024-08-14 21:29:31.490074 |
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
| 30 | c    |    30 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
3 rows in set (0.00 sec)

この状態でのインデックスは以下になります。

  • セカンダリインデックス
    • key は name, score
    • value は PK
  • クラスタインデックス
    • key は PK
    • value は 全カラム値

クラスタインデックスの等価検索によるロック

id の等価検索である以下のクエリのロック状況を見てみましょう。

select * from scores where id = 20 for update;

ロック状況は以下です。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where id = 20 for update;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
1 row in set (0.00 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+------------+---------------+-------------+-----------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME | LOCK_MODE     | LOCK_STATUS | LOCK_DATA |
+-----------+-----------------------+------------+---------------+-------------+-----------+
| TABLE     |                  9081 | NULL       | IX            | GRANTED     | NULL      |
| RECORD    |                  9081 | PRIMARY    | X,REC_NOT_GAP | GRANTED     | 20        |
+-----------+-----------------------+------------+---------------+-------------+-----------+
2 rows in set (0.01 sec)

INDEX_NAME=PRIMARY, LOCK_MODE=X,REC_NOT_GAP, LOCK_DATA=20 となっているので、クラスタインデックスの id=20 のレコードロックということがわかります。

クラスタインデックスの空振り検索によるロック

id の一意検索で空振りするとロック状況が変わります。

select * from scores where id = 15 for update;
-- id = 15 の行は存在していない

ロック状況は以下です。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where id = 15 for update;
Empty set (0.01 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+------------+-----------+-------------+-----------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------+-----------------------+------------+-----------+-------------+-----------+
| TABLE     |                  9082 | NULL       | IX        | GRANTED     | NULL      |
| RECORD    |                  9082 | PRIMARY    | X,GAP     | GRANTED     | 20        |
+-----------+-----------------------+------------+-----------+-------------+-----------+
2 rows in set (0.01 sec)

INDEX_NAME=PRIMARY, LOCK_MODE=X,GAP, LOCK_DATA=20 となっているので、クラスタインデックスの id=20 のインデックスレコードの前のギャップに対するギャップロックということがわかります。指定した id 以外の insert もブロックしてしまうので注意が必要です。

クラスタインデックスの範囲検索によるロック

続いて、範囲検索を見ていきます。

select * from scores where id < 25 for update;

ロック状況は以下です。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where id < 25 for update;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 10 | a    |    10 | 2024-08-14 21:29:31.490074 |
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
2 rows in set (0.01 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+------------+-----------+-------------+-----------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------+-----------------------+------------+-----------+-------------+-----------+
| TABLE     |                 11023 | NULL       | IX        | GRANTED     | NULL      |
| RECORD    |                 11023 | PRIMARY    | X         | GRANTED     | 10        |
| RECORD    |                 11023 | PRIMARY    | X         | GRANTED     | 20        |
| RECORD    |                 11023 | PRIMARY    | X,GAP     | GRANTED     | 30        |
+-----------+-----------------------+------------+-----------+-------------+-----------+
4 rows in set (0.01 sec)

LOCK_MODE=X はネクストキーロックです。 id=10id=20 に対してネクストキーロックが付与されています。 id=30 にはギャップロックが付与されています。図にすると以下になります。

範囲検索の場合、インデックス走査は以下のようになります。

  • 条件句の最小値から順にインデックスレコードを検査する
  • 条件に合致していたら、次のインデックスレコードへ移る
  • 条件に合致していないインデックスレコードでは、エラーを返してループを抜ける

該当のループのコード - GitHub

つまり、「条件に合致しないインデックスレコード」もひとつだけ走査します。そのインデックスレコードにもロックを付与します。

範囲検索の場合は通常、「インデックス走査+ネクストキーロック」の組み合わせでロックします。しかし、クラスタインデックスの場合は「条件に合致しないインデックスレコード」だけネクストキーロックではなくギャップロックのみになります。この挙動は 8.0.18 から入った修正です。詳しくは参考リンクを見てください。

参考リンク

セカンダリインデックスの等価検索によるロック

続いてセカンダリインデックスの挙動を見ていきます。まずは等価検索です。

select * from scores where name = 'b' for update;

ロック状況は以下です。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where name = 'b' for update;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
1 row in set (0.01 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| TABLE     |                 11024 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                 11024 | idx_name_score | X             | GRANTED     | 'b', 20, 20 |
| RECORD    |                 11024 | PRIMARY        | X,REC_NOT_GAP | GRANTED     | 20          |
| RECORD    |                 11024 | idx_name_score | X,GAP         | GRANTED     | 'c', 30, 30 |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
4 rows in set (0.00 sec)

インデックス idx_name_score に対して、ネクストキーロック X とギャップロック X,GAP が付与されています。また、等価検索の一致レコードには、クラスタインデックスのレコードロックが付与されています。図にすると以下になります。

ネクストキーロックとギャップロックが付与されるのは、インデックス idx_name_score がユニークインデックスではないからです。インデックスの走査は以下になります。

  • name='b' のインデックスレコードの最小値を探索
  • 最小レコードからインデックスレコードを走査し、条件 name='b' に合致しないインデックスレコードまで走査する
  • 走査したインデックスレコードにネクストキーロックを付与する
  • 合致しないインデックスレコードにはギャップロックを付与する

これにより「 name='b' が insert される可能性のある範囲」を全てブロックしています。

セカンダリインデックスの空振り検索によるロック

次はセカンダリインデックスの空振り検索です。

select * from scores where name = 'b' and score = 15 for update;
-- このレコードは存在しない

ロック状況は以下です。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where name = 'b' and score = 15 for update;
Empty set (0.00 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+-----------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+-----------+-------------+-------------+
| TABLE     |                 11025 | NULL           | IX        | GRANTED     | NULL        |
| RECORD    |                 11025 | idx_name_score | X,GAP     | GRANTED     | 'b', 20, 20 |
+-----------+-----------------------+----------------+-----------+-------------+-------------+
2 rows in set (0.00 sec)

対象のギャップに対してギャップロックが付与されています。これはクラスタインデックスと同じ挙動です。今回は対象のインデックスレコードは存在しないため、クラスタインデックスのレコードロックは付与されていません。

セカンダリインデックスの範囲検索によるロック

セカンダリインデックスの範囲検索も見ていきます。

select * from scores where name = 'b' and score < 25 for update;

ロック状況は以下です。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where name = 'b' and score < 25 for update;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
1 row in set (0.01 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| TABLE     |                 11026 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                 11026 | idx_name_score | X             | GRANTED     | 'b', 20, 20 |
| RECORD    |                 11026 | idx_name_score | X             | GRANTED     | 'c', 30, 30 |
| RECORD    |                 11026 | PRIMARY        | X,REC_NOT_GAP | GRANTED     | 20          |
+-----------+-----------------------+----------------+---------------+-------------+-------------+
4 rows in set (0.00 sec) 

クラスタインデックスの際に説明したとおり、範囲検索なので「インデックス走査+ネクストキーロック」でロックしています。範囲判定のために走査した 'c', 30 のインデックスレコードもネクストキーロックされているのがわかります。かつ、範囲内に見つかった id=20 に対しては、クラスタインデックスのレコードロックも取得しています。

insert によるロック

最後に insert によるロックを見ていきます。

insert into scores (id,name,score) values (15,'b',15);

挿入位置は、各インデックスで以下になります。

ロック状況を見てみます。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into scores (id,name,score) values (15,'b',15);
Query OK, 1 row affected (0.01 sec)

mysql> select * from scores order by name,score;
+----+------+-------+----------------------------+
| id | name | score | created_at                 |
+----+------+-------+----------------------------+
| 10 | a    |    10 | 2024-08-14 21:29:31.490074 |
| 15 | b    |    15 | 2024-08-16 09:32:09.212076 |
| 20 | b    |    20 | 2024-08-14 21:29:31.490074 |
| 30 | c    |    30 | 2024-08-14 21:29:31.490074 |
+----+------+-------+----------------------------+
4 rows in set (0.00 sec)

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+------------+-----------+-------------+-----------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+-----------+-----------------------+------------+-----------+-------------+-----------+
| TABLE     |                 11035 | NULL       | IX        | GRANTED     | NULL      |
+-----------+-----------------------+------------+-----------+-------------+-----------+
1 row in set (0.00 sec)

おや、インデックスに対して何もロックされていないように見えます。他のセッションで id=15 に insert を試みてみましょう。

-- 別セッション

mysql> insert into scores (id,name,score) values (15,'hoge',999);
-- ブロックされる

ブロックされました。やはり insert によって何らかのロックが存在しています。ブロック中のロック状況を見てみます。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+------------+---------------+-------------+-----------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME | LOCK_MODE     | LOCK_STATUS | LOCK_DATA |
+-----------+-----------------------+------------+---------------+-------------+-----------+

-- 後続の Tx
| TABLE     |                 11036 | NULL       | IX            | GRANTED     | NULL      |
| RECORD    |                 11036 | PRIMARY    | S,REC_NOT_GAP | WAITING     | 15        |

-- 先行の Tx
| TABLE     |                 11035 | NULL       | IX            | GRANTED     | NULL      |
| RECORD    |                 11035 | PRIMARY    | X,REC_NOT_GAP | GRANTED     | 15        |

+-----------+-----------------------+------------+---------------+-------------+-----------+
4 rows in set (0.01 sec)

先に insert をしたトランザクションの ENGINE_TRANSACTION_ID11035 でした。よく見ると上から4行目に LOCK_MODE=X,REC_NOT_GAP, LOCK_DATA=15 というレコードロックが現れています。この排他レコードロックによってブロックされていたのです。

sys.innodb_lock_waits を見てみましょう。

mysql> SELECT * FROM  sys.innodb_lock_waits\G
*************************** 1. row ***************************
                wait_started: 2024-08-16 09:32:37
                    wait_age: 00:00:05
               wait_age_secs: 5
                locked_table: `test`.`scores`
         locked_table_schema: test
           locked_table_name: scores
      locked_table_partition: NULL
   locked_table_subpartition: NULL
                locked_index: PRIMARY
                 locked_type: RECORD
              waiting_trx_id: 11036    -- ===================> 待機中のトランザクションID
         waiting_trx_started: 2024-08-16 09:32:37
             waiting_trx_age: 00:00:05
     waiting_trx_rows_locked: 1
   waiting_trx_rows_modified: 0
                 waiting_pid: 11
               waiting_query: insert into scores (id,name,score) values (15,'hoge',999)
             waiting_lock_id: 4722070400:13:4:7:5234578968
           waiting_lock_mode: S,REC_NOT_GAP
             blocking_trx_id: 11035    -- ===================> ブロックしているトランザクションID
                blocking_pid: 10
              blocking_query: SELECT * FROM  sys.innodb_lock_waits
            blocking_lock_id: 4722069408:13:4:7:5234574360
          blocking_lock_mode: X,REC_NOT_GAP
        blocking_trx_started: 2024-08-16 09:32:09
            blocking_trx_age: 00:00:33
    blocking_trx_rows_locked: 1
  blocking_trx_rows_modified: 1
     sql_kill_blocking_query: KILL QUERY 10
sql_kill_blocking_connection: KILL 10
1 row in set (0.02 sec)

予想通り、 blocking_trx_id: 11035waiting_trx_id: 11036 をブロックしていることがわかります。レコードロックの存在を確認できました。

改めてデッドロックの原因を探る

ここまできたら記事冒頭のデッドロックを説明できるはずです。見ていきましょう。

まずは Tx1 です。

-- Tx1
-- ユーザー 'c' に score = 25 を insert

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into scores (name,score) values ('c', 25);
Query OK, 1 row affected (0.00 sec)

続いて Tx2 です。

-- Tx2
-- ユーザー 'b' の score < 22 を select for update

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from scores where name = 'b' and score < 22 for update;
-- ここでロック待ちになる

ロック待ちになりました。ロック状況を見てみましょう。わかりやすいように Tx1, Tx2 で分けて見てみます。

mysql> SELECT LOCK_TYPE,ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
+-----------+-----------------------+----------------+---------------+-------------+-------------+
| LOCK_TYPE | ENGINE_TRANSACTION_ID | INDEX_NAME     | LOCK_MODE     | LOCK_STATUS | LOCK_DATA   |
+-----------+-----------------------+----------------+---------------+-------------+-------------+

-- Tx2
| TABLE     |                 11040 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                 11040 | idx_name_score | X             | GRANTED     | 'b', 20, 20 |
| RECORD    |                 11040 | PRIMARY        | X,REC_NOT_GAP | GRANTED     | 20          |
| RECORD    |                 11040 | idx_name_score | X             | WAITING     | 'c', 25, 33 |

-- Tx1
| TABLE     |                 11037 | NULL           | IX            | GRANTED     | NULL        |
| RECORD    |                 11037 | idx_name_score | X,REC_NOT_GAP | GRANTED     | 'c', 25, 33 |

+-----------+-----------------------+----------------+---------------+-------------+-------------+
6 rows in set (0.01 sec)

これにより、以下がわかります。

  • Tx1(赤)
    • insert によりインデックス idx_name_score のインデックスレコード 'c', 25 にレコードロックが付与されている
  • Tx2(青)
    • select for update の範囲検索により、範囲内の 'b', 20 と範囲外のひとつ 'c', 25 にネクストキーロックを付与しようとする
    • しかし、 Tx1 のレコードロックによって 'c', 25 に排他ロックがかかっているので、 Tx2 の 'c', 25 のネクストキーロックは WAITING になっている

さて、この状態で Tx1 で 'c', 23 に insert を行うとデッドロックが発生するのでした。

-- Tx1
-- ユーザー 'c' に score = 23 を insert

mysql> insert into scores (name,score) values ('c', 23);
Query OK, 1 row affected (0.00 sec)
-- Tx2

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
-- ロック待ちしていたクエリが Deadlock となる

先に Tx2 のネクストキーロックが Tx1 のレコードロックによってブロックされていました。デッドロックが起きたということは、今度は逆に「 Tx1 のロックが Tx2 のロックによってブロックされた」ということです。ブロックされている Tx1 のロック、ブロックしている Tx2 のロック、それぞれ何のロックでしょうか。

デッドロックが起きた瞬間を整理すると原因が見えてきます。デッドロックが起きた瞬間の Tx1 の SQL は以下でした。

-- Tx1
-- ユーザー 'c' に score = 23 を insert

mysql> insert into scores (name,score) values ('c', 23);
Query OK, 1 row affected (0.00 sec)

これは insert なので、対象のギャップに対して挿入インテンションロックをリクエストします。対象のギャップは 'c', 25 となります。

では、この挿入インテンションロックをブロックするギャップロックはなんでしょうか。図からもうおわかりですね。 WAITING 状態になっている Tx2 のネクストキーロックです。ロックキューの説明にあったとおり、新しくリクエストされたロックは、 GRANTED WAITING 問わず、ロックキュー内の全てのロックと競合チェックされます。この仕組みにより、 Tx1 の挿入インテンションロックが Tx2 の WAITING 状態のネクストキーロックによってブロックされます。「 Tx1 のロックが Tx2 のロックによってブロックされた」という状況が起き、デッドロックに陥ったのでした。

本当にそうなっていたのかを show engine innodb status;LATEST DETECTED DEADLOCK セクションで確かめてみます。これは直近のデッドロックの詳細を見ることができるコマンドです。デッドロックを引き起こした2つのトランザクションについて、原因となっているロックの情報を見ることができます。いろいろ出ていますが、くじけずにじっくり見てみましょう。コメントを記載した箇所を見ていただければ状況がわかると思います。

mysql> show engine innodb status;

... snip ...

------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-08-16 11:58:23 0x170623000
*** (1) TRANSACTION:  -- デッドロックを引き起こした1つ目のトランザクション
TRANSACTION 11040, ACTIVE 14 sec fetching rows  -- ID が 11040 なので、 Tx2 を意味する
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1192, 3 row lock(s)
MySQL thread id 11, OS thread handle 6199570432, query id 112 localhost root executing
select * from scores where name = 'b' and score < 22 for update  -- デッドロックを引き起こした Tx2 の SQL

*** (1) HOLDS THE LOCK(S):  -- Tx2 がホールドしているロック。このロックが Tx1 のロックをブロックしている
RECORD LOCKS space id 13 page no 5 n bits 80 index idx_name_score of table `test`.`scores` trx id 11040 lock_mode X waiting  -- `lock_mode X waiting` でネクストキーロックの `WAITING` 状態のロックを保持していることがわかる
Record lock, heap no 7 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
-- 以下は行の情報
 0: len 1; hex 63; asc c;;  -- name = 'c' 
 1: len 4; hex 00000019; asc     ;;  -- score = 25 (16進数 '19' は10進数の '25')
 2: len 4; hex 00000021; asc    !;;  -- id = 33 (16進数 '21' は10進数の '33')


*** (1) WAITING FOR THIS LOCK TO BE GRANTED: -- Tx2 の WAITING 状態のロックについて
RECORD LOCKS space id 13 page no 5 n bits 80 index idx_name_score of table `test`.`scores` trx id 11040 lock_mode X waiting  -- `lock_mode X` でネクストキーロックが `WAITING` になっていることがわかる
Record lock, heap no 7 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
-- 以下は行の情報
 0: len 1; hex 63; asc c;;  -- name = 'c'
 1: len 4; hex 00000019; asc     ;;  -- score = 25 (16進数 '19' は10進数の '25')
 2: len 4; hex 00000021; asc    !;;  -- id = 33 (16進数 '21' は10進数の '33')


*** (2) TRANSACTION:  -- デッドロックを引き起こした2つ目のトランザクション
TRANSACTION 11037, ACTIVE 548 sec inserting  -- ID が 11037 なので、 Tx1 を意味する
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1192, 2 row lock(s), undo log entries 2
MySQL thread id 10, OS thread handle 6198456320, query id 113 localhost root update
insert into scores (name,score) values ('c', 23)  -- デッドロックを引き起こした Tx1 の SQL

*** (2) HOLDS THE LOCK(S):  -- Tx1 がホールドしているロック。このロックが Tx2 のロックをブロックしている
RECORD LOCKS space id 13 page no 5 n bits 80 index idx_name_score of table `test`.`scores` trx id 11037 lock_mode X locks rec but not gap  -- `lock_mode X locks rec but not gap` でレコードロックを保持していることがわかる
Record lock, heap no 7 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
-- 以下は行の情報
 0: len 1; hex 63; asc c;;  -- name = 'c' 
 1: len 4; hex 00000019; asc     ;;  -- score = 25 (16進数 '19' は10進数の '25')
 2: len 4; hex 00000021; asc    !;;  -- id = 33 (16進数 '21' は10進数の '33')


*** (2) WAITING FOR THIS LOCK TO BE GRANTED: -- Tx1 の WAITING 状態のロックについて
RECORD LOCKS space id 13 page no 5 n bits 80 index idx_name_score of table `test`.`scores` trx id 11037 lock_mode X locks gap before rec insert intention waiting  -- `X locks gap before rec insert intention waiting` で挿入インテンションロックが `WAITING` になっていることがわかる
Record lock, heap no 7 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
-- 以下は行の情報
 0: len 1; hex 63; asc c;;  -- name = 'c'
 1: len 4; hex 00000019; asc     ;;  -- score = 25 (16進数 '19' は10進数の '25')
 2: len 4; hex 00000021; asc    !;;  -- id = 33 (16進数 '21' は10進数の '33')
-- この `'c', 25` のデータは、挿入インテンションロックがブロックされているギャップの後方のインデックスレコードを表している

*** WE ROLL BACK TRANSACTION (1)
-- rollback されたのは TRANSACTION (1) = Tx2
-- Tx1 の insert は rollback されずに commit されている

... snip ...

確かに説明のとおりになっていますね。

これで、冒頭のデッドロックの仕組みを説明することができました。

最後に

InnoDB のロックアーキテクチャを理解すると、デッドロックがなぜ起きたかをイメージしやすくなります。この記事がその一助となれば幸いです。

ただ、ここに記載している内容は一部に過ぎません。他に以下のケースが考えられます。

  • カバリングインデックス
  • join
  • update/delete
  • 外部キー
  • サブクエリ
  • etc...

ここまで読んだ方であれば調査方法もご存知のはずなので、ぜひ手を動かしてみてください。どういうロック状況で、なぜデッドロックが起きているのかをイメージしやすくなっているはずです。

Finatext Tech Blog

Discussion

もりたもりた

(まだ全部読めてませんが、)最高です!!! ありがとうございます!!!

at_grandpaat_grandpa

ありがとうございます!ゆっくりで結構ですので、じっくり読んでみてください!