ファントムリードの「複数行のある集合」って何?実験して確かめてみた
はじめに
業務で同僚とPostgreSQLのトランザクション分離レベルについて話していたときのこと。公式ドキュメントのファントムリードの定義を見て、ふと疑問が浮かびました。
トランザクションが、複数行のある集合を返す検索条件で問い合わせを再実行した時、別のトランザクションがコミットしてしまったために、同じ検索条件で問い合わせを実行しても異なる結果を得てしまう。
― PostgreSQL 9.4 ドキュメント(最新の17でも同じ記述)
「これって複数行の場合だけってこと?」
「いや、そんなわけないよね...?」
「じゃあ0行から1行になってもファントムリードなの?」
「そうだと思うけど...」
「複数行のある集合」という表現が、2行以上必要なのか、それとも単に「行の集合」を意味しているのか曖昧でした。
ドキュメントを読むだけでは確信が持てなかったので、実際にPostgreSQLでファントムリードを再現して検証することにしました。
ファントムリードとは
まず基本的な理解から。トランザクション分離レベルで発生する3つの現象:
| 現象 | 原因 | 何が変わる |
|---|---|---|
| ダーティリード | 未コミット | 他のトランザクションの未コミットデータを読む |
| 反復不能読み取り | UPDATE | 同じ行の値が変わる |
| ファントムリード | INSERT/DELETE | 結果セットの行数が変わる |
ファントムリードの特徴:
- 同じ検索条件で2回クエリを実行
- 1回目と2回目で行の数が変わる
- 幽霊(phantom)のように行が出現・消失する
実験環境
UFO目撃情報データベースを使って検証します。
(なぜUFO?それは、ドラマ「X-ファイル」のテーマソングがB'zの「LOVE PHANTOM」だったからです。)
CREATE TABLE ufo_sightings (
id SERIAL PRIMARY KEY,
sighting_date DATE,
location VARCHAR(100),
shape VARCHAR(50),
color VARCHAR(50),
duration_minutes INT,
witness_count INT,
credibility_score DECIMAL(3, 2)
);
INSERT INTO ufo_sightings (sighting_date, location, shape, color, duration_minutes, witness_count, credibility_score) VALUES
('2024-01-15', 'Roswell, New Mexico', 'disk', 'silver', 5, 3, 0.75),
('2024-02-03', 'Area 51, Nevada', 'disk', 'metallic', 12, 7, 0.82),
('2024-03-22', 'Phoenix, Arizona', 'disk', 'glowing-white', 8, 2, 0.65);
実験の前提
すべての実験はREAD COMMITTED分離レベルで行います。これはPostgreSQLのデフォルトの分離レベルで、ファントムリードが発生します。
| 分離レベル | ファントムリード |
|---|---|
| READ UNCOMMITTED | 発生する |
| READ COMMITTED | 発生する ← 今回の実験 |
| REPEATABLE READ | 発生しない(PostgreSQLの実装では) |
| SERIALIZABLE | 発生しない |
実験1: 基本パターン(3行→4行)
まずは「複数行」のパターンで検証。
-- ターミナル1
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 円盤型UFOを検索
SELECT * FROM ufo_sightings WHERE shape = 'disk' ORDER BY sighting_date;
結果: 3行
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
----+---------------+-----------------------+-------+----------------+------------------+---------------+-------------------
1 | 2024-01-15 | Roswell, New Mexico | disk | silver | 5 | 3 | 0.75
2 | 2024-02-03 | Area 51, Nevada | disk | metallic | 12 | 7 | 0.82
3 | 2024-03-22 | Phoenix, Arizona | disk | glowing-white | 8 | 2 | 0.65
-- ターミナル2
BEGIN;
INSERT INTO ufo_sightings (sighting_date, location, shape, color, duration_minutes, witness_count, credibility_score)
VALUES ('2024-04-05', 'Los Angeles, California', 'disk', 'bright-red', 25, 15, 0.91);
COMMIT;
-- ターミナル1(続き)
-- 同じクエリを再実行
SELECT * FROM ufo_sightings WHERE shape = 'disk' ORDER BY sighting_date;
結果: 4行(新しいUFOが出現!)
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
----+---------------+---------------------------+-------+----------------+------------------+---------------+-------------------
1 | 2024-01-15 | Roswell, New Mexico | disk | silver | 5 | 3 | 0.75
2 | 2024-02-03 | Area 51, Nevada | disk | metallic | 12 | 7 | 0.82
3 | 2024-03-22 | Phoenix, Arizona | disk | glowing-white | 8 | 2 | 0.65
7 | 2024-04-05 | Los Angeles, California | disk | bright-red | 25 | 15 | 0.91 ← 幽霊UFOが出現!
👻 ファントムリード発生!
実験2: 0行→1行パターン
ここからが本題です。「複数行のある集合」を文字通り受け取ると、最初が0行ならファントムリードにならない?
-- ターミナル1
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM ufo_sightings WHERE shape = 'unknown';
結果: 0行(空の結果)
-- ターミナル2
BEGIN;
INSERT INTO ufo_sightings (sighting_date, location, shape, color, duration_minutes, witness_count, credibility_score)
VALUES ('2024-05-01', 'Denver, Colorado', 'unknown', 'flickering', 2, 1, 0.30);
COMMIT;
-- ターミナル1で再実行
SELECT * FROM ufo_sightings WHERE shape = 'unknown';
結果: 1行(幽霊UFOが出現!)
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
----+---------------+---------------------+---------+-------------+------------------+---------------+-------------------
7 | 2024-05-01 | Denver, Colorado | unknown | flickering | 2 | 1 | 0.30
👻 ファントムリード発生!
実験3: 1行→2行パターン
-- ターミナル1
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM ufo_sightings WHERE shape = 'triangle';
結果: 1行
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
----+---------------+---------------------+----------+-------+------------------+---------------+-------------------
4 | 2024-01-28 | Seattle, Washington | triangle | black | 3 | 1 | 0.45
-- ターミナル2
BEGIN;
INSERT INTO ufo_sightings (sighting_date, location, shape, color, duration_minutes, witness_count, credibility_score)
VALUES ('2024-05-15', 'Chicago, Illinois', 'triangle', 'dark-gray', 10, 4, 0.67);
COMMIT;
-- ターミナル1で再実行
SELECT * FROM ufo_sightings WHERE shape = 'triangle';
結果: 2行(新しいUFOが出現!)
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
----+---------------+---------------------+----------+-----------+------------------+---------------+-------------------
4 | 2024-01-28 | Seattle, Washington | triangle | black | 3 | 1 | 0.45
8 | 2024-05-15 | Chicago, Illinois | triangle | dark-gray | 10 | 4 | 0.67
👻 ファントムリード発生!
実験4: 主キー検索でも
主キー検索なら大丈夫?
ここで主キー検索を試す意図は、1回目の結果が必ず0行または1行になるという点です。主キーの性質上、複数行が返ることはありません。これによって「複数行のある集合」が本当に2行以上を意味するのか、より厳密に検証できます。
-- ターミナル1
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM ufo_sightings WHERE id = 999;
結果: 0行(空の結果)
-- ターミナル2
BEGIN;
INSERT INTO ufo_sightings (id, sighting_date, location, shape, color, duration_minutes, witness_count, credibility_score)
VALUES (999, '2024-06-01', 'New York, New York', 'disk', 'silver', 30, 20, 0.95);
COMMIT;
-- ターミナル1で再実行
SELECT * FROM ufo_sightings WHERE id = 999;
結果: 1行(主キー検索でも出現!)
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
-----+---------------+----------------------+-------+--------+------------------+---------------+-------------------
999 | 2024-06-01 | New York, New York | disk | silver | 30 | 20 | 0.95
👻 ファントムリード発生!
実験5: DELETE(1行→0行)
-- ターミナル1
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM ufo_sightings WHERE id = 1;
結果: 1行
id | sighting_date | location | shape | color | duration_minutes | witness_count | credibility_score
----+---------------+---------------------+-------+--------+------------------+---------------+-------------------
1 | 2024-01-15 | Roswell, New Mexico | disk | silver | 5 | 3 | 0.75
-- ターミナル2
BEGIN;
DELETE FROM ufo_sightings WHERE id = 1;
COMMIT;
-- ターミナル1で再実行
SELECT * FROM ufo_sightings WHERE id = 1;
結果: 0行(UFOが消えた!)
👻 ファントムリード発生!
英語の原文を確認
PostgreSQL公式ドキュメント(英語)の定義:
phantom read
A transaction re-executes a query returning a set of rows that satisfy a search condition and finds that the set of rows satisfying the condition has changed due to another recently-committed transaction.
"a set of rows" = 「行の集合」
英語では:
- "rows" は複数形だが、これは文法上の複数形
- 英語では不特定・一般的な場合に複数形を使う
- 例: "UFO sightings in Nevada"(目撃情報が0件でも1件でも複数形)
- 例: "a collection of alien artifacts"(アーティファクトが何個あっても複数形の "artifacts")
- 「2行以上」という意味ではない
- この集合は0個、1個、複数個のいずれもありえる
DeepL翻訳と比較
DeepLで翻訳すると:
トランザクションが検索条件を満たす行の集合を返すクエリを再実行し、最近コミットされた別のトランザクションによって条件を満たす行の集合が変更されたことを検出する。
「行の集合」とシンプルに訳されています。
結論
「複数行のある集合」の意味
PostgreSQL日本語ドキュメントの「複数行のある集合」という表現は、英語の "a set of rows" を訳したものです。
- 英語の "rows" は文法上の複数形(不特定の場合に使う)
- この集合は0個、1個、複数個のいずれの要素数もありえる
- 日本語で「複数行」と表現されているが、「2行以上必要」という意味ではない
- 英語原文では単に「行の集合」という意味
ファントムリードの本質
実験結果から明らかになったこと:
- ✅ 0行→1行でもファントムリードは発生する
- ✅ 1行→2行でもファントムリードは発生する
- ✅ 主キー検索でもファントムリードは発生する
- ✅ DELETEで1行→0行でもファントムリードは発生する
ファントムリードは「検索条件を満たす行の集合のメンバーシップが変化する現象」であり、最初の結果が何行であるかは関係ありません。
実験してよかったこと
ドキュメントを読むだけでなく、実際に手を動かして検証することで:
- 「複数行のある集合」という表現の真の意味を理解できた
- 英語原文と比較することで、言語の違いによるニュアンスの差を発見できた
- 0行、1行、複数行のすべてのパターンで確認できた
- UFOデータで楽しく学べた👽
参考文献
リポジトリ
本記事の実験環境(Docker環境、SQLファイル、ドキュメント)は以下で公開しています:
ぜひクローンして、自分の手でファントムリードを再現してみてください!
Discussion