🍣

トランザクション分離レベル表を覚えずに仕組みから理解する

に公開

よく見るトランザクション分離レベル表

分離レベル ダーティリード ファジーリード ファントムリード 説明
READ UNCOMMITTED 発生する 発生する 発生する 未コミット変更も読み取る
READ COMMITTED 発生しない 発生する 発生する コミット済みの変更のみ読み取る
REPEATABLE READ 発生しない 発生しない 発生しない※ 同じクエリは常に同じ結果を返す
SERIALIZABLE 発生しない 発生しない 発生しない 完全に直列化された実行と同等

※ MySQLのInnoDBではREPEATABLE READでもファントムリードは発生しない

毎回この表を見返しては忘れてしまう。
そんな方向けに仕組みを理解できるように解説してみたいと思います。
(MySQL InnoDBの話です。)

何を理解すればトランザクション分離レベルを理解したことになるのか

分離レベルによって様々なリード現象が発生するわけですが、分離レベルの設定によってどう内部の挙動が変わりそのリード現象が発生するのかを理解できればトランザクション分離レベルを理解した、と言っても良いでしょう。

そもそもトランザクションとは

トランザクションが持つべき4つの性質として、Atomicity(原子性)、Consistency(一貫性)、Isolation(独立性)、Durability(持続性)の4の頭文字をとってACID特性と呼ばれることがあります。

ネットで調べればいくらでも出てくる情報なのでここで解説はしませんが、
トランザクションとは何か、を一言で言うと**「全部成功するか失敗するか(All or Nothing)」**
となります。

データベースは様々な更新/参照処理が同時に実行されます。
例えばホテル予約システムならホテルの検索画面から在庫情報を同時に大量のユーザーが検索しつつ、その中の何人かは同時に予約まで行うでしょう。予約ではポイントやクーポンを利用することもあるので、
この過程で様々なテーブルにSELECT/INSERT/UPDATE/DELETEが同時に実行されます。

この時トランザクションがなければ「予約できているけどポイントが消費されてない」「クーポンの利用上限枚数に達しているのにさらに利用できてしまう」などの不整合が起こりえます。

一貫性読み取りとMVCC

こうした問題を回避したければロックを取れば良いのですが、ロックを取ると他のトランザクションからの読み書きもブロックすることになってしまい同時実行数をスケールできなくなってしまいます。

何度SELECTしてもトランザクション開始時点で読めるべき同じ内容を返す、ことができればロックを取らなくても済みます。

これを実現するのがMVCC(Multiversion Concurrency Control)です。

MVCCはトランザクション開始時点でのスナップショットに基づいて、一貫性のある読み取りを提供してくれます。

スナップショットと聞くと、ある時点でのデータベースの状態を保持しているのかと思うかもしれませんが、あたかもスナップショットに見えるだけで、実際にはレコード単位でスナップショットっぽい挙動をするように実装されています。

この原理を追っていきます。

テーブルの内部構造

MySQL公式サイトの15.3 InnoDB マルチバージョン
によると各テーブルには次のカラムが自動的に付与されます。

  • 6バイトのDB_TRX_ID: 行を挿入または更新した最後のトランザクション識別子
  • 7バイトのDB_ROLL_PTR: Undoログレコード(更新前の内容)へのポインタ
  • 6バイトのDB_ROW_ID: 行ID(PKなしの場合のみ)

内部的には次のテーブル構造になります。

CREATE TABLE 会員 (
会員ID INT AUTO_INCREMENT PRIMARY KEY,
氏名 VARCHAR(100) NOT NULL,
-- 内部用カラム
DB_TRX_ID BINARY(6) NOT NULL,
DB_ROLL_PTR BINARY(7)
)

ここから分かることは

  • レコードはどのTRX_IDで挿入/更新されたかを識別できる
  • レコードは過去の状態をROLL_PTRを遡ればUNDOログから復元できる
    ということです。

テーブル構造的にスナップショットのような動作ができそうなことは分かったので、どう実装しているかを見てみます。

ReadView

トランザクションが開始すると最初のSELECT文の実行時にReadViewと呼ばれる構造体が作られます。
(分かりやすさのためにReadViewのソースコードとは少し変数名を変えています)

{
  min_trx_id: 最小のアクティブトランザクションID,
  next_trx_id : 次に作成されるトランザクションID,
  active_trx_ids: 現在アクティブなトランザクションIDのリスト
}

これらの値を保持しておくとSELECT文を発行する時にレコードごとに次のロジックで判定することでReadView作成時点で読み取るべきレコードだけを読み取ることができます。

  1. trx_id == 自分のID → 読み取り可(自分自身の変更は常に読める)
  2. trx_id <= min_trx_id → 読み取り可(コミット済であることが分かっているため)
  3. trx_id >= next_trx_id → 読み取り不可(開始時に存在しないトランザクションなので)
  4. trx_id in active_trx_ids → 読み取り不可(開始時にアクティブ(未コミット)なので)
  5. trx_id not in active_trx_ids →読み取り可(開始時にコミット済なので)

具体的な挙動を見てみたいと思います。

ReadViewの具体例

この図はREPEATABLE READ分離レベルにおいて、トランザクションAの実行中に他トランザクションから変更を加えられたときに読めるレコードが変わらないことを示しています。

1回目のSELECTを行った時にテーブルにはTRX_ID = 29, 30, 31, 36のレコードが存在します。

  • TRX_ID=29はmin_trx_idの30より小さい、つまりコミット済なので読み取り可
  • TRX_ID=30, 36はactive_trx_idsに含まれる、つまり未コミットなので読み取り不可
  • TRX_ID=31はactive_trx_idsに含まれない、つまりコミット済なので読み取り可
    となりTRX_ID=29, 31のレコードのみが読み取れます。

2回目のSELECTを行ったとしてもReadViewは最初のままであり読み取り結果は変化しません。

UPDATEの復元

続いてUPDATEが行われた場合にどうなるかを見ていきます。

UPDATEは更新前の内容がUNDOログに含まれており、DB_ROLL_PTRを辿れば更新前の内容を復元できます。

この図ではあるトランザクション開始時のReadView, SELECT時のテーブル、UNDOログの状態から読み取れるレコードがどうなるかを示しています。

TRX_ID=29, 31は先ほど同様にReadViewの内容からコミット済なので読み取り可能です。
TRX_ID=30, 36はROLL_PTRに値が入っているのでUPDATEされたレコードであることが分かります。
ROLL_PTRに値が入っているならば、最新のTRX_IDが未コミットのものであってもUPDATE前の状態を復元して読み取れる時点のTRX_IDまで遡れるかを確認しなければいけません。

TRX_ID=30はROLL_PTRにX1という値が入っているのでUNDOログのX1が指し示す位置、ここではColumnV=V300の行を取得し復元します。復元後のTRX_IDは26となり、ReadViewのmin_trx_idより小さいのでこのレコードは読み取り可能です。

TRX_ID=36はROLL_PTRにY2という値が入っており、UNDOログのY2(ここでは3行目のレコード)で復元します。しかし復元後のTRX_IDは33であり、これはactive_trx_idsに含まれるので少なくともこのトランザクション開始時点では未コミットです。さらにROLL_PTRを辿るとTRX_ID=30の行が見つかりますが、これもReadViewが作られた時点では未コミットなのでよみとれません。

結果としては復元したレコードを含めて3行のレコードを読み取れました。

トランザクション分離レベルの動作をMVCCで理解する

ここでようやく分離レベルの話に戻ります。
READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READの3つがどのようにレコードを読み取るかを見てみましょう。

READ_UNCOMMITTED

READ_UNCOMITTEDではTRX_IDによる読み取り判定もROLL_PTRによるUPDATEの復元も不要なため、そもそもReadViewを作る必要がなくテーブルにあるレコードは全て読み取ります。
その結果、未コミットのレコードも読み取る動作になります。

READ_COMMITTED

READ_COMMITTEDではSELECT時に毎回ReadViewを生成するため、SELECT時点でコミット済のものが読み取れる挙動になります。
ただし、テーブル上の最新レコードが未コミットの場合は、REPEATABLE_READと同様にUNDOログを遡って復元処理を行います。

REPEATABLE_READ

REPEATABLE_READはトランザクション開始後の最初のSELECT時点でReadViewを作るため、何度SELECTしてもトランザクション内では同じレコードだけを読み取れます。

ここまでの話から各リード現象がなぜ発生するのか理解できたのではないでしょうか。

ダーティリード
他トランザクションの未コミットの変更を読み取ってしまう現象

ファジーリード
同じ行の再読み取り時に、他のトランザクションのUPDATEにより値が変わってしまう現象

ファントムリード
同じ範囲検索時に、他のトランザクションが行を追加/削除した結果、行数が変わってしまう現象

ロック読み取り

ここまでの話は非ロック読み取りといって以下のようなロックを伴わない話でした。
ロック読み取りを行う場合は挙動が変わってきます。

CREATE TABLE t (id INT PRIMARY KEY, price INT NOT NULL);
INSERT INTO t VALUES (1, 100);

上記のテーブルを作ってから2つのトランザクションで以下の処理を実行してみます。

-- TRX1
START TRANSACTION;

-- TRX2
START TRANSACTION;
SELECT * FROM t WHERE id = 1; > [id: 1, price: 100]

-- TRX1
UPDATE t SET price = 200 WHERE id = 1;
COMMIT;

-- TRX2
SELECT * FROM t WHERE id = 1; > [id: 1, price: 100]
SELECT * FROM t WHERE id = 1 FOR UPDATE; > [id: 1, price: 200]
SELECT * FROM t WHERE id = 1; > [id: 1, price: 100]

デフォルトの分離レベルであるREPEATABLE_READではトランザクション開始時点でコミットされているものだけが読み取れる、という説明をしましたがSELECT FOR UPDATEはREAD_COMMITTEDのような挙動をしています。

ロックを取るのは基本的に更新するためなので、更新を前提とする処理ではトランザクション開始時のコミット済ではなく、SELECT時点でコミット済のレコードを読み取れる仕様になっています。

非ロック読み取りとロック読み取りで挙動が異なるので注意しましょう。

まとめ

トランザクション分離レベル表のREAD_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READについてMySQL(InnoDB)の内部実装であるMVCC, ReadViewなどを概念を用いて挙動を説明しました。

実用上はMySQLならデフォルトの分離レベルのREPEATABLE_READをそのまま使うだけということが多いかもしれませんが、内部の仕組みを理解しておくことでいつか何か問題が起きた時の役に立つかもしれません。

Discussion