【SQL】トランザクション分離レベルとダーティリード・ファントムリード

SQL

こんにちは、かつコーチです。

前回はACID特性の4つの性質を解説し、その中のIsolation(独立性)には「強さ」の段階があるという話をしました。

今回はその独立性を段階的に設定するトランザクション分離レベルを扱います。

分離レベルの違いによって、ダーティリード・ノンリピータブルリード・ファントムリードという3種類の不整合現象がどう発生し、どう防げるのかを整理していきます。

上級者向けの内容として、実際にMySQLで再現しながら理解を進める前提で解説します。

トランザクション分離レベルとは?

独立性の強さを4段階で設定する仕組み

トランザクション分離レベルとは、複数のトランザクションが同時に実行されたときに、互いの変更がどこまで見えるかを制御する設定です。

SQL標準では、独立性が弱い順に次の4段階が定義されています。

分離レベル独立性
READ UNCOMMITTEDもっとも弱い
READ COMMITTEDやや弱い
REPEATABLE READやや強い(MySQLの初期設定)
SERIALIZABLEもっとも強い

独立性を強くするほどデータの一貫性は保たれやすくなりますが、その分ロックの範囲が広がり、同時実行できる処理の数(スループット)が下がるというトレードオフがあります。

なぜ分離レベルを理解する必要があるのか

分離レベルは、多くの場合デフォルト設定のまま意識せずに使われています。

しかしECサイトの在庫管理や座席予約のように、同時アクセスが集中する処理では、分離レベルの選択がバグの有無を左右します。

「なぜか同時に注文が入ると在庫がマイナスになる」「集計処理の結果が実行するたびに微妙に違う」といった不具合の多くは、分離レベルの理解不足が背景にあります。

現在使っている分離レベルは、MySQLでは次のコマンドで確認できます。

SELECT @@transaction_isolation;

3つの不整合現象を理解する

以下のテーブルを使い、2つのトランザクション(セッションA・セッションB)が同時に動く前提で解説します。

CREATE TABLE products (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    stock INT NOT NULL
);

INSERT INTO products (name, stock) VALUES ('限定Tシャツ', 10);

ダーティリード:COMMIT前の変更が見えてしまう

ダーティリードとは、他のトランザクションがまだCOMMITしていない変更を読み取ってしまう現象です。

-- セッションA(READ UNCOMMITTEDで実行)
START TRANSACTION;
UPDATE products SET stock = 0 WHERE id = 1;
-- まだCOMMITしていない

-- セッションB
SELECT stock FROM products WHERE id = 1;
-- READ UNCOMMITTEDだと、COMMIT前の「0」が見えてしまう

もしセッションAがこの後ROLLBACKした場合、セッションBは「実際には存在しなかった在庫0」という情報をもとに、在庫切れの表示をしてしまうことになります。

READ UNCOMMITTED以外の分離レベルでは、この現象は発生しません。

ノンリピータブルリード:同じトランザクション内で読む値が変わる

ノンリピータブルリードとは、同じトランザクション内で同じ行を2回読んだときに、間に他のトランザクションのCOMMITが挟まることで、1回目と2回目で異なる値が返ってくる現象です。

-- セッションA(READ COMMITTEDで実行)
START TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- 10が返る

-- この間にセッションBが更新してCOMMIT
-- UPDATE products SET stock = 5 WHERE id = 1; COMMIT;

SELECT stock FROM products WHERE id = 1; -- 5が返る(1回目と違う)
COMMIT;

READ COMMITTEDでは、COMMIT済みのデータだけを読む代わりに、同じトランザクション内でも他のCOMMITの影響を受けてしまいます。

REPEATABLE READ以上の分離レベルでは、トランザクション開始時点のスナップショットを見続けるため、この現象は起きません。

ファントムリード:条件検索の結果件数が変わる

ファントムリードとは、同じ条件で範囲検索を2回実行したときに、間に他のトランザクションが行を追加・削除したことで、1回目と2回目で返ってくる行数が変わる現象です。

-- セッションA
START TRANSACTION;
SELECT COUNT(*) FROM products WHERE stock > 5; -- 3件と表示

-- この間にセッションBが新しい商品をINSERTしてCOMMIT

SELECT COUNT(*) FROM products WHERE stock > 5; -- 4件と表示(幽霊のように行が増える)
COMMIT;

MySQLのInnoDBではREPEATABLE READでもギャップロックという仕組みによってファントムリードの多くが防がれますが、SQL標準の定義上、REPEATABLE READはファントムリードを許容する分離レベルとされています。

完全にファントムリードを防ぎたい場合は、もっとも独立性の強いSERIALIZABLEが必要になります。

つまずきやすいポイント:分離レベルとロックコストの誤解

とにかくSERIALIZABLEにすれば安全という思い込み

❌ Before:不整合を怖れて、すべての処理をSERIALIZABLEに設定する

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

START TRANSACTION;
SELECT * FROM products WHERE stock > 5;
COMMIT;

私が過去に相談を受けた案件で、「不整合が怖いから」という理由ですべての処理をSERIALIZABLEに設定していたシステムがありました。

その結果、同時アクセスが増えるタイミングでロック待ちが多発し、Lock wait timeout exceededのエラーが頻発するようになっていました。

独立性を最大にすればするほど、同時に実行できる処理の数が絞られ、スループットが犠牲になるという副作用を見落としていたのが原因です。

✅ After:処理内容に応じて必要最小限の分離レベルを選ぶ

-- 通常の参照処理はMySQLの初期設定(REPEATABLE READ)のままにする
SELECT * FROM products WHERE stock > 5;

-- 厳密な整合性が必要な一部の処理だけ、明示的に分離レベルを上げる
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- 在庫の厳密な排他制御が必要な処理のみここに書く
COMMIT;

「どの処理に、どの不整合現象が許容できないか」を洗い出したうえで、必要な箇所だけ分離レベルを引き上げるのが実務的な落とし所です。

在庫の排他制御など、どうしても厳密さが必要な一部の処理には、分離レベルを上げる代わりにSELECT ... FOR UPDATEで該当行だけをロックするという選択肢もあります。

応用・一歩先の使い方

SELECT … FOR UPDATEによる明示的ロック

分離レベル全体を変更しなくても、特定の行だけを明示的にロックする方法があります。

START TRANSACTION;

-- 対象行をロックしながら取得する
SELECT stock FROM products WHERE id = 1 FOR UPDATE;

UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT;

FOR UPDATEを付けてSELECT文を実行すると、対象行に排他ロックがかかり、他のトランザクションはこの行の更新やロックの取得を、現在のトランザクションがCOMMITまたはROLLBACKするまで待たされます。

在庫確認と在庫減算を1つのトランザクション内で安全に行いたい場合など、分離レベル全体を引き上げるより局所的で影響範囲の小さい対策として重宝します。

分離レベルの設定方法

分離レベルはセッション単位、あるいはトランザクション単位で設定できます。

-- このセッション内のすべてのトランザクションに適用
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 次に開始する1つのトランザクションだけに適用
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;

アプリケーション全体のデフォルトはMySQLの初期設定であるREPEATABLE READのままにしておき、特に整合性が求められる一部の処理だけSET TRANSACTION ISOLATION LEVELで個別に引き上げる、という使い分けが現実的です。

まとめ

この記事のポイント

  • トランザクション分離レベルは、READ UNCOMMITTED・READ COMMITTED・REPEATABLE READ・SERIALIZABLEの4段階で独立性の強さを設定する
  • 独立性が弱いほどダーティリード・ノンリピータブルリード・ファントムリードが起きやすく、強いほどロックコストが増えてスループットが下がる
  • MySQL(InnoDB)の初期設定はREPEATABLE READで、ギャップロックによりファントムリードの多くを防いでいる
  • 分離レベルを一律で上げるのではなく、厳密な整合性が必要な処理だけを見極めて設定する、あるいはSELECT ... FOR UPDATEで局所的にロックするのが実務的な対処法

この記事とSQL基礎編のまとめ

これでSQL基礎編(全35本)は完結です。

データベースの全体像からSELECT文の基本、JOIN、DML、テーブル設計、インデックス、そしてトランザクションとACID特性・分離レベルまで、DB製品に依存しないSQLの共通知識を一通り解説してきました。

ここまで読んでくださった方は、MySQLやPostgreSQLなど、特定の製品固有の機能を学ぶ土台がすでに整っています。

次はMySQL編として、ストレージエンジンや製品固有の関数・設定など、より実践的な内容に進んでいく予定です。

引き続きよろしくお願いします。


タグ: SQL, 上級者向け, トランザクション

タイトルとURLをコピーしました