トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

作成日:
読了:13
更新日:

「残高を引いたのに、もう片方への加算が失敗して、お金が消えた」——こうした事故を防ぐ仕組みがトランザクションです。複数の操作を「全部成功か、全部なかったことにするか」のひとかたまりとして扱い、同時に走る他の処理から適切に隔離します。この記事では、ACIDの意味4つの分離レベル、そこで起き得る異常現象の対応関係を、PostgreSQL・MySQL公式を一次ソースに整理します。結論を先に言うと、「分離レベルが緩いほど速いが異常が起きやすく、厳しいほど安全だが衝突や待ちが増える」というトレードオフを理解することがすべての出発点です。

トランザクションとは

トランザクションは、1つ以上のSQLをまとめて「不可分な単位」として実行する仕組みです。典型例は銀行の振込で、「Aから1000円引く」「Bに1000円足す」の2つは、必ず両方成功するか両方取り消されなければなりません。

基本の3コマンドはどのRDBでも共通です。

トランザクションの基本
BEGIN;                       -- トランザクション開始
UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE id = 'B';
COMMIT;                      -- ここで確定(永続化)
-- 途中で問題があれば COMMIT の代わりに ROLLBACK; で全部取り消す

COMMITするまでの変更は確定していない(他から見えない、または取り消せる)状態です。ROLLBACKを実行すれば、BEGIN以降の変更はすべてなかったことになります。

ACIDとは

トランザクションが満たすべき4つの性質の頭文字を取ったものがACIDです。

  • Atomicity(原子性): トランザクション内の操作は「全部実行されるか、まったく実行されないか」のどちらか。中途半端な状態を残しません。
  • Consistency(一貫性): トランザクションの前後で、データが整合性ルール(制約・外部キーなど)を満たした正しい状態に保たれます。
  • Isolation(分離性): 同時に走る複数のトランザクションが、互いの途中経過を見て壊れないように隔離されます。この「どこまで隔離するか」を決めるのが分離レベルです。
  • Durability(永続性): 一度COMMITした結果は、たとえ直後に電源が落ちても失われません(多くはWAL/redoログで担保)。

このうち実務で最も奥が深いのがIsolationです。完全に隔離すれば安全ですが、すべてを順番待ちにすると性能が出ません。そこで「どこまで緩めるか」を選べるようにしたのが分離レベルです。

同時実行で起きる3つの異常現象

分離が不十分だと、同時実行中に次のような読み取り異常(read phenomena)が起こり得ます。SQL標準はこの3つを定義しています。

  • dirty read(ダーティリード): 他のトランザクションがまだCOMMITしていない変更を読んでしまう。相手がROLLBACKすれば、存在しなかったはずの値を読んだことになります。
  • non-repeatable read(反復不能読み取り): 同じ行を2回読んだら、間に他のトランザクションがCOMMITした更新で値が変わっていた。「同じSELECTが同じ結果を返さない」状態です。
  • phantom read(ファントムリード): 同じ条件で範囲検索を2回したら、間に挿入・削除がCOMMITされ、行が増減していた。「見えるはずのなかった幽霊の行」が現れます。

non-repeatable readが「既存行の値が変わる」のに対し、phantom readは「行の集合(件数)が変わる」点が違いです。

4つの分離レベルと異常の対応表

SQL標準は、許容する異常の度合いで4段階の分離レベルを定めています。下に行くほど厳しく(安全に)なります。これが本記事の核心の表です。

分離レベルdirty readnon-repeatable readphantom read
READ UNCOMMITTED起こり得る起こり得る起こり得る
READ COMMITTED防ぐ起こり得る起こり得る
REPEATABLE READ防ぐ防ぐ起こり得る(標準では許容)
SERIALIZABLE防ぐ防ぐ防ぐ

NOTE

この表はSQL標準の定義です。実際の製品は「標準より強い保証」を提供することが多く、特にREPEATABLE READでのphantomの扱いは製品によって異なります(後述)。

各レベルの考え方は次の通りです。

  • READ UNCOMMITTED: ほぼ何も保証しない最弱レベル。未コミットの値まで見えます。実務で意図的に使う場面は限られます。
  • READ COMMITTED: コミット済みの値だけを読みます。多くのシステムで現実的な既定値。ただし同じSELECTを繰り返すと値が変わり得ます。
  • REPEATABLE READ: トランザクション開始時点のスナップショットを一貫して読むイメージ。同じ行を何度読んでも同じ値です。
  • SERIALIZABLE: 複数トランザクションが「1つずつ順番に実行したのと同じ結果」になることを保証する最強レベル。

分離レベルの指定方法

セッションや個々のトランザクションに対して、SQLで分離レベルを指定できます。

PostgreSQL / MySQL 共通に近い指定
-- 次に始まるトランザクションだけ変える
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
 
-- トランザクション開始と同時に指定(PostgreSQL)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... 処理 ...
COMMIT;

MySQL(InnoDB)ではセッション単位・グローバル単位の指定も使えます。

MySQL InnoDB での指定
-- このセッションの以降のトランザクションに適用
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
 
-- 以降に張られる全接続のデフォルトを変更
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

製品差の注意(ここが一番つまずく)

「分離レベルの名前が同じでも、製品が実際にどう振る舞うかは違う」——これが実務での最大の落とし穴です。一次ソースで確認できた主な差を挙げます。

デフォルトの分離レベルが違う
  • SQL標準のデフォルトはSERIALIZABLEです。
  • PostgreSQLのデフォルトはREAD COMMITTEDです。
  • MySQL(InnoDB)のデフォルトはREPEATABLE READです。

つまり、同じアプリでも接続先がPostgreSQLかMySQLかで、何も指定しなければ既定の隔離の強さが変わります。

PostgreSQLの挙動
  • READ UNCOMMITTEDはREAD COMMITTEDとして扱われます。PostgreSQLにはdirty readを許す本物のREAD UNCOMMITTEDは存在しません。
  • PostgreSQLのREPEATABLE READはphantom readも防ぎます。スナップショットを使うため、標準が許容するphantomまで起きません(標準より強い保証)。
  • SERIALIZABLEはSSI(Serializable Snapshot Isolation)で実装され、危険な競合を検出するとシリアライズ失敗(serialization failure)でトランザクションを中断します。アプリ側でリトライする設計が必要です。

WARNING

PostgreSQLのREPEATABLE READ・SERIALIZABLEでは、競合時に「could not serialize access ...」というエラーでトランザクションが失敗することがあります。これは正常な動作で、該当トランザクションを丸ごとやり直す(リトライ)のが前提です。

MySQL(InnoDB)の挙動
  • InnoDBはSQL標準の4レベルすべてをサポートします。
  • InnoDBのREPEATABLE READは、非ロックのSELECTについて最初の読み取りで確立したスナップショットを一貫して読みます。さらにネクストキーロック/ギャップロックにより、範囲検索への他セッションの挿入をブロックし、phantom readを防ぎます
  • SELECT ... FOR UPDATEUPDATEなどのロックを伴う読み取りは、スナップショットではなく最新のコミット済みデータを見る点に注意が必要です。

このように、PostgreSQLとMySQLはどちらも「REPEATABLE READでphantomを防ぐ」ものの、実現方法(スナップショットMVCC中心か、ギャップロック中心か)が異なるため、ロック待ちやデッドロックの起きやすさも変わります。確証が持てない細部は、必ず使用バージョンの公式ドキュメントで確認してください(製品・バージョンにより異なります)。

どの分離レベルを選ぶか

  • 一般的なWebアプリの多くはREAD COMMITTEDで足り、性能と安全のバランスが良いです(PostgreSQLの既定)。
  • 「同じトランザクション内で何度読んでも値が変わってほしくない」集計やレポートではREPEATABLE READが有効です。
  • 在庫の二重引き当てのように厳密な整合性が要る処理はSERIALIZABLEを検討します。ただしリトライ前提の設計と、衝突増加による性能コストを織り込む必要があります。

なお、分離レベルを上げる代わりに、SELECT ... FOR UPDATEなどの明示的な行ロックで特定の競合だけをピンポイントに防ぐ手もあります。インデックスの設計次第でロック範囲(ギャップロック)が変わるため、関連トピックとしてデータベースインデックス入門も合わせて押さえると理解が深まります。

まとめ

  • トランザクションは複数操作を不可分な単位にまとめる仕組みで、BEGINCOMMITROLLBACKで扱います。
  • ACIDは原子性・一貫性・分離性・永続性。実務で奥が深いのは分離性(Isolation)です。
  • 同時実行の異常はdirty read・non-repeatable read・phantom readの3つ。分離レベルが厳しいほど多く防げます。
  • SQL標準の対応は「READ UNCOMMITTED < READ COMMITTED < REPEATABLE READ < SERIALIZABLE」の順に強くなります。
  • デフォルトはSQL標準=SERIALIZABLE、PostgreSQL=READ COMMITTED、MySQL InnoDB=REPEATABLE READと製品差があります。
  • PostgreSQLはREAD UNCOMMITTEDをREAD COMMITTED扱いにし、REPEATABLE READでphantomも防ぎ、SERIALIZABLEは失敗時リトライが前提です。
  • MySQL InnoDBはREPEATABLE READをスナップショット+ネクストキー/ギャップロックで実現し、phantomを防ぎます。
  • 名前が同じでも製品・バージョンで挙動が違うため、細部は必ず公式ドキュメントで確認しましょう。

参考リンク

ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け

ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け

8

一覧の分割表示(ページネーション)を実務目線で整理します。LIMIT/OFFSET によるオフセット方式の利点(任意ページへジャンプ・総件数)と欠点(大きな OFFSET で遅い・挿入や削除でページずれ)、前回の最後の行を基準にするカーソル/キーセット方式の利点(大規模でも高速・安定)と欠点(任意ジャンプ不可・総ページ数が出しにくい)、複合キーのタイブレーク、不透明カーソルと API レスポンス設計(next_cursor / has_more / GraphQL Relay)、そして使い分けと落とし穴まで、PostgreSQL 公式・Use The Index, Luke・Stripe・Slack を一次ソースにまとめます。

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

10

データベースのインデックスを実務目線で整理します。フルスキャンとの違い、B-tree インデックスがなぜ速いのか、等価・範囲・前方一致・ORDER BY・JOIN で効く理由、複合インデックスの左端プレフィックス、カバリングインデックス(index-only scan)、列に関数を使うと効かない・前方ワイルドカード LIKE が効かないといった落とし穴、書き込みコストやストレージのトレードオフ、InnoDB のクラスタ化インデックスと PostgreSQL の違い、EXPLAIN の読み方まで、PostgreSQL・MySQL 公式と Use The Index, Luke を一次ソースにまとめます。