はじめに
こんにちは。エンジニアリング&デザインマネジメント室(EDMO)でSREを担当しております、堀江です。SREではあるのですが、幅広くサーバーインフラの構築や運用も行っています。今回は社内の勉強会用にデータベースの挙動について調べていたところ、MySQL 8.0での興味深い挙動を見つけましたので、こちらで共有させていただこうと思いました。
ネクストキーロックとは
RDBMSにはトランザクション分離レベルという概念があり、これはデータの一貫性を保護するための仕組みです。複数のトランザクションが同時に実行される際に発生しうる以下のような問題を防ぐために設計されています。
- ダーティリード (Dirty Read): 他のトランザクションがまだコミットしていない変更を読み取ってしまう
- ノンリピータブルリード (Non-Repeatable Read): 同じトランザクション内で同じデータを2回読み取ったときに、異なる値が返される
- ファントムリード (Phantom Read): 範囲検索を行った際に、前回の検索では存在しなかった行が突然現れる
この挙動はSQL標準として規定されたものであり、RDBMS製品はそれぞれの手法でトランザクション分離レベルを実装しています。
MySQLのInnoDBストレージエンジンでは、デフォルトのトランザクション分離レベルとしてREPEATABLE READを採用しており、これらの問題に対処するために様々なロック機構を実装しています。その中でも重要な役割を果たすのが、ネクストキーロックです。
ネクストキーロック (Next-Key Lock) は、InnoDBがファントムリードを防ぐために使用する特殊なロック機構です。これは以下の2つのロックを組み合わせたものになります。
- レコードロック (Record Lock): インデックスレコード自体をロックする
- ギャップロック (Gap Lock): インデックスレコード間の「隙間」をロックし、その範囲への新規挿入を防ぐ
これにより、他のトランザクションがこの範囲に新しいレコードを挿入することを防ぎ、同じクエリを再実行しても同じ結果が得られることを保証します。
実際の挙動
トランザクションが並列して実行される場合を考えてみましょう。先行して実行されるトランザクション(トランザクション1)がコミットまたはロールバックされる前に、後続のトランザクション(トランザクション2)でSQL文を実行すると、トランザクション1の完了を待たされるケースが多く発生します(トランザクション1に長い時間がかかるとSQL文はタイムアウトします)。
このような簡単なテーブルを作成し、実際の挙動を確認してみました。
| c0 (PK) | c1 | 
|---|---|
| 10 | A | 
| 20 | A | 
| 30 | A | 
MySQL 5.7.44 デフォルト分離レベル(REPEATABLE READ)での結果
| トランザクション1 | トランザクション2 | ロック | 
|---|---|---|
| update t set c1 = 'B' where c0 = 20 | update t set c1 = 'C' where c0 = 20 | 待ち | 
| update t set c1 = 'B' where c0 < 20 | update t set c1 = 'C' where c0 = 10 | 待ち | 
| insert into t values (15, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 20 | 待ち! | |
| insert into t values (25, 'C') | なし | |
| update t set c1 = 'C' where c0 = 30 | なし | |
| update t set c1 = 'B' where c0 <= 20 | update t set c1 = 'C' where c0 = 10 | 待ち | 
| insert into t values (15, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 20 | 待ち | |
| insert into t values (25, 'C') | 待ち! | |
| update t set c1 = 'C' where c0 = 30 | 待ち! | |
| update t set c1 = 'B' where c0 > 20 | update t set c1 = 'C' where c0 = 10 | なし | 
| insert into t values (15, 'C') | なし | |
| update t set c1 = 'C' where c0 = 20 | なし | |
| insert into t values (25, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 30 | 待ち | |
| update t set c1 = 'B' where c0 >= 20 | update t set c1 = 'C' where c0 = 10 | なし | 
| insert into t values (15, 'C') | なし | |
| update t set c1 = 'C' where c0 = 20 | 待ち | |
| insert into t values (25, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 30 | 待ち | 
表中で「待ち!」とマークした箇所に注目してください。これらは一見すると不思議な挙動に見えます。
例えば、トランザクション1で WHERE c0 < 20 (20未満) を更新しているのに、トランザクション2で WHERE c0 = 20 (ちょうど20) の更新が待たされています。通常の感覚では、20は「20未満」の範囲外なので、ロックされないはずです。
また、WHERE c0 <= 20 (20以下) で更新した場合、INSERT INTO t VALUES (25, 'C') (25を挿入) や WHERE c0 = 30 (30を更新) といった、明らかに条件範囲外の操作まで待たされてしまいます。
これこそがネクストキーロックの特徴です。 ネクストキーロックは、条件に合致したレコードだけでなく、その次のインデックスレコードまでの範囲(ギャップ)もロックします。これにより、ファントムリードを確実に防ぐことができますが、その代償として、直感的には関係なさそうな操作までブロックされることがあるのです。
この挙動は、同時実行性を重視するアプリケーションにとって、トランザクション2の実行時間増大を意味し、予期せぬパフォーマンス低下の原因となることがあります。このため、高い並列性が求められる場合には(整合性のリスクを取った上で)トランザクション分離レベルをREPEATABLE READから変更することを検討する場合もあるでしょう。
しかし、今回MySQL 8.0で試してみたところ、5.7とは異なる挙動になりました。
MySQL 8.0.44 デフォルト分離レベル(REPEATABLE READ)での結果
| トランザクション1 | トランザクション2 | ロック | 
|---|---|---|
| update t set c1 = 'B' where c0 = 20 | update t set c1 = 'C' where c0 = 20 | 待ち | 
| update t set c1 = 'B' where c0 < 20 | update t set c1 = 'C' where c0 = 10 | 待ち | 
| insert into t values (15, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 20 | なし | |
| insert into t values (25, 'C') | なし | |
| update t set c1 = 'C' where c0 = 30 | なし | |
| update t set c1 = 'B' where c0 <= 20 | update t set c1 = 'C' where c0 = 10 | 待ち | 
| insert into t values (15, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 20 | 待ち | |
| insert into t values (25, 'C') | なし | |
| update t set c1 = 'C' where c0 = 30 | なし | |
| update t set c1 = 'B' where c0 > 20 | update t set c1 = 'C' where c0 = 10 | なし | 
| insert into t values (15, 'C') | なし | |
| update t set c1 = 'C' where c0 = 20 | なし | |
| insert into t values (25, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 30 | 待ち | |
| update t set c1 = 'B' where c0 >= 20 | update t set c1 = 'C' where c0 = 10 | なし | 
| insert into t values (15, 'C') | なし | |
| update t set c1 = 'C' where c0 = 20 | 待ち | |
| insert into t values (25, 'C') | 待ち | |
| update t set c1 = 'C' where c0 = 30 | 待ち | 
かなり直感的に理解しやすいギャップロックの挙動になっているのではないでしょうか。
MySQL 8.0は高並列性を意識した仕様変更が行われている
このように、MySQL 8.0では5.7で見られた「直感的ではないロック」が大幅に改善されていることがわかりました。
具体的な改善点
今回の検証結果から、以下のような改善が確認できました:
- WHERE c0 < 20の場合: 5.7では- c0 = 20の更新まで待たされていましたが、8.0では待ちが発生しません
- WHERE c0 <= 20の場合: 5.7では- c0 = 25の挿入や- c0 = 30の更新まで待たされていましたが、8.0では条件範囲外の操作は即座に実行できます
これらの改善により、ロックがより直感的な範囲でのみ動作するようになっています。開発者がWHERE句から予測できる範囲でロックが機能するため、予期せぬロック待ちによるパフォーマンス低下のリスクが大幅に軽減されました。
並列性の向上
この変更は、特に高い並列性が求められるアプリケーションにとって大きなメリットがあります。従来はネクストキーロックによる過度なロック範囲が原因で、トランザクション分離レベルを下げるなどの対処が必要なケースもありました。しかし、MySQL 8.0ではREPEATABLE READのままでも、より効率的な並列処理が可能になっていると言えるでしょう。
今後の課題
ただし、現時点ではこの挙動の変更を明確に説明するMySQL公式ドキュメントを見つけることができていません。ネクストキーロックに関する記述も以前のバージョンと大きな変更がないように見えます。今回の検証結果は実際の動作確認に基づくものですが、公式の仕様変更アナウンスやリリースノートでの裏付けが取れていない点はご留意ください。
もし、この挙動変更に関する公式情報をご存知の方がいらっしゃいましたら、ぜひコメント等で教えていただけると幸いです。
まとめ
MySQL 8.0では、ネクストキーロックの挙動が大幅に改善され、より直感的で予測しやすいロック機構になっています。これにより:
- 開発者がSQL文から予測できる範囲でロックが動作するようになった
- 不必要なロック待ちが減少し、並列処理性能が向上した
- REPEATABLE READでも高い並列性を維持しやすくなった
MySQL 8.0を利用する場合、この改善はパフォーマンス面で大きなメリットとなるでしょう。ただし、実際の運用環境では、必ず事前に十分な検証を行うことをお勧めします。WonderPlanetでも引き続き、検証と研究を続けてまいります!
