MySQLのロック機構を深掘り - 月間14万件の入居申込データを安全に扱うための最適化戦略

はじめに

賃貸募集支援事業プロダクト開発チームのかげやまです! 普段は申込受付くんの開発をしています!

申込受付くんでは申込みに関わる様々なデータを扱っており、その数も日を追うごとに増加しています。 参考までに、月間WEB入居申込件数は最大月で約14万件に及びます。

そのためDBの操作には細心の注意を払う必要があります。

今回は、危うくデッドロックやタイムアウトを起こしかねなかった実際のタスクから、MySQLのロック仕様について深ぼってみます。

こんな方に読んでほしい

  • MySQLのトランザクションやロックの仕組みをより深く理解したいエンジニアの方
  • デッドロックやタイムアウトといったデータベースのパフォーマンス問題に悩まされている開発者の方
  • 大規模データを扱うアプリケーションでの効率的なデータ操作手法を模索している方

動作環境

MySQLバージョン: 8.0.39

ストレージエンジン: InnoDB

トランザクション分離レベル: REPEATABLE-READ

検証用サンプルデータ

実務で使用したデータは使用できないので、似た形式のテーブルとレコードを作成します。

関連テーブルに外部キー制約を付けるか問題はありますが、今回は外部キー制約は付与せず単純にインデックスを付与します。

/* parentsテーブル作成 */
CREATE TABLE parents (
  id INT NOT NULL,
  PRIMARY KEY(id)
);

/* parentsテーブルのレコードを追加 */
INSERT INTO parents VALUES (10); 
INSERT INTO parents VALUES (15); 
INSERT INTO parents VALUES (20); 
INSERT INTO parents VALUES (25); 
INSERT INTO parents VALUES (30);
INSERT INTO parents VALUES (35);
INSERT INTO parents VALUES (40);

/* childrenテーブル作成 */
CREATE TABLE children (
  id INT NOT NULL AUTO_INCREMENT,
  parent_id INT NOT NULL,
  PRIMARY KEY(id),
  KEY idx_parent_id (parent_id)
);

/* childrenテーブルのレコードを追加 */
INSERT INTO children (parent_id) VALUES (10);
INSERT INTO children (parent_id) VALUES (20);
INSERT INTO children (parent_id) VALUES (30);
INSERT INTO children (parent_id) VALUES (40);

タスク要件について

あるレコードと、それに関連する別テーブルのレコードを一括削除するバッチ処理の実装が要件でした。

また、関連テーブルは約50テーブルほどあり、関連レコードの一部だけ削除されることが無いように1つのトランザクションで担保する必要がありました。

前述のサンプルデータで例えると、parents.id = 20のレコードを削除するために、children.parent_id = 20のレコードも削除しなくてはならないイメージです。

単純に上記の要件を実現すると以下のようなクエリになると思います。

START TRANSACTION;

DELETE FROM children where parent_id = 20;
/* 実際には他にも関連テーブルのレコードを削除するクエリが入る */
/* DELETE FROM ~~ */
/* DELETE FROM ~~ */
/* DELETE FROM ~~ */
DELETE FROM parents where id = 20;

COMMIT;

1つ目のDELETE FROM children where parent_id = 20;の実行自体はすぐに終了しますが、取得したロックはトランザクションが終了するまで保持されます。

また、この際childrenテーブルで取得されるロックが問題になるのはparent_id = 20へのinsertのみではありません。

そのため、トランザクション内の処理が増えるほどロック時間が増加し、ロックが競合する可能性も高くなります。

今回はこのDELETE FROM children where parent_id = 20;のロック範囲を深ぼってみます。

ロックの競合を起こしてみる

まずは以下のクエリを実行してみます。

/* trx1 */
START TRANSACTION;

DELETE FROM children where parent_id = 20;

次に別のトランザクションで以下のクエリを実行します。 当然ですが、parent_id = 20へのinsertはtrx1のロックと競合しinsertが出来ません。

/* trx2 */
START TRANSACTION;

/* insertできない */
INSERT INTO children (parent_id) VALUES (20);

では次のクエリはどうでしょうか?

/* trx2 */
START TRANSACTION;

INSERT INTO children (parent_id) VALUES (15);
INSERT INTO children (parent_id) VALUES (25);

こちらもtrx1のロックと競合し、insertが出来ません。

ロックの範囲を確認する

ではtrx1で取得されているロックの範囲を確認していきます。

SELECT
  OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, LOCK_MODE, LOCK_DATA
FROM
  performance_schema.data_locks
WHERE
  LOCK_TYPE = 'RECORD';

/* 実行結果 */
+-------------+-----------+-------------+---------------+-----------+
| OBJECT_NAME | LOCK_TYPE | LOCK_STATUS | LOCK_MODE     | LOCK_DATA |
+-------------+-----------+-------------+---------------+-----------+
| children    | RECORD    | GRANTED     | X             | 20, 2     |
| children    | RECORD    | GRANTED     | X,REC_NOT_GAP | 2         |
| children    | RECORD    | GRANTED     | X,GAP         | 30, 3     |
+-------------+-----------+-------------+---------------+-----------+

LOCK_MODEでは取得しているロックの種類、LOCK_DATAではロックを取得しているレコードの情報を確認できます。

レコードロック

LOCK_DATAが2のインデックスレコードに対してLOCK_MODEがX,REC_NOT_GAPというロックを取得しています。

これはレコードロックを示します。

レコードロックは単純にその値を持つインデックスレコードのみをロックします。

LOCK_DATAに記載されている値は今回削除しようとしているparent_id = 20のPRIMARY_KEYです。 そのため、id = 2のインデックスレコードをロックしています。

ギャップロック

LOCK_DATAが30, 3のインデックスレコードに対してLOCK_MODEがX,GAPというロックを取得しています。

これはギャップロックを示します。

ギャップロックは対象のインデックスレコード自体はロックせずに前のギャップのみをロックします。

以下はロック状態のイメージです。

parent_id = 30の前にはparent_id = 20のインデックスレコードが存在するので、parent_idが21~30までのinsertがブロックされます。

また、指定したインデックスレコードの前にインデックスレコードが存在しない場合は、infimum(下限)という仮想レコードとのギャップをロックします。

もしparent_id = 10のレコードに対してdelete文を実行した場合、0~10はもちろん、0以下の負の値も追加がブロックされます。

また、supremun(上限)のギャップロックを取得した場合はparent_idが40より大きいレコードの追加がブロックされます。

ネクストキーロック

LOCK_DATAが20, 2のインデックスレコードに対してLOCK_MODEがXというロックを取得しています。

これはネクストキーロックを示します。

ネクストキーロックはレコードロックとギャップロックの組み合わせです。

そのため、対象のインデックスレコードとその前のギャップをロックします。

現在、childrenテーブルにはparent_idが10,20,30,40のレコードが存在し、parent_id = 20のインデックスレコードを削除しようとしています。

parent_id = 20の前にはparent_id = 10のインデックスレコードが存在するので、parent_idが11~20までの追加がブロックされます。

最終的なロック範囲

以下は最終的なロック範囲のイメージです。

parent_id = 20のレコードと、その前後のギャップをロックしているのがわかると思います。

ではそもそもなぜロックを取得するのか、どのようにしてロックの範囲が決まるかを分けて深ぼっていきます。

なぜロックを取得するのか

ロックを取得する理由の1つにファントムリードを防ぐという目的があります。

ファントムリードとは、トランザクション内で実行したselect文の結果が1回目と2回目で違うような場合を指します。

参考: https://dev.mysql.com/doc/refman/8.0/ja/innodb-next-key-locking.html

ファントムリードが起きてしまうと、例えばトランザクション内で1度目のSELECTでは存在していたparent_id = 20のレコードが、2度目のSELECTで見えなくなるケースが起こり得ます。

ロックの範囲はどのようにして決まるか

ではなぜこのような範囲でロックを取得するのでしょうか? delete文で指定したparent_idカラムの情報を整理してみます。

  • インデックスが張られている
  • UNIQUE制約はない

上記の情報から、リーフノードはソートされて並んでおり、parent_id = 20のレコードは重複する可能性があることがわかります。

もしparent_id = 20のレコードを新規追加しようとした際は最終的なロック範囲のイメージで示した赤線の範囲に挿入されることになります。

そのため、この範囲をロックすることでファントムリードを防いでいます。

ロック範囲を減らすためにどんな対策をしたか

ロック範囲を減らすためには以下のような対策が考えられます。

  • parent_idにUNIQUE制約を付与する
  • トランザクション分離レベルを変更する
  • PRIMARY_KEYを使って削除する

実際のタスクではUNIQUE制約を付与できないテーブルもあったため、PRIMARY_KEY以外のカラムで関連するレコードを取得した後、再度PRIMARY_KEYで取得し直して削除する方法を取りました。

まとめ

MySQLの構造や原理は勉強すればするほどわからないことが出てきて楽しいです😇

この記事の内容が少しでも参考になれば幸いです!