testing パッケージの *testing.T
にある Parallel()
関数1を使った並列テストにおいて、
テストの規模が大きくなるに連れて MySQL 上で deadlock する機会が多くなっていた。使っているのは MySQL でバージョンは mysql:8.0.26
である。
複数のレコードを bulk insert するにあたって primary key をソートしておかないと deadlock するぞ、というのは良くある話だが、今回は単一レコードという条件だった。
原因の調査でパターンを理解ししたので、その内容について書き留めておく。
結果から書くと使っていた https://github.com/DATA-DOG/go-txdb と InnoDB のインデックスの振る舞いの相性が悪い。
明示的に排他ロック共有ロックを取っている場合や transaction を使っている場合を除いて production で発生することはないので気にしないで良いケースが多い。
前提知識
MySQL(InnoDB)のロックに関する用語がわかっていると良い。
他に自分はこの辺のドキュメントを読んで理解を深めた。
deadlock の状況
testing パッケージにある並列実行を利用して DB にレコードを用意しつつテストを実行しているプロジェクトがあった。
MySQL を起動しておいて https://github.com/DATA-DOG/go-txdb を使ったレコード管理をしており、プロジェクト規模はテストケースはそこまで多くない。
deadlock していたのは特定のテーブルへの insert で、具体的には primary key やセカンダリインデックスに指定されているカラムに外部から値をセットしようとしているケースだった。
今回の deadlock は MySQL 上で次のようなに再現させることができる。簡易的なテーブル t1
を用意。
続いてセッションを3つ用意し、それぞれで次のクエリを実行する。
最初に起動したセッション1では i = 1
のレコードに対して排他ロックを取得している状態になる。
ロック状況を出力すると 2254, 2255, 2256 の thread がそれぞれセッション1,セッション2,セッション3とする。
ロック状況を見ると insert のための排他ロックはセッション1が持っていることがわかる。
しかし、セッション2, セッション3 はなぜか共有ロックを待っている。これは InnoDB の仕様で、duplicate-key error が発生した insert は共有ロックを取りに行くかららしい。
この状態で、排他ロックを持っているセッション1にて rollback を実行する。この rollback は txdb の cleanup 相当の意味を持つ。
txdb は 単一のテーブルを使いまわしてテストを実行する関係上、各テストの実行ごとにトランザクションが貼られ、テストケース実行ごとに rollback が実行される。production でほぼ再現しないのはこれが理由になる。
すると3つ目のセッションで deadlock が発生して実行に失敗する。その時点でのロック状況は以下。
セッション2 は Insert intention lock を取得し、(transaction 内では)レコードを insert できていることになった。commit するとレコードは無事作成される。
show engine innodb status
で deadlock の様子を見てみる。
何が起こっているかというと、セッション2, セッション3では duplicate-key error が発生して共有ロックを取得していた。
その後取得した共有ロックを持った状態で2つのセッションが insert のために排他ロックを取ろうとするので、競合のためロックが取れずでセッション2とセッション3が deadlock に落ちる。
つまり、以下のような条件下に発生している。
- insert を実行しようとするテストケースが同時に3つ以上実行されている
- インデックスが貼られている column に対して値を insert しようとしている
- insert しようとしている値は各テストケースで同じ値である
回避方法としては簡単で、インデックスの貼られている column へは複数のテストケースで同じ値を指定するのではなく、uuid や ulid など衝突しづらい値を利用すれば良い。
挙動を理解はしたが duplicate-key error からの共有ロックを取りに行くのが謎なのでもう少し調査すると、例えば以下のようなケースで振る舞いに一貫性が持てなくなる。Lock 機構の都合かぁ。