MySQLとTiDBの一貫性読み取りの違い

MySQLとTiDBの一貫性読み取りの違い

こんにちは。
サーバーサイドエンジニアの市東です。今回は、TiDBのトランザクションの特徴について話そうと思います。

TiDBは、MySQL互換のインターフェースを持ちながら、分散環境でのスケーラビリティや高可用性を実現できる強力な分散型データベースです。大量のデータや高い並行処理が求められる場面でも、アプリケーションコードを大きく変更することなく導入できる点が魅力です。

いくつかのプロジェクトでは、データベースを使う上で、共通のロック獲得方針を取ってきました。これは整合性を担保するためであるのはもちろん、実装者が迷わずに正しいロックを選択できるようにする狙いもあります。また、複数人が同じ機能を実装する際に、ロックの粒度や取得順序を揃えることで、デッドロックの発生を減らす効果もあります。しかし、TiDBのRepeatable Readでは、特定のロック獲得方針がそのままでは期待通りに動かないことがわかりました。この記事では、その挙動差を示しつつ、最終的に対応策をまとめます。厳密にはTiDBのIsolation LevelはSnapshotが正しいですが、コンソール上はRepeatable Readと表示されるので、記事上ではこの表記とします

対象読者

トランザクションの分離レベルを知っている方
ロック獲得の基礎知識がある方
データベースの挙動に興味がある方

環境

MySQL Server: 8.0.39
TiDB: v8.5.1
Isolation Level: Repeatable Read

要約

MVCCのバージョン割り当ての違い:
MySQLは最初の読み取り時点、TiDBはトランザクション開始時点でバージョンを固定します。

ロック獲得時の挙動差:
トランザクション開始直後にロックを獲得する場合、MySQLとTiDBで見えるデータが異なります。

対応策:
ロックの獲得方針は変えず、Isolation LevelをRead Committedに変更するか、勧告的ロックを使用します。

ロック獲得方針

数人のユーザーが、定員3人の部屋に入るような機能を考えてみます。
単純なクエリで表すと、以下のようになります。

– 事前準備
CREATE TABLE rooms (
  id INT NOT NULL,
  created_at TIMESTAMP NOT NULL,
  PRIMARY KEY (id)
);
CREATE TABLE room_memberships (
  room_id INT NOT NULL,
  user_id INT NOT NULL,
  PRIMARY KEY (room_id, user_id)
);
INSERT INTO rooms (id, created_at) VALUES (1, '2020-01-01 00:00:00+09:00');
INSERT INTO room_memberships (room_id, user_id) VALUES (1, 1001);
INSERT INTO room_memberships (room_id, user_id) VALUES (1, 1002);
– 部屋の状態を確認し、入る
BEGIN;
SELECT * FROM rooms WHERE id = 1;
SELECT COUNT(*) FROM room_memberships WHERE room_id = 1;
INSERT INTO room_memberships (room_id, user_id) VALUES (1, 1003);
COMMIT;

ほとんどのDBMSでは、デフォルトのIsolation Levelのまま複数人が同時に実行すると、定員3人を超えてしまう可能性があります。理論的には Serializable を選択すればこの不整合は防げますが、性能面や実装上の制約から、多くのDBMSでは現実的な選択肢とはなっていません。そのため、実運用ではより緩やかなIsolation Levelを利用せざるを得ず、不整合が発生しうるために明示的なロック獲得方針が必要となります。
Cloud Spannerなどは、性能と整合性の両立を可能にする実用的なSerializableを提供しており、このようなwrite skew anomalyは発生しません

A: 共通するリソースでロックを獲得する方針

BEGIN;
SELECT * FROM rooms WHERE id = 1 FOR UPDATE;
SELECT COUNT(*) FROM room_memberships WHERE room_id = 1;
INSERT INTO room_memberships (room_id, user_id) VALUES (1, 1003);
COMMIT;

この方針では、ロック対象を検討するコストの高さや、対象を間違えたときの不整合リスクを考慮し、共通するroomで広めにとっておき、実装者の負担やレビューコストを下げることを目的としています。roomに関する様々な機能が追加されたとき、常にroomのロックでブロックすると待ちが多くなりすぎて、アプリケーションパフォーマンスが落ちるケースもあるため、その場合は適切な別のロック対象(例えばロック用のレコード)などを選択することがあります。

B: 純粋に人数の整合性を取るために、COUNT時にロックを獲得する方針

BEGIN;
SELECT COUNT(*) FROM room_memberships WHERE room_id = 1 FOR UPDATE;
SELECT * FROM rooms WHERE id = 1;
INSERT INTO room_memberships (room_id, user_id) VALUES (1, 1003);
COMMIT;

最新の値を取得したいレコードのロックを獲得する方針です。これをMySQL Repeatable Readで実行してしまうと、前後のギャップロックを獲得してしまい、無関係なroom membershipsのinsert(例えばroom_id = 2)をブロックすることになるというデメリットが存在します。リリース直後でデータがほとんどないときは、実質テーブルロックと同じ範囲でブロックし、アプリケーションパフォーマンスに悪い影響を出す可能性があります。Read Committedの場合は問題ありません。

C: 勧告的ロックを使用する方針

BEGIN;
GET_LOCK(‘some_key’, 1); – 関数はDBMSによって異なる
SELECT * FROM rooms WHERE id = 1;
SELECT COUNT(*) FROM room_memberships WHERE room_id = 1;
INSERT INTO room_memberships (room_id, user_id) VALUES (1, 1003);
COMMIT;

DBMS特有のロック関数を使い、任意のkeyを使ってロックを獲得する方針です。keyの生成方針が定まっていれば、非常に使いやすいですが、関数がDBMS依存になってしまう点だけ注意が必要です。

プロジェクトによっては、Aを基盤としたロック獲得方針で実装しています。

さて、このAやCの方針は実はTiDBでは期待通りに動きません。その理由を説明するために、まずはDBMSにおけるMVCC(Multi-Version Concurrency Control)について簡単に紹介します。

MVCC

MVCCは、同じデータ項目に対して複数のversionを保持し、トランザクションごとに適切なバージョンを参照することで隔離性を保ちつつ、同時実行性を高める仕組みです。例えば、ある行が更新されると、その行の直前の状態は履歴(Undoログやバージョンチェーン)として残されます。読み取り側は、自分のトランザクションが開始した時点、もしくは最初の読み取り時点など、あらかじめ決まった「可視性ルール」に基づいて、どのバージョンを読むかを判断します。このMVCCの実装はDBMSによって異なります。

MySQL

MySQLではUndoログとRead Viewを使ってMVCCを実現しています。トランザクションIDによってバージョンを決定しており、BEGIN直後には未割り当てで、最初の読み取りクエリ実行時にIDが決定します。このIDによって、読み取るバージョンが決定します。

PostgreSQL

PostgreSQLでは、テーブルの各行に作成/削除トランザクションID(xmin/xmax)を直接埋め込む形でMVCCを実装しています。書き込み操作(INSERT/UPDATE/DELETE/SELECT … FOR UPDATE)を実行したタイミングでトランザクションIDが割り当てられ、そのIDに基づき可視性判定が行われます。

TiDB

TiDBはGoogle Percolatorの設計を参考にした分散MVCCを採用しています。BEGIN実行時にStartTS(開始タイムスタンプ)が即時に割り当てられ、以後はこのStartTS以前にコミットされたバージョンのみが可視となります。

Cloud Spanner

Cloud Spannerでは、TrueTime APIに基づくタイムスタンプベースのMVCCを採用しています。読み取り専用トランザクションはBEGIN時にRead Timestampが決まり、その時刻に基づき可視判定が行われます。読み書きトランザクションは常にロックを獲得し、最新の値を読み取りに行きます。

問題となる挙動

ここで押さえておきたいのは、「どのタイミングで参照するバージョンが決まるか」 という点です。TiDBでは、BEGIN を実行した瞬間に、以降の読み取りで参照するバージョンが確定します。一方、MySQLなどでは BEGIN 直後にはまだ確定しておらず、最初の特定のクエリ(読み取りやロック取得)を実行したタイミングで決まります。この挙動の差を、先ほど紹介したテーブルを使って比較してみましょう。

この現象はトランザクション情報の比較からも確認することができます。

BEGIN
-- MySQL: SELECT trx_id FROM information_schema.innodb_trx; => 0 row
-- TiDB: SELECT START_TIME FROM information_schema.tidb_trx; => 1 row
SELECT ... FOR UPDATE
-- MySQL: SELECT trx_id FROM information_schema.innodb_trx; => 1 row
-- TiDB: SELECT START_TIME FROM information_schema.tidb_trx; => 1 row

このように、特定のロック方針の場合、MySQLでは整合性を担保できていたものの、TiDBでは担保できなくなる現象が発生します。

解決策

対応策として、いくつかの方法が挙げられます。

ISOLATION LEVELをREAD COMMITTEDにする

COMMITされたデータは読めるようになるため、ロック獲得方針を変えないまま適用できます。ただし、read skew anomalyなどが発生するようになってしまいます。他のanomalyは、ロック獲得が正しく実行されていれば、問題ないはずです。

勧告的ロックを使用する

前述したようなBEGIN直後に勧告的ロックを使用する場合だと、同じ問題が発生してしまうため、BEGIN直前に使用する必要があります。

GET_LOCK(‘some_key’, 1);
BEGIN;
– do something
COMMIT;
RELEASE_LOCK(‘some_key’);

最新の値を読み取りたい場合にロックを獲得する

TiDBのガイド には、必要最低限のロックを獲得する方法が書かれています。TiDB単体を使う場合はこれで問題ありませんが、MySQLでも動かすことを考えた場合、前述したようにMySQLのRepeatable Readではスキーマによっては広範囲のロックを獲得してしまう可能性があるため現実的な方法ではありません。そのまま動くようにするためにはMySQLでREAD COMMITTEDを選択することになり、実質1つ目の方法と同じになります。

まとめると、

  • MySQLと互換性のあるロック獲得方針にしたい場合は、READ COMMITTEDにするか、勧告的ロックを使用する
  • TiDB単体で動けば問題ない場合は、ガイドの通り必要最低限のロック獲得にする

ということになります。

最後に

TiDBのような分散型データベースは、今後ますます選択肢に入る技術だと思います。便利さの裏にある挙動差を理解し、正しく使いこなすことで、大規模なアプリケーションにも安心して導入できます。今後もこうした違いを検証しながら、実運用に耐える設計を積み重ねていきたいと思います。