41から始めました

文字通り41歳から始めたブログです。DB(MySQL)を使ってお仕事してるので、DB周りの話を中心に最近始めたこととかをTwitterのノリで書いています。なお、本サイトにおいて示されている見解は私個人の見解であり、所属団体や組織を代表するものではありません。

MySQLのギャップロックとネクストキーロック

曖昧に理解してるかもと思い、自分の振り返りのために書いてます。

先日書いた記事で作ったデータで説明します。

MySQLのロック

通常、DML実行時に取得されるロックは排他ロックと共有ロックで構成されます。 最初にトランザクションでロックをかけたほうが排他ロック、 ロックがかかったデータを参照だけするためにかけたほうが共有ロックと呼ばれます。

共有ロックがかかった状態では他のトランザクションでも共有ロックはかかり(データの読み取りができ)ますが、データの書き込みはできません。

一方、他のトランザクションが同じ行をロックするのを回避するタイプのロックを排他ロックといいます。

トランザクション分離レベルに応じて、

となります。

MySQLで書き込み中データ読み取れると思ったでしょ?

そうです。

デフォルトの InnoDB 分離レベルである REPEATABLE READ では、排他ロックを持つ行をトランザクションが読み取ることを許可しています。(=一貫性読み取りと呼ばれる方法)

ギャップロック

さて、本題に入ります。

通常データを書き込む時はそのロックの単位は行(row)で行われます。

それはMySQLでも変わらないのですが、MySQLではその行以外もロック対象になることがあります。

その一つがギャップロックです。

ギャップロックの特徴としては、

  • index値を持つ行と行の間にあるギャップ
  • 先頭のindex値を持つ行の前のギャップ
  • 末尾のindex値を持つ行の後のギャップ

に対してロックをかけます。

なんのこっちゃと思われると思ったので、実際に試してみます。

一つ目のトランザクションでIDが1から6のレコードを抽出(for update付)しました。

この時点ではID=5のレコードはありません。

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

mysql> select * from test.j_pedigree where id between 1 and 6 for update;
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
| id | last_name          | first_name         | father_id | mother_id | pucci_flg | jap_flg |
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
|  1 | ジョージ(1世)      | ジョースター       |      NULL |      NULL |         0 |       0 |
|  2 | メアリー           | ジョースター       |      NULL |      NULL |         0 |       0 |
|  3 | ジョナサン         | ジョースター       |         1 |         2 |         0 |       0 |
|  4 | エリナ             | ジョースター       |      NULL |      NULL |         0 |       0 |
|  6 | ディオ             | ブランドー         |         5 |      NULL |         0 |       0 |
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
5 rows in set0.00 sec)

二つ目のトランザクションでID=5のレコードを追加してみます。

行単位でのロックであれば入るはずですが・・・、

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

mysql> insert into test.j_pedigree values(5,'ダリオ','ブランドー',null,null,0,0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

INSERTは待たされ、最終的にはタイムアウトしました。

1つ目のトランザクションでj_pedigree テーブルのイメージはこうなっています。

id データ有無 ロック
1 あり 排他ロック
2 あり 排他ロック
3 あり 排他ロック
4 あり 排他ロック
5 無し 排他ロック
6 あり 排他ロック

データがない部分に対しても排他ロックがかかり、そこにデータを入れようとしてもロック待ちとなる。

これがギャップロックです。

フルテーブルスキャン時のロック

ちなみにフルテーブルスキャンになるようなトランザクションがかかった時、ロックはどうなっているのでしょう?(範囲は指定しています)

mysql> explain select * from test.j_pedigree where mother_id = 2 for update;
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table      | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | j_pedigree | NULL       | ALL  | NULL          | NULL | NULL    | NULL |   44 |    10.00 | Using where |
+----+-------------+------------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

mysql> select * from test.j_pedigree where mother_id = 2 for update;
+----+-----------------+--------------------+-----------+-----------+-----------+---------+
| id | last_name       | first_name         | father_id | mother_id | pucci_flg | jap_flg |
+----+-----------------+--------------------+-----------+-----------+-----------+---------+
|  3 | ジョナサン      | ジョースター       |         1 |         2 |         0 |       0 |
+----+-----------------+--------------------+-----------+-----------+-----------+---------+
1 row in set0.00 sec)

2つ目のトランザクションでデータを入れようとしても・・・

mysql> insert into test.j_pedigree values(5,'ダリオ','ブランドー',null,null,0,0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql> insert into test.j_pedigree values(0,'ロバート','スピードワゴン',null,null,0,0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql> insert into test.j_pedigree values(47,'羽','伴毅',null,null,0,0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

テーブル全体に排他ロックがかかり、データを一切入れることができません。

MySQL8からですが、performance_schema.data_locksでそれが確認できます。

mysql>select engine,object_schema,object_name,index_name,lock_type,lock_mode from performance_schema.data_locks;
+--------+---------------+-------------+------------+-----------+-----------+
| engine | object_schema | object_name | index_name | lock_type | lock_mode |
+--------+---------------+-------------+------------+-----------+-----------+
| INNODB | test          | j_pedigree  | NULL       | TABLE     | IX        |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | PRIMARY    | RECORD    | X         |
| INNODB | test          | j_pedigree  | NULL       | TABLE     | IX        |
+--------+---------------+-------------+------------+-----------+-----------+
47 rows in set0.01 sec)

lock_mode がX/IXは排他ロックを意味しています。

ネクスキーロック

ネクスキーロックは先ほどのギャップロックと行ロックの組み合わせで、インデックスレコードに対するレコードロックと、そのインデックスレコードの前にあるギャップに対するギャップロックとを組み合わせたものです。

https://dev.mysql.com/doc/refman/5.6/ja/innodb-record-level-locks.html

分かりづらいんで、これも実際に試してみましょう。

一つ目のトランザクション

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

mysql> select * from test.j_pedigree where id < 5 for update;
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
| id | last_name          | first_name         | father_id | mother_id | pucci_flg | jap_flg |
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
|  1 | ジョージ(1世)      | ジョースター       |      NULL |      NULL |         0 |       0 |
|  2 | メアリー           | ジョースター       |      NULL |      NULL |         0 |       0 |
|  3 | ジョナサン         | ジョースター       |         1 |         2 |         0 |       0 |
|  4 | エリナ             | ジョースター       |      NULL |      NULL |         0 |       0 |
+----+--------------------+--------------------+-----------+-----------+-----------+---------+

IDが5未満なので4までを行ロックは取得しています。

これにギャップロックはその末尾のindex値を持つ行の後のギャップもロックを取るんで5もロックされるというわけです。

試してみます。

2つ目のトランザクション

mysql> insert into test.j_pedigree values(5,'ダリオ','ブランドー',null,null,0,0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

はい、やはり待たされてタイムアウトエラーとなりました。

じゃあ、IDが4未満だとどうなるの?

データは入ります。

1つ目のトランザクション

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

mysql> select * from test.j_pedigree where id < 4 for update;
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
| id | last_name          | first_name         | father_id | mother_id | pucci_flg | jap_flg |
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
|  1 | ジョージ(1世)      | ジョースター       |      NULL |      NULL |         0 |       0 |
|  2 | メアリー           | ジョースター       |      NULL |      NULL |         0 |       0 |
|  3 | ジョナサン         | ジョースター       |         1 |         2 |         0 |       0 |
+----+--------------------+--------------------+-----------+-----------+-----------+---------+
3 rows in set0.00 sec)

2つ目のトランザクション

mysql> insert into test.j_pedigree values(5,'ダリオ','ブランドー',null,null,0,0);
Query OK, 1 row affected (0.00 sec)

ね?入ったでしょ。

IDは4未満なので3までが行ロックとなり、4がギャップロック対象なので5はロック対象外となり使用可能というわけです。

MySQLのロック

  • テーブルロックと行(レコード)に対するロックがあります。
  • ロックの強度は排他と共有の2種類があります(MySQL5.7からはその中間である共有排他(SX)ロックがありますがいったん置いときます)
  • 行(row)に対するロックは次の3種類です。

これらを意識しておけば、デッドロックや更新のタイムアウトエラーが発生したときに「範囲じゃないはずなのになぁ…」というときでも「いや、待てよ?」という気づきになるかもしれませんので、MySQLを使われる方は頭の片隅に入れておいてください。

ギャップロックをMySQLで回避するには?

トランザクション分離レベルをREAD COMMITTEDに設定すればOKです。

トランザクション分離レベルは

  • SET TRANSACTION構文を使って変更する
  • mysqldから起動する際にオプションを指定する
  • my.cnfに設定する

の三つがあります。

SET TRANSACTION構文で変更

グローバルとセッションで設定できます。

-- 設定
mysql> SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL READ COMMITTED; -- GLOBAL or SESSION

mysqld

CentOS7以降だと以下のようにsystemctlコマンドを使って起動します。

# systemctl set-environment MYSQLD_OPTS="--transaction-isolation=READ-COMMITTED"
# systemctl restart mysqld.service

my.cnf

以下の一文をmy.cnfのmysqld配下に入れましょう

[mysqld]
transaction-isolation = READ-COMMITTED

いずれも設定後は以下のクエリで確認するといいです。

mysql> SELECT @@GLOBAL.tx_isolation, @@tx_isolation;

#または
mysql> SELECT @@GLOBAL.transaction_isolation, @@transaction_isolation;

追記(降順インデックスの話)

この記事を書いた後にhmatsu47さんから教えていただいたんですが、

f:id:next4us-ti:20190327111917p:plain

降順インデックスだとネクスキーロックの向きが逆になる、と。 なるほど。言われてみるとそうかも。

参照

dev.mysql.com

gihyo.jp