【MySQL】トランザクションの分離レベルを理解する|READ COMMITTEDとREPEATABLE READの違い

はじめに

同じトランザクション内で SELECT を2回打ったとき、結果が変わらないことを期待していませんか。MySQL の InnoDB では、トランザクション分離レベルによって、その期待が満たされるかどうかが変わります。

実務で特によく比較されるのが次の2つです。

  • REPEATABLE READ(InnoDB のデフォルト)
  • READ COMMITTED

この記事では、通常 SELECT の見え方と、範囲ロックまわりの違いを、2セッションの再現手順つきで整理します。用語の定義は次節の表で扱います。READ UNCOMMITTED と SERIALIZABLE の詳細は扱いません。

前提: MySQL 8.4 Reference Manual の InnoDB 説明を根拠にします。ストレージエンジンは InnoDB を想定しています。他エンジンやレプリケーション構成の細部は必要箇所のみ触れます。

背景:分離レベルで何が変わるか

分離レベルは、同時実行されるトランザクション同士の見え方とロック戦略のバランスです。InnoDB は SQL:1992 の4レベル(READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE)を提供します。デフォルトは REPEATABLE READ です(Transaction Isolation Levels)。

本記事でいう スナップショットは、「ある時点のデータの見え方を固定した、読み取り用の視点」です。いつその視点を取るかが、REPEATABLE READ と READ COMMITTED の大きな差です。

用語意味の目安
スナップショットある時点の見え方を固定した読み取り用の視点
ダーティリード未コミットの変更が見える(本記事で扱わない READ UNCOMMITTED 向け)
非リピータブルリード同じ行を再度読んだとき、他トランザクションのコミットで内容が変わる
ファントム同じ条件で再度読んだとき、他トランザクションの INSERT などで行が増減する
一貫読み取り(consistent read)スナップショットを、ロックなしの通常 SELECT で読む読み方
ギャップロックインデクス上の「値と値のあいだ」への INSERT を防ぐロック
ネクストキーロックインデクスレコードのロックに、直前ギャップのロックを足したもの

REPEATABLE READ:最初の読み取りでスナップショットが固まる

通常の(ロックしない)SELECT による一貫読み取りでは、そのトランザクション内の最初の一貫読み取りで取ったスナップショットを、以降の一貫読み取りでも使います。詳細は Consistent Nonlocking Reads を参照してください。

そのため、同じトランザクション内の複数の通常 SELECT は、互いに一貫した見え方になります。他セッションがコミットしても、自分のスナップショットより後の変更は、通常 SELECT では見えません(自分のトランザクション内の変更は例外的に見えます)。

ロック読み取り(SELECT ... FOR UPDATE / FOR SHARE)や UPDATE / DELETE では、検索条件によってギャップロックやネクストキーロックが使われます。これにより、範囲への INSERT を防ぐ動きになります。

READ COMMITTED:文ごとに新しいスナップショット

一貫読み取りは、同じトランザクション内でも文ごとに新しいスナップショットを取ります。そのため、他セッションのコミット済み変更は、次の通常 SELECT で見えることがあります(非リピータブルリードが起き得る)。

ロック読み取りや UPDATE / DELETE では、原則としてインデクスレコードのロックに留まり、ギャップはロックしません(外部キー制約チェックと重複キーチェックではギャップロックが使われます)。そのため、ロックされたレコードの隣への INSERT は許可されやすく、ファントムが起き得ます。

ドキュメントでは、READ COMMITTED では一致しない行のレコードロックを WHERE 評価後に解放するため、デッドロックの確率は下がるがゼロにはならない、と説明されています。

比較表(InnoDB)

観点REPEATABLE READ(デフォルト)READ COMMITTED
通常 SELECT のスナップショットトランザクション内の最初の一貫読み取り文ごと
非リピータブルリード(通常 SELECT)起きにくい起き得る
ギャップロック(検索・スキャン)使い得る原則オフ(外部キー/重複キー以外)
ファントムロック戦略で抑えやすい起き得る
よく挙がる用途のイメージ一貫性が重要なオンライン更新(注文・残高など)ロック競合を抑えたい、レポート寄りの読み取りなど

用途のイメージはドキュメントの説明を要約したものです。アプリ要件と検証結果で決めてください。


実装と検証:2セッションで違いを見る

ターミナルを2つ開き、それぞれ mysql クライアントで同じデータベースに接続してください(以降 Session A / Session B)。

検証用テーブルです。

CREATE TABLE accounts (
  id INT PRIMARY KEY,
  balance INT NOT NULL
) ENGINE=InnoDB;

INSERT INTO accounts (id, balance) VALUES (1, 100), (2, 200);
COMMIT;

現在の分離レベルは、次のコマンドで確認できます。

SELECT @@transaction_isolation;

セッション単位で切り替える例です。

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- または
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

例1:通常 SELECT のスナップショット差

前提: オートコミットはオフ(START TRANSACTION を使う)。Session B は更新後に COMMIT 済み。Session A は通常の SELECT のみです。

REPEATABLE READ

-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 100(ここでスナップショットが固まる)

-- Session B
START TRANSACTION;
UPDATE accounts SET balance = 150 WHERE id = 1;
COMMIT;

-- Session A(続き)
SELECT balance FROM accounts WHERE id = 1;  -- 100
COMMIT;
SELECT balance FROM accounts WHERE id = 1;  -- 150(新しいトランザクション)

READ COMMITTED

-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 100

-- Session B
UPDATE accounts SET balance = 150 WHERE id = 1;
COMMIT;

-- Session A(続き)
SELECT balance FROM accounts WHERE id = 1;  -- 150
COMMIT;

この差が「最初の読み取りで固定するか/文ごとに取り直すか」です。

例2:ギャップロックの有無(範囲ロック)

ギャップロックは、インデクス上の「値と値のあいだ」への INSERT を防ぐロックです。REPEATABLE READ では範囲スキャンで使われ得ます。READ COMMITTED では検索・スキャンでは原則使いません。

CREATE TABLE orders (
  id INT PRIMARY KEY,
  amount INT NOT NULL
) ENGINE=InnoDB;

INSERT INTO orders (id, amount) VALUES (10, 100), (20, 200), (30, 300);
COMMIT;

REPEATABLE READ(INSERT が待ちやすい)

-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;

-- Session B
START TRANSACTION;
INSERT INTO orders (id, amount) VALUES (15, 150);  -- ここで待ちやすい

-- Session A
COMMIT;  -- このあと Session B の INSERT が進む

READ COMMITTED(INSERT が進みやすい)

-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;

-- Session B
START TRANSACTION;
INSERT INTO orders (id, amount) VALUES (15, 150);  -- 進みやすい
COMMIT;

-- Session A
COMMIT;

実行計画や負荷で差が出ることもあるため、最終確認は手元の MySQL で行ってください。

実装時の注意点:

  • 通常の SELECT(一貫読み取り)はロックを取らない。ギャップロックの話は主にロック読み取りや更新系
  • 公式は、REPEATABLE READ トランザクション内でロック文と非ロック SELECT を混ぜるのは推奨していない(見え方が食い違いやすい)
  • 本番で READ COMMITTED に変えるときは、バイナリログ形式にも注意する。ドキュメントでは row 形式が前提になる挙動が説明されている。手元の2セッション検証だけなら、この点は後回しでよい

アプリから接続単位で指定する例(Laravel)

ORM を使う場合も、やることは「トランザクション開始前に SET SESSION ... を流す」です。Laravel の例です。

use Illuminate\Support\Facades\DB;

DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');

DB::transaction(function () {
    // この接続上のトランザクション
});

グローバル既定を変えるのは影響範囲が大きいです。まずはセッション単位で検証してください。


実務での選び方

状況検討の方向
同一トランザクション内で複数回読み、途中結果を揃えたいREPEATABLE READ(デフォルト)を維持しやすい
範囲ロックやデッドロックが目立ち、レポート系で多少の見え方の変化を許容できるREAD COMMITTED を検証候補にする
在庫減算などで行を確実に排他したい分離レベルだけに頼らず、SELECT ... FOR UPDATE やアプリ設計もセットで考える
長時間トランザクションどちらでもロック保持や巻き戻し用履歴(undo)の肥大が出やすい。短く切る

「READ COMMITTED にすれば必ず速くなる」わけではありません。ロック競合の形が変わるだけなので、再現手順とメトリクスで確認してください。


よくある失敗例:通常 SELECT だけで在庫を決めて更新する

分離レベルを正しく選んでも、通常 SELECT だけでは行の排他にはなりません。REPEATABLE READ のまま、次のような流れだけだと競合に弱いことがあります。

-- Session A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 100(一貫読み取り・ロックなし)
-- アプリ側で「まだ足りる」と判断

-- Session B
START TRANSACTION;
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;

-- Session A(続き)
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;
-- 意図せず残高が想定より減る、などの不整合になり得る

通常 SELECT はロックしません。REPEATABLE READ でも「読んだ行を他者が更新できない」わけではありません。

対処:

  1. 更新対象行を SELECT ... FOR UPDATE で読んでから更新する
  2. UPDATE ... WHERE id = 1 AND balance >= 80 のように条件付き更新にし、影響行数を確認する
  3. 必要ならアプリ層でリトライする
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 80 WHERE id = 1 AND balance >= 80;
COMMIT;

まとめ

  • InnoDB のデフォルトは REPEATABLE READ。通常 SELECT は最初の一貫読み取りスナップショットをトランザクション内で再利用する
  • READ COMMITTED は文ごとにスナップショットを取り直し、ギャップロックも原則弱い。ロック競合は減りやすい一方、非リピータブルリードやファントムが起き得る
  • 在庫のような排他が必要な処理は、分離レベル変更だけでは足りない。ロック読み取りや条件付き更新を併用する

次に試すこと

  1. 検証 DB で SELECT @@transaction_isolation; を確認する
  2. 例1の2セッション手順を REPEATABLE READ / READ COMMITTED の両方で実行し、2回目の SELECT 結果を記録する
  3. 例2で FOR UPDATE 中の INSERT がブロックするかを両方のレベルで比較する
  4. アプリのクリティカルパス(在庫・残高など)が通常 SELECT のみになっていないかを見直す
  5. READ COMMITTED へ寄せる場合は、ステージングでデッドロック件数と結果の再現性を測る

Source

DatabaseMySQL

Posted by 千原 耕司