Jepsenによる MySQL 8.0.34 の評価
(jepsen.io)- MySQL 8.0.34 のデフォルト分離レベルである Repeatable Read は、単一の正常ノード上であっても ANSI SQL および Adya の PL-2.99 の期待に一致しないトランザクション整合性違反を示した
- Elle の list-append チェッカー、targeted workload、LazyFS を組み合わせて、MySQL 8.0.34、MariaDB 10.11.3、binlog レプリケーションクラスタ、AWS RDS MySQL Multi-AZ DB Cluster をあわせて検証した
- Kleppmann の 2014 年の Hermitage の結果と同様に、G2-item、G-single、lost update が再現され、内部整合性違反、non-repeatable read、Monotonic Atomic View 違反も観測された
- 単一 MySQL の Read Uncommitted、Read Committed、Serializable はそれぞれ PL-1、PL-2、PL-3 に適合しているように見えたが、AWS RDS MySQL クラスタは Serializable でも G2-item と G-single を示した
- ANSI または PL-2.99 レベルの Repeatable Read が必要なら、MySQL Repeatable Read だけを信頼するのは難しく、Serializable や
SELECT... FOR UPDATEのような明示的ロックが必要になる
評価対象と範囲
- MySQL は広く使われているリレーショナルデータベースであり、この分析で「MySQL」とは、デフォルトのストレージエンジンである InnoDB を使用する MySQL を指す
- 焦点は単一サーバー MySQL だが、binlog レプリケーションを使う単一書き込み primary と読み取り専用 secondary のクラスタもあわせて扱う
- テスト対象は次のとおり
- MySQL 8.0.34
- MariaDB 10.11.3
- Debian Bookworm
- AWS RDS Cluster の「Multi-AZ DB Cluster」プロファイル
- 作業は無償で独立して実施され、Jepsen ethics policy に従って進められた
SQL 分離レベルと Repeatable Read の基準
- ANSI SQL は Read Uncommitted、Read Committed、Repeatable Read、Serializable を、P1 dirty read、P2 non-repeatable read、P3 phantom の可否で定義している
- 1995 年に Berenson らは A Critique of ANSI SQL Isolation Levels で ANSI 定義の曖昧さと不完全さを批判した
- P1、P2、P3 には解釈の余地がある
- P0 dirty write のような重要な現象が抜け落ちている
- P3 は predicate に影響する insert だけを禁止し、update や delete は扱っていない
- Atul Adya の 1999 年の論文は、トランザクション間の 依存グラフ に基づく実装非依存の分離レベルを定義した
- PL-1 は G0 write cycle を禁止する
- PL-2 は G0 と G1 を禁止する
- PL-2.99 は G0、G1、G2-item を禁止し、Repeatable Read に対応する
- PL-3 は G0、G1、G2 を禁止し、Serializable に対応する
- Jepsen は一般に Adya の形式を使ってトランザクション履歴と異常を判定する
MySQL ドキュメントと Repeatable Read の衝突
- MySQL のドキュメントでは、InnoDB が SQL:1992 標準の 4 つの分離レベルをすべて提供すると説明している
- デフォルト分離レベルである Repeatable Read については、同じトランザクション内の consistent read は最初の読み取りで設定されたスナップショットを読むと説明している
- consistent read のドキュメント でも、最初の読み取り時点の timepoint に従ってデータベースを見ると説明している
- しかし同じ文書の注記には、スナップショットは
SELECTには適用されるが DML 文には必ずしも適用されず、他のトランザクションが commit した row にDELETEやUPDATEが触れうると書かれている - この注記は、ANSI SQL と MySQL リファレンスマニュアルが
SELECTも DML と見なしている点と衝突し、Repeatable Read で書き込みが読み取れなかった row に影響しうるという混乱を生んでいる
テスト設計
- MySQL 向けテストスイート は Jepsen testing library 0.3.4 をベースに書かれている
- クライアントは
mysql-connector-jJDBC アダプタを使用する - テストには process pause、crash、network partition、fsync されていないディスク書き込み損失のような fault injection が含まれる
- ただし今回の分析における発見のほとんどは、正常状態の単一 MySQL ノード で発生している
-
Elle list-append workload
- 中核となる workload は Elle の list-append checker を使用する
- Elle はトランザクション間の write-write、write-read、read-write 依存を推論し、依存グラフの cycle によって特定の分離レベル違反を立証する
- list-append workload は、primary key で識別される複数の list に対して read と append からなるランダムなトランザクションを実行する
- list はカンマ区切りの値を保持する
textフィールドとしてエンコードし、append は SQLCONCATで処理する - 最近の改善により、Elle は次をよりよく検出できるようになった
- 読み取られなかった append element に対する ww/rw 依存の推論
- P4 lost update の明示的検出
- real-time edge と process edge を含む複雑な cycle 探索
-
Targeted workload
- Non-repeatable read workload は
peopleテーブルの 1 つの row を対象にする - ある系列のトランザクションは
nameだけを update し、別の系列はnameを読み、genderを update した後、再びnameを読む - 2 回の読み取りの間で
nameが変わっていれば Repeatable Read 違反である - Monotonic Atomic View workload は 2 つの row の
valueを使う - writer は row 0 の
valueを増やした後で row 1 を増やす - reader は row 0 を読み、row 1 の
noopを update した後で row 1 と row 0 を読む - あるトランザクションの効果の一部を見たなら、そのすべての効果を見なければならない
- Non-repeatable read workload は
-
LazyFS
- LazyFS は fsync されていない書き込み損失をシミュレートする FUSE ファイルシステムである
- MySQL process を kill し、LazyFS キャッシュを破棄した後で MySQL を再起動する方法でテストした
- このレポートは LazyFS を含む最初の公開 Jepsen レポートである
MySQL Repeatable Read で見つかった異常
-
G2-item
- Adya の PL-2.99 Repeatable Read は、predicate を含まない write-write、write-read、read-write 依存 cycle である G2-item を禁止する
- MySQL Repeatable Read は、単一の正常ノード上でも G2-item を繰り返し許容する
- Kleppmann が 2014 年に Hermitage で報告した挙動は、MySQL 8.0.34 でも引き続き発生する
- ある例のテストでは 40 秒間で 214 個の cycle が見られた
- この挙動は PL-2.99 Repeatable Read では禁止されるが、ANSI SQL の P2 定義は同じ row を 2 回読むケースしか扱わないため、ANSI 定義上は解釈の余地が残る
-
G-single と read skew
- MySQL Repeatable Read は G-single も示す
- G-single は write-write、write-read、read-write edge から構成されるが、read-write edge が互いに隣接しない cycle である
- Kleppmann が 2014 年に報告した read skew は、MySQL 8.0.34 でも確認された
- 60 秒の append テストでは、毎秒およそ 140 トランザクションで G-single 244 件と G2-item 305 件が現れた
- append テストは predicate 演算を使わないため、いずれも Repeatable Read 違反として分類される
-
Lost update
- P4 lost update は、2 つのトランザクションが同じ key の同じ version を読み、両方が update する G-single の特殊ケースである
- Snapshot Isolation と PL-2.99 Repeatable Read は lost update を禁止する
- MySQL Repeatable Read は、単一の正常ノード上でも lost update を繰り返し許容する
- あるテストでは 9,048 件の成功トランザクションのうち、新しい checker が 198 件の lost update に関与した 446 件のトランザクションを見つけた
- このうち cycle として表れた例は 47 件だけだった
- 値を読んでから書くパターンは、MySQL Repeatable Read では安全ではない
- オブジェクトを読み、メモリ上で修正してから保存し直す標準的な ORM パターンでは、commit 済みの変更が静かに失われうる
- ユーザーは明示的ロックを自ら使う必要がある
-
Non-repeatable read と内部整合性違反
- MySQL Repeatable Read は、単一の正常ノード上でも 内部整合性違反 を示す
- 同じテスト run で、9,048 件の commit トランザクションのうち 126 件が内部整合性エラーを示した
- ある例では、1 つのトランザクションが key を
nilとして読み、1 つの値を append した後で同じ key を再び読むと、他の 3 つの値が追加された状態を観測した - 別の例では、key 1096 を
[1 2 3]として読み、7を append してから再度読むと[1 2 3 4 5 6 7]を観測した - targeted workload では、1 つの Repeatable Read トランザクション内で
nameを"pebble"と読み、genderを"femme"に update した後、同じnameをもう一度読むと"moss"が返ってきた - このような挙動は、ANSI SQL の non-repeatable read の定義と、MySQL ドキュメントの「最初の読み取りで設定されたスナップショット」という説明に反する
-
Monotonic Atomic View 違反
- Monotonic Atomic View は、あるトランザクションの何らかの効果を見たトランザクションは、そのトランザクションのすべての効果を見なければならないという性質である
- MySQL Repeatable Read は、正常な単一ノード上でもこれを繰り返し破る
- workload では writer が row 0 を増やした後で row 1 を増やす
- reader は row 0 で以前の値
0を見た後、row 1 で writer の増加分1を見て、再び row 0 で依然として0を見る - これは row 1 の効果は見たが row 0 の効果は見ていない 非単調読み取り であり、一般的なスナップショットの挙動と一致しない
AWS RDS MySQL Serializable の異常
- AWS RDS MySQL クラスタは、「Serializable」分離レベルでも Serializability を繰り返し破る
- デフォルトの推奨 production プロファイルの RDS MySQL クラスタでは、append テストで G2-item と G-single anomaly が見られた
- 観測された anomaly は、あるトランザクションの効果を見たトランザクションの先行依存を別のトランザクションが取りこぼす形だった
- この anomaly は G-single かつ G2-item に分類され、Snapshot Isolation、Repeatable Read、Serializability のすべてに違反する
replica_preserve_commit_order関連の設定が疑わしい要因として残っている- MySQL 8.0.27 以降では
replica_preserve_commit_order=ONがデフォルト値である - RDS のデフォルトパラメータは、依然として
replica_preserve_commit_order=OFFに相当する設定を選んでいる - RDS parameter group では、この設定の旧名称である
slave_preserve_commit_orderを使う - ローカルのテストクラスタにこの設定を適用すると、似た G-single と G2-item が観測される
- MySQL 8.0.27 以降では
正常に見えた点と LazyFS の結果
- MySQL 8.0.34 の Read Uncommitted、Read Committed、Serializable は、それぞれ PL-1、PL-2、PL-3 を満たしているように見える
- この結果は、単一ノードと binlog レプリケーションを使用する小規模な読み取り専用レプリカクラスタの両方で観測された
- process pause、crash、network partition においてもこの結果は維持された
- LazyFS fault injection では、MySQL のデフォルト設定に問題は見つからなかった
innodb_flush_log_at_trx_commit=1のデフォルト値では、process crash と fsync されていないデータ損失の後でも、commit 済みトランザクションの損失は現れなかったinnodb_flush_log_at_trx_commit=0に変更すると、MySQL は数秒に 1 回しか fsync せず、データ損失が観測された
MySQL Repeatable Read の実際の性質
- MySQL Repeatable Read は PL-2.99 Repeatable Read を満たさない
- G2-item と write skew を示す
- Snapshot Isolation も満たさない
- G-single、read skew、lost update を示す
- cursor stability も満たさない
- lost update が発生する
- Read Atomic、Causal Consistency、Consistent View、Prefix Consistency、Parallel Snapshot Isolation も除外される
- 内部整合性違反が観測される
- MySQL Repeatable Read は Read Committed よりはいくらか強いように見える
- G0 dirty write、G1a aborted read、G1b intermediate read、G1c cyclic information flow は観測されなかった
- 一部の読み取りの repeatability は Read Committed より強い性質を提供する
- ただし、MySQL Repeatable Read が正確にどの consistency model なのかは明確でなく、公式な性質定義もない
ドキュメントとコミュニティ理解の不一致
- MySQL コミュニティでは、Repeatable Read の挙動が十分に理解されていない状態である
- 多くの記事は MySQL Repeatable Read が lost update を防ぐと信じているが、別の記事では防げないと報告し、明示的ロックの使用を勧めている
- 多くのインターネット上の資料は MySQL Repeatable Read は実際に repeatable だと述べているが、Jepsen のテストはそうではない例を示している
- MySQL と MariaDB のドキュメントも、Repeatable Read は同じトランザクション内で同じスナップショットを読むと説明している
- MySQL の consistent read ドキュメントの一文は、この説明と矛盾する挙動を示唆しているが、その内容は文書の中に埋もれている
推奨事項
- MySQL が現在の挙動を維持するなら、「Repeatable Read」が実際にはどのような consistency model を提供するのかを明確に文書化すべきである
- 別の選択肢は、現在の挙動をバグとして扱い修正することである
- MySQL と他のベンダーが PL-2.99 Repeatable Read の提供を約束するなら、Jepsen はこれを歓迎すると述べている
- PL-2.99 または ANSI Repeatable Read が必要なユーザーは、MySQL Repeatable Read に注意すべきである
- 実務上の代替策は次のとおり
- MySQL の Serializable 分離レベルを使う
READ COMMITTEDでSELECT ... FOR UPDATEのようなロック手法により読み取りを強化する
RDS 利用者への推奨
- AWS RDS MySQL クラスタは「Serializable」で read skew と G2-item を示す
- Serializability に依存するユーザーは、RDS parameter group で
slave_preserve_commit_orderをONに設定すべきである - AWS はデフォルト値を変更するか、RDS MySQL の known limitations 文書で許容される Serializability 違反を明確に説明すべきだという提案が出ている
今後の作業と標準化への要請
- MySQL binlog replication は脆弱に見えた
- ローカルの Jepsen テストでは、replication が停止する複数の状況が観測された
- AWS RDS MySQL replication は数分のテストだけで完全に壊れうることがあり、primary で成功した
CREATE DATABASEが secondary に現れない状態が 1 時間回復しなかった
- secondary を primary に昇格するケースや、ring、star のようなレプリケーショントポロジーは探索していない
- predicate safety を評価するための、より一般的な predicate test の研究が進行中である
- ANSI SQL 分離レベルの定義は、Berenson らが曖昧さと不完全さを指摘してから 28 年、さらに 7 回の ANSI・ISO 改訂を経ても変わっていない
- ISO/IEC 9075-2 が、内部 anomaly、lost update、dirty write のような現象を明確に扱えるよう、より形式的で移植可能な分離レベル定義が必要である
1件のコメント
Hacker News の意見
repeatable read は、実装が完璧であっても悪いアイデアだと昔から思っている。
データベース内部で正しく動作したとしても、複雑なクエリでは推論があまりにも難しい。
意味のある分離レベルは read committed と serializable の2つだけだと思う。
最後まで一貫して驚きのない serializable を選ぶか、あるいはトランザクション内で一貫したビューが必要なら、読み取り前に行をロックしなければならないことが明確な read committed を選ぶべきだ。
read committed は一般的なマルチスレッドコードやメモリ管理に近く、エンジニアが直感を持ちやすいし、serializable は非常に厳格なので思いがけないミスを生みにくい。
その中間は無人地帯であり、read committed より整合性が低いものは、もはやまともなデータベースとは言い難い。
アプリケーションが大きくなるほど、どこでロックが取られ、どこでデータにアクセスされるのかを、あらゆるケースで理解するのは非常に難しくなる。
読み書きトランザクションには serializable だけが正気の分離モデルであり、読み取り専用トランザクションには、ある時点のデータベーススナップショットを扱う snapshot isolation が良いモデルだと思う。
Spanner が提供するモードも、実質的にはこの2つだけだ: https://cloud.google.com/spanner/docs/transactions
FOSSDEM 2024 で、SQL データベースの 分離レベルと MVCC を比較した発表がある。
Oracle、MySQL、SQL Server、PostgreSQL、YugabyteDB を扱っている。
https://fosdem.org/2024/schedule/event/fosdem-2024-3600-isol...
append(a) が与えられたテーブルの実際の SQL 演算 にどうマッピングされるのか気になる。
TEXT フィールドをリストのように使っているのだろうか。
MySQL の repeatable read モードでは、単一行を対象とする単一の SELECT がありえない結果を返したこともある。
SELECT min(value), max(value) FROM table WHERE id = 1;のような形で、idは主キーだったのに、minとmaxが異なる値になった。参考までに、これは CONCAT に特有の問題ではない。CONCAT を使っている理由は、異常現象を指数時間ではなく 線形時間 で推論できるからだ。
同種の挙動は普通の読み書きレジスタでも現れる。
記事と AWS RDS を取り上げてくれたのはよかったが、AWS Aurora MySQL にも焦点が当たっていたのか気になる。
知らない人のために言うと、AWS は MySQL や PostgreSQL を装うプロトコル互換のデータベースプラットフォームを作った。
Aurora MySQL が RDS や MariaDB と同じような「特徴」を持つのかを見るのは面白そうだ。
それでも非常に興味深い対象であり、Aurora ははるかに新しいデータベースなので、古い MySQL よりもまだ見つかっていない微妙な問題がありそうな気がする。
ただし大きな厄介ごとが1つある。
Plaid のエンジニアが違いをうまく整理した記事を書いている: https://plaid.com/blog/exploring-performance-differences-bet...
私にとって最大の違いは、Aurora クラスタが 共有ストレージ を使うため、分離モデルが少し異なることだ。
read committed はクラスタ全体のパラメータを設定した場合にのみ可能で、read uncommitted は私の見る限り不可能だ。
非常に興味深い記事だ。
これほど多くの 整合性異常 を示す基盤の上でも、どれほど多くの「実際に動くシステム」が作れてしまうのかをよく示している。
5分ほど触っただけでRDSレプリケーションが停止し、失敗したヘルスチェックのアラートもなかったという点は少し心配
ただし、ユーザーに 150 個を超えるメトリクスをあさり、ドキュメントを読んで重要なものを見つける負担を押し付けがちでもある
また、<https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_...> ではレプリケーション状態を示すコンソールの表セルがあるとしているが、コンソールではしばしばその列表示をユーザーが自分で有効にしなければならず、よくない
AWS が言う「共同責任モデル」にかなり大きく依存している
ホストやコンテナの内部ですべて自前でやる必要がある
AWS/Rackspace のサポートは「AWS サービス内で動作しているものは当社では管理していないので顧客の問題です」と言うだけだ
2022 年に Jepsen が Porto 大学の INESC TEC に LazyFS の開発を依頼したという点がよい
fsyncされていない書き込みの損失をシミュレーションする FUSE ファイルシステムとは、技術水準を前進させる素晴らしい例だSELECT ... FOR UPDATEがこうした問題への答えのように見える更新する行をロックすれば、突然すべてが宣伝どおりに動くのではないか?
あるレコードを別のレコードのデータに基づいて更新したいなら、その別のレコードと、おそらく更新するレコードにも ロック読み取り を行う必要がある
単一の SQL クエリで別のレコードに基づいてレコードを更新すると、MySQL はどのみち両方をロックしてくれる
複数の対象に基づいて何かを更新しなければならないなら、私の経験では デッドロック が非常に起きやすい
その代わり、ロック用レコードのようなものをロックしてから、目的のデータに対して repeatable read を行い、更新するほうがよい
repeatable read の時点は、一貫読み取りを行うまで決まらない
SELECT ... FOR UPDATEは一貫読み取りではないので、通常の SQL 更新で数十・数百行をロックすることなく、同時実行の状況でもうまく動作する私の経験では、ほとんどの開発者はそもそも 分離レベル を考慮せず、デフォルト値をそのまま使う
競合状態が起きても「え、変だな」で済ませてしまう
[1] https://news.ycombinator.com/item?id=38696421
だから、ほとんどの開発者は分離レベルを自分で悩まないほうがよく、MySQL や一部のデータベースは平均的な開発者に対して保証レベルをあまりにも少なくしか提供していないと思う