データベースにおけるトランザクションは、一連の操作を一つの論理的な単位として扱い、すべて成功するか、すべて失敗するかのいずれかであることを保証する仕組みです。複数のトランザクションが同時に実行される環境では、データの整合性を保つために**分離レベル(Isolation Level)**が重要になります。
本記事では、4 つの標準分離レベルが防ぐ不整合を定義したうえで、実際に PostgreSQL 16 の 2 セッションを同時に動かし、ダーティリード・ノンリピータブルリード・ファントムリードが「本当に起きるか/起きないか」を実測します。さらに、SERIALIZABLE でさえ見逃されがちな**ライトスキュー(write skew)**という不整合も、同じ手法で実測します。分離レベルはデータベース製品ごとに解釈・実装が異なるため、本記事の実測部分はすべて PostgreSQL 16.14 での検証結果であることを明記し、他製品(MySQL 等)の挙動については公式ドキュメントを根拠として引用したうえで「未実測・ドキュメントに基づく記述」であることを明示します。
データベースのトランザクション処理で問題となる不整合
複数のトランザクションが並行して動作する際に発生しうる代表的なデータ不整合の現象です。ANSI/ISO SQL 標準(および Berenson らによる古典的な批判論文 “A Critique of ANSI SQL Isolation Levels”, 1995)で整理されている 3 つに加えて、標準の枠組みだけでは捉えきれないライトスキューを 4 つ目として扱います。
1. ダーティリード (Dirty Read)
- 現象: あるトランザクション A が、別のトランザクション B によって変更されたが、まだコミットされていないデータを読み取ってしまう現象です。
- 問題点: もし B が後でロールバックされた場合、A が読み取ったデータは実際には存在しない無効なデータとなり、データの信頼性が損なわれます。
2. ノンリピータブルリード (Non-repeatable Read)
- 現象: あるトランザクション A が同じ行を複数回読み取る際に、その間に別のトランザクション B によってその行が更新(または削除)され、異なる結果が返される現象です。
- 問題点: トランザクション A 内で一貫性のないデータが提供されることになり、処理結果の正確性が低下します。
3. ファントムリード (Phantom Read)
- 現象: 特定の条件でクエリを実行して結果セットを得た後、同じクエリを再実行した際に、その間に別のトランザクションによって条件に合致する新たな行が追加されたり、既存の行が削除されたりして、異なる結果セットが返される現象です。
- 問題点: 集計処理や条件に基づくデータ操作で予期せぬ結果を招く可能性があります。ノンリピータブルリードが「既存の行の値」の不整合であるのに対し、ファントムリードは「行の集合そのもの」の不整合である点が異なります。
4. ライトスキュー (Write Skew)
- 現象: 2 つのトランザクション A・B が、それぞれ別の行に書き込むために、共通の条件(不変条件)を読み取って判断するが、互いの書き込みが完了した後の状態を考慮せずに、両方とも「自分の変更なら問題ない」と判断してコミットしてしまう現象です。A の書き込み先と B の書き込み先が異なる行であるため、行レベルのロックや MVCC の競合検出では捕捉されません。
- 具体例: 病院の当直表で「常に 1 人以上の医師がオンコール状態でなければならない」という不変条件があるとします。医師 Alice と Bob の 2 人がオンコール中に、2 人がほぼ同時に「もう 1 人がオンコールなら自分は外れてよい」と判断してそれぞれ独立したトランザクションでオンコールを解除すると、両者とも「もう 1 人がオンコールである」ことを確認したうえでコミットに成功し、結果として誰もオンコールでない状態が生まれます。
- 問題点: 古典的な 3 つの不整合を防ぐだけの実装(特にスナップショット分離ベースの
REPEATABLE READ、および実装によってはSERIALIZABLEを名乗るスナップショット分離)では検出できません。真の直列化可能性(true serializability)を保証する実装でなければ防げない、より subtle な不整合です。この記事の後半で実際に PostgreSQL 上で再現・検証します。
トランザクションの分離レベル
ANSI/ISO SQL 標準では、これらの不整合を防ぐために、以下の 4 つの分離レベルが定義されています。分離レベルが上がるほど防げる不整合の種類は増えますが、並行性が低下し、パフォーマンスへの影響が大きくなります。ここで示すのは SQL 標準が要求する「最低限の保証」であり、実際のデータベース製品はこれより厳しく(あるいは緩く)実装している場合があります(脚注 *1, *2 を参照。詳細は後述の実測セクションで検証します)。
| 分離レベル | ダーティリード | ノンリピータブルリード | ファントムリード |
|---|---|---|---|
READ UNCOMMITTED | 発生しうる | 発生しうる | 発生しうる |
READ COMMITTED | 防げる *1 | 発生しうる | 発生しうる |
REPEATABLE READ | 防げる | 防げる | 発生しうる *2 |
SERIALIZABLE | 防げる | 防げる | 防げる |

1. READ UNCOMMITTED
最も低い分離レベルです。標準上はダーティリード・ノンリピータブルリード・ファントムリードのすべてが発生しうるとされています。パフォーマンスは最も高いですが、データの一貫性はほとんど保証されません。
2. READ COMMITTED
他のトランザクションがコミットした後のデータのみを読み取ります。これにより、未コミットのデータによるダーティリードは防げます。しかし、文ごとに最新のコミット済みスナップショットを取得し直すため、ノンリピータブルリードやファントムリードは発生しえます。多くのデータベースシステムでデフォルトの分離レベルとして採用されています(PostgreSQL・Oracle・SQL Server の既定値)。
3. REPEATABLE READ
トランザクションが開始された時点で読み取ったデータは、そのトランザクションが終了するまで他のトランザクションによる変更から保護されます。これにより、同じ行の再読み取りが一貫します(ノンリピータブルリードを防ぎます)。SQL 標準はこのレベルでのファントムリードの発生を許容していますが、後述するように PostgreSQL や MySQL(InnoDB) の実装ではファントムリードも(多くのケースで)防がれます。実装依存の挙動なので、移植性が必要なアプリケーションでは標準の最低保証を前提に設計すべきです。
4. SERIALIZABLE
最も厳格な分離レベルです。トランザクションが他のトランザクションとは完全に独立して(直列に)実行されたかのように動作することを保証し、上記 3 つの不整合をすべて防ぎます。ただし「SERIALIZABLE を名乗ること」と「真に直列化可能であること」はイコールではありません。Oracle や(PostgreSQL 9.0 以前を含む)多くの実装は、内部的にはスナップショット分離(Snapshot Isolation)で SERIALIZABLE を実装しており、この場合ライトスキューは防げません。PostgreSQL は 2011 年リリースの 9.1 以降、**Serializable Snapshot Isolation(SSI)**というアルゴリズムを実装しており、ライトスキューを含む真の直列化可能性を提供します。これも後段で実測します。
SNAPSHOT 分離レベル(一部の DB システムで提供)
SERIALIZABLE と同等以上の並行性を実現するために、一部のデータベースシステム(例: SQL Server, Oracle)で SNAPSHOT として単独提供される分離レベルです。トランザクション開始時点のスナップショットに対して操作を行い、他トランザクションのコミットの影響を受けません。ただし前述の通り、スナップショット分離単体ではライトスキューを防げない点に注意が必要です。
検証環境
以降の実測は、Homebrew でインストールした PostgreSQL 16.14(macOS / aarch64、ローカル環境)に対して、2 つの psql セッションを名前付きパイプ経由で同時に起動し、片方のコマンドを実行 → 完了を確認 → もう片方のコマンドを実行、という形で明示的にインターリーブ順序を制御しながら実行しています。表示する SQL・実行結果はすべて実際にこの環境で得られた実測値であり、「典型的にはこうなるはず」という推測や創作ではありません。MySQL や Oracle はこのサンドボックス環境で利用できなかったため、それらの挙動については公式ドキュメントを明示的に引用し、実測と区別して記載します。
実測 1: ダーティリードは本当に起きるか(READ UNCOMMITTED)
accounts(id, balance) テーブルに (1, 1000) を用意し、セッション A で READ UNCOMMITTED を明示的に指定したうえで、セッション B が未コミットの更新を行っている最中に A が同じ行を読めるかを確認します。
| 時刻 | Session A | Session B |
|---|---|---|
| 1 | BEGIN;SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; | |
| 2 | SELECT id, balance FROM accounts WHERE id = 1; → 1000 | |
| 3 | BEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;(未コミット) | |
| 4 | SELECT id, balance FROM accounts WHERE id = 1; (B が未コミットの間に再読み取り) | |
| 5 | ROLLBACK; | |
| 6 | COMMIT; |
実際の実行結果(手順 4 の実測値)です。
transaction_isolation
------------------------
read uncommitted
(1 行)
id | balance
----+---------
1 | 1000
(1 行) -- 手順2: 最初の読み取り
id | balance
----+---------
1 | 1000 -- 手順4: Bが未コミットで変更中でも 1000 のまま
(1 行)
SHOW transaction_isolation; は read uncommitted を返しているにもかかわらず、B が未コミットの balance = 900 をまったく読み取れていません。これは PostgreSQL の公式ドキュメントに明記されている仕様どおりの結果です。PostgreSQL は MVCC(多版型同時実行制御)によってどの分離レベルでも未コミットの行バージョンを他トランザクションに公開しないため、SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED を指定しても内部的には READ COMMITTED として振る舞い、ダーティリードは構造的に発生しません(
PostgreSQL: Documentation 13.2. Transaction Isolation
)。これは前掲の表で READ UNCOMMITTED 行に付けた脚注 *1 の実測による裏付けです。
実測 2: ノンリピータブルリード(READ COMMITTED vs REPEATABLE READ)
同じ行を同一トランザクション内で 2 回読み、その間に別トランザクションがコミットした場合の挙動です。balance を 1000 にリセットしてから実行しています。
READ COMMITTED(実測)
| 時刻 | Session A | Session B |
|---|---|---|
| 1 | BEGIN;SELECT balance FROM accounts WHERE id=1; → 1000 | |
| 2 | BEGIN; UPDATE accounts SET balance = balance - 200 WHERE id=1; COMMIT;(800 に更新してコミット) | |
| 3 | SELECT balance FROM accounts WHERE id=1;(同一トランザクション内で再読み取り) | |
| 4 | COMMIT; |
balance
---------
1000 -- 手順1: 最初の読み取り
(1 行)
balance
---------
800 -- 手順3: 同一トランザクション内での再読み取り。値が変わっている
(1 行)
同一トランザクション A の中で、2 回目の SELECT が B のコミット結果(800)を反映しています。これがノンリピータブルリードです。READ COMMITTED は文(ステートメント)ごとに最新のコミット済みスナップショットを取り直すため、この挙動は仕様どおりです。
REPEATABLE READ(実測、balance を 1000 にリセットして再実行)
balance
---------
1000 -- 手順1: 最初の読み取り
(1 行)
balance
---------
1000 -- 手順3: 同一トランザクション内での再読み取り。Bはコミット済みだが値は変わらない
(1 行)
まったく同じ手順を REPEATABLE READ で実行すると、B が UPDATE ... COMMIT を実行したにもかかわらず、A の 2 回目の読み取りは最初と同じ 1000 のままです。REPEATABLE READ はトランザクション開始時点のスナップショットを維持するため、ノンリピータブルリードが防がれることが実測で確認できます。
実測 3: ファントムリード(READ COMMITTED vs REPEATABLE READ)
WHERE balance > 400 という条件の COUNT を同一トランザクション内で 2 回実行し、その間に条件に合致する新しい行が挿入された場合の挙動です。
READ COMMITTED(実測)
| 時刻 | Session A | Session B |
|---|---|---|
| 1 | BEGIN;SELECT count(*) FROM accounts WHERE balance>400; → 2 | |
| 2 | BEGIN; INSERT INTO accounts VALUES (3, 900); COMMIT; | |
| 3 | SELECT count(*) FROM accounts WHERE balance>400;(再実行) | |
| 4 | COMMIT; |
count
-------
2 -- 手順1: 最初のカウント
(1 行)
count
-------
3 -- 手順3: Bの挿入がコミット済みで反映され、件数が増えている
(1 行)
2 回目のカウントが 2 から 3 に増えており、ファントムリードが実際に発生しています。
REPEATABLE READ(実測、挿入した行を削除してから再実行)
count
-------
2 -- 手順1: 最初のカウント
(1 行)
count
-------
2 -- 手順3: Bが同じ行を挿入・コミットしても件数は増えない
(1 行)
まったく同じ手順を REPEATABLE READ で実行すると、B が新しい行をコミットしても A から見える件数は 2 のまま変わりません。これは前掲の脚注 *2 で述べたとおり、PostgreSQL の REPEATABLE READ は SQL 標準の最低保証を上回り、ファントムリードも実質的に防いでいることを実測で示しています。PostgreSQL のドキュメントも「PostgreSQL の Repeatable Read はスナップショット分離として実装されており、標準がこのレベルで許容するファントムリードなどの現象を追加で防止する」と明記しています(
PostgreSQL: Documentation 13.2. Transaction Isolation
)。
実測 4: ライトスキュー(REPEATABLE READ vs SERIALIZABLE)
doctors(id, name, on_call) テーブルに Alice・Bob の 2 人を on_call = true で用意し、「オンコール中の医師は 1 人以上」という不変条件を、それぞれ独立したトランザクションで守ろうとするシナリオです。
REPEATABLE READ では防げない(実測)
| 時刻 | Session A(Alice のトランザクション) | Session B(Bob のトランザクション) |
|---|---|---|
| 1 | BEGIN;SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;SELECT count(*) FROM doctors WHERE on_call; → 2 | |
| 2 | BEGIN; SET ... REPEATABLE READ;SELECT count(*) FROM doctors WHERE on_call; → 2 | |
| 3 | UPDATE doctors SET on_call=false WHERE name='Alice';COMMIT;(成功) | |
| 4 | UPDATE doctors SET on_call=false WHERE name='Bob';COMMIT;(成功) |
両トランザクションとも「オンコール中の医師は 2 人(自分を含む)」というスナップショットを見て、「もう 1 人が残るなら自分は外れてよい」と判断し、実際にどちらも COMMIT に成功します。最終状態は次のとおりです(実測)。
id | name | on_call
----+-------+---------
1 | Alice | f
2 | Bob | f
(2 行)
2 人ともオンコールから外れてしまいました。 REPEATABLE READ(PostgreSQL ではスナップショット分離)は、A と B がそれぞれ別の行を書き込んでいるため行レベルの競合を検出できず、「1 人以上がオンコール」という不変条件がすり抜けています。これがライトスキューです。
SERIALIZABLE(PostgreSQL の SSI)は検出して片方を中断させる(実測)
on_call を両者とも true に戻し、まったく同じシナリオを SERIALIZABLE で実行します。
| 時刻 | Session A | Session B |
|---|---|---|
| 1 | BEGIN; SET ... SERIALIZABLE;SELECT count(*) ... on_call; → 2 | |
| 2 | BEGIN; SET ... SERIALIZABLE;SELECT count(*) ... on_call; → 2 | |
| 3 | UPDATE ... WHERE name='Alice';(コミットせず保留) | |
| 4 | UPDATE ... WHERE name='Bob';(コミットせず保留) | |
| 5 | COMMIT; | |
| 6 | COMMIT; |
実際の実行結果です。
-- Session A の COMMIT
COMMIT
-- Session B の COMMIT
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
Session A の COMMIT は成功しますが、Session B の COMMIT は SQLSTATE 40001(serialization_failure) で失敗します。最終状態は次のとおりです。
id | name | on_call
----+-------+---------
1 | Alice | f
2 | Bob | t
(2 行)
今度は Bob のオンコールが維持され、不変条件が守られました。PostgreSQL の SERIALIZABLE は 2011 年の 9.1 以降、**Serializable Snapshot Isolation(SSI)**というアルゴリズムを使い、スナップショット分離の上に「危険な依存関係(rw-antidependency の連鎖)」の検出を追加することで、追加のロックなしに真の直列化可能性を実現しています(
Ports & Grittner, “Serializable Snapshot Isolation in PostgreSQL”, VLDB 2012
/
PostgreSQL Wiki: SSI
)。
ここで重要な注意点があります。すべての「SERIALIZABLE を名乗る実装」がこの保証を提供するわけではありません。Oracle の SERIALIZABLE は内部的にはスナップショット分離であり、SSI のような依存関係検出を行わないため、上記と同じシナリオでライトスキューを防げません。「SERIALIZABLE と設定名にあるから安全」と早合点せず、使用する製品のドキュメントで実装方式を確認する必要があります。
MySQL の REPEATABLE READ について(ドキュメントに基づく記述・未実測)
このサンドボックス環境には MySQL がインストールされていなかったため、以下は公式ドキュメントに基づく記述であり、本記事の他のセクションのような実測結果ではありません。
MySQL(InnoDB) の REPEATABLE READ は、通常の(ロックを伴わない)SELECT に対してはスナップショットベースの MVCC のみで動作しますが、SELECT ... FOR UPDATE や UPDATE/DELETE のようなロックを伴う読み取りに対しては、インデックス範囲全体をロックするネクストキーロック(next-key lock)= レコードロック+ギャップロックという機構を追加で使います。ギャップロックは、既存レコードの間の「隙間」への INSERT をブロックするため、ロックを伴うスキャンに関しては多くのケースでファントムリードが防止されます(
MySQL 8.4 Reference Manual: 17.7.2.1 Transaction Isolation Levels
/
MySQL 8.4 Reference Manual: 17.7.4 Phantom Rows
)。
ただし公式ドキュメントも明記しているとおり、これはロックを伴う読み取りに限った挙動です。ロックを伴わない通常の SELECT は純粋な MVCC スナップショットに従うため、PostgreSQL の REPEATABLE READ と同様にファントムリードは(別の理由で)観測されにくくなりますが、メカニズムそのもの(スナップショット分離 vs ロック)は PostgreSQL とは異なります。移植性が必要なアプリケーションでは、この実装差を前提にせず、必要な保証は明示的なロック(SELECT ... FOR UPDATE)やアプリケーション側の再検証で担保するのが安全です。
分離レベルを上げることのコスト
分離レベルは「上げれば上げるほど安全」ではありますが、「とりあえず一番厳しいレベルにしておけば良い」という単純な話でもありません。
- ロック競合とブロッキングの増加:
REPEATABLE READやSERIALIZABLEでは、読み取ったデータの一貫性を保証するためにロック保持期間が長くなったり(ロックベースの実装の場合)、競合検出の対象範囲が広がったりします。同時実行数が多いワークロードではブロッキングやデッドロックの頻度が上がり、スループットが低下します。 SERIALIZABLEではリトライが実質必須: 上記の実測で見たとおり、PostgreSQL の SSI は競合を検出すると40001エラーでトランザクションを中断させます。これは「バグ」ではなく仕様であり、SERIALIZABLEを使うアプリケーションはシリアライズ失敗を検知して自動的にトランザクションをリトライするロジックを実装しておく必要があります。このリトライ処理を省略すると、ユーザーにエラーがそのまま露出してしまいます。- 性能への影響は実装依存: PostgreSQL の SSI を提案した論文(Ports & Grittner, VLDB 2012)では、スナップショット分離に対する SSI のオーバーヘッドは多くのワークロードで 7% 未満と報告されており、従来の 2 相ロック方式の
SERIALIZABLE実装よりも大幅に高速なケースが多いとされています。とはいえこれは PostgreSQL の実装に関する数値であり、他製品でそのまま成り立つとは限りません。 - 実務上の指針: 多くのアプリケーションはデフォルトの
READ COMMITTED(またはREPEATABLE READ)で十分に動作し、金額の二重計上や在庫のマイナス在庫のような「守るべき不変条件」が明確な処理にだけSERIALIZABLE+ リトライループ、あるいは明示的なロック(SELECT ... FOR UPDATE)やアプリケーションレベルの制約(一意制約、CHECK制約、排他制約)を使う、という使い分けが現実的です。
最近の動向
PostgreSQL の SERIALIZABLE(SSI)は 2011 年の 9.1 で導入されて以来、基本的なアルゴリズムは変わっていません。本記事の検証に使った PostgreSQL 16(および 2025 年にリリースされた最新の PostgreSQL 18)でも、transaction-iso ドキュメントに記載されている分離レベルの定義・実装方式(READ UNCOMMITTED → READ COMMITTED への読み替え、REPEATABLE READ/SERIALIZABLE のスナップショット分離ベースの実装)に変更はありません。ライトスキューという不整合の概念自体は新しいものではなく、SSI 論文をはじめとする学術的な議論を通じて 2000 年代後半〜2010 年代前半に広く認知されるようになり、今日では Martin Kleppmann の Designing Data-Intensive Applications などのテキストでも標準的なトピックとして扱われています。一方で Oracle のようにスナップショット分離ベースの SERIALIZABLE を採用し続けている製品も存在するため、「SERIALIZABLE という名前」だけで安全性を判断せず、対象の DB エンジンが SSI 相当の依存関係検出を実装しているかをドキュメントで確認する習慣が引き続き重要です。
まとめ
- SQL 標準で定義される不整合は ダーティリード・ノンリピータブルリード・ファントムリード の 3 つで、標準の 4 分離レベルはこれらをどこまで防ぐかで定義される
- 実測の結果、PostgreSQL には真の
READ UNCOMMITTEDが存在せず、常にREAD COMMITTEDとして動作するため、ダーティリードは(どの分離レベルを指定しても)実際には発生しない - 実測の結果、PostgreSQL の
REPEATABLE READは SQL 標準の最低保証を超え、ファントムリードも実質的に防ぐ(スナップショット分離であるため) - ライトスキューは「別々の行を書き込む 2 つのトランザクションが共通の不変条件を独立に確認する」ことで発生する、古典的な 3 分類には収まらない不整合であり、
REPEATABLE READ(スナップショット分離)では防げないことを、当直医の例で実測した - PostgreSQL の
SERIALIZABLE(SSI, 2011 年〜)はライトスキューを検出し、片方のトランザクションを40001エラーで中断させることで防ぐ。これも実測で確認した - すべての「
SERIALIZABLE」実装が同じ保証を提供するわけではなく、Oracle のように内部的にスナップショット分離のままの実装も存在する - 分離レベルを上げることは無償ではなく、ブロッキングの増加や
SERIALIZABLEでのリトライ実装の必要性というコストを伴うため、不変条件の重要度に応じて使い分けるのが実務的である - MySQL(InnoDB) の
REPEATABLE READはギャップロックによってロックを伴う読み取りのファントムリードを防ぐが、これは PostgreSQL とは異なる機構であり、本記事内では実測ではなく公式ドキュメントに基づく記述である
参考文献
- PostgreSQL: Documentation 13.2. Transaction Isolation
- PostgreSQL: Documentation, SET TRANSACTION
- PostgreSQL Wiki: Serializable Snapshot Isolation (SSI)
- Ports, D. R. K., & Grittner, K. (2012). “Serializable Snapshot Isolation in PostgreSQL.” Proceedings of the VLDB Endowment, 5(12), 1850-1861. arXiv:1208.4179
- Berenson, H., Bernstein, P., Gray, J., Melton, J., O’Neil, E., & O’Neil, P. (1995). “A Critique of ANSI SQL Isolation Levels.” ACM SIGMOD Record, 24(2), 1-10. arXiv:cs/0701157
- MySQL 8.4 Reference Manual: 17.7.2.1 InnoDB Transaction Isolation Levels
- MySQL 8.4 Reference Manual: 17.7.4 Phantom Rows
- Kleppmann, M. (2017). Designing Data-Intensive Applications. O’Reilly Media.(Chapter 7: Transactions で write skew を含む分離レベルの詳細な議論あり)
- Prisma ORM: Transactions
- トランザクション分離レベル - Wikipedia
関連ツール
- SQLフォーマッター(DevToolBox) - SQL文の整形・フォーマットツール