はじめに
PostgreSQLの REPEATABLE READ は非常に優秀で、SQL標準で定義されている「ダーティリード」「ノンリピータブルリード」「ファントムリード」の3大異常現象をすべて防いでくれます。
「じゃあ、最高レベルの SERIALIZABLE はいらないのでは?」
いいえ、そんなことはありません。REPEATABLE READでも防げない、複数のトランザクションが複雑に絡み合った時に起こる「ライトスキュー(Write Skew)」という最後の異常現象が存在します。
この記事では、ライトスキューの恐ろしさと、SERIALIZABLEがそれをどう防ぐのかをDockerを使って実証します。
1. ライトスキュー(Write Skew)とは何か?
一言でいうと、「2つのトランザクションが、お互いに『相手の古いデータ』を見た上で『別々の行』を更新した結果、全体のルールがぶっ壊れる現象」です。
病院の当番システムの例
病院には「アリス」と「ボブ」の2人の当番医がいます。
ルール:「当番(ON)は、常に最低1人はいないといけない」
ある日、アリスとボブが「2人ともON」の状態でした。
ここで、2人が「今日休みたいな…」と思い、同時にシステムを操作した時のタイムラインを見てみましょう。
なぜREPEATABLE READでは防げないのか?
以前の「同時更新エラー」の時は、AとBが「同じ行(同じ口座)」を更新しようとしたからPostgresが弾いてくれました。
しかし今回は、アリスは「アリスの行」を、ボブは「ボブの行」を更新しています。別々の行を更新しているため、行ロック(コンフリクト)が発生せず、両方とも成功してしまうのです。これがライトスキューです。
2. ハンズオン準備(Docker Compose)
実際にDocker環境で、この悲劇を再現してみましょう。
(※すでに前回のコンテナが動いている場合は、そのまま接続してください)
docker compose exec db psql -U user -d testdb
-- 病院の当番テーブルを作成
CREATE TABLE doctors (
id INT PRIMARY KEY,
name VARCHAR(50),
on_call BOOLEAN
);
-- アリスとボブを当番(true)として登録
INSERT INTO doctors VALUES (1, 'Alice', true);
INSERT INTO doctors VALUES (2, 'Bob', true);
3. 実験①:REPEATABLE READの悲劇
ターミナルを2つ(Terminal A, B)並べて開き、同時に操作します。
Step 1: 両方でトランザクションを開始し、状況を確認
-- Terminal A (アリス)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true;
-- 結果: 2
-- Terminal B (ボブ)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true;
-- 結果: 2
Step 2: それぞれが「相手がいるから大丈夫」と判断して自分を休みにする
-- Terminal A (アリス)
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- UPDATE 1 (成功)
-- Terminal B (ボブ)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
-- UPDATE 1 (成功)
Step 3: 両方ともコミット
-- Terminal A
COMMIT;
-- Terminal B
COMMIT;
Step 4: 最終確認
SELECT * FROM doctors;
-- Alice: f (false)
-- Bob: f (false)
-- ★ルール崩壊!誰も病院にいなくなりました!
4. 実験②:SERIALIZABLEが世界を救う
データを元に戻して、PostgreSQLの究極の防衛線「SERIALIZABLE」の力を試してみましょう!
-- 事前準備:2人を当番に戻す
UPDATE doctors SET on_call = true;
Step 1: 両方で SERIALIZABLE をセットして開始
-- Terminal A (アリス)
BEGIN;
-- ★最高峰 SERIALIZABLE にする
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true; -- 結果: 2
-- Terminal B (ボブ)
BEGIN;
-- ★最高峰 SERIALIZABLE にする
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true; -- 結果: 2
Step 2: それぞれ自分を休みにする
-- Terminal A (アリス)
UPDATE doctors SET on_call = false WHERE name = 'Alice';
-- Terminal B (ボブ)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
ここまでは両方ともエラーなく進行します。
Step 3: 運命のコミット
アリスが先にコミットし、次にボブがコミットしようとすると……
-- Terminal A (アリス)
COMMIT; -- (成功)
-- Terminal B (ボブ)
COMMIT;
ここで Terminal B に強烈なエラーが発生します!
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.
(翻訳:「トランザクション間の読み書きの依存関係がおかしいので、直列化(シリアライズ)できませんでした。エラーにします!」)
PostgreSQLは、「別々の行」の更新であっても、「お互いが読んだデータをもとに更新し合っている(依存関係の交差)」ことを裏で完璧に検知し、矛盾が起きる前にボブのコミットを強制的に弾き飛ばしました。
結果として、アリスだけが休みになり、ボブは当番として残るため、病院のルールが守られました!
5. まとめ
-
REPEATABLE READ の限界:
自分以外の行を更新し合うような複雑なケース(ライトスキュー)では、行ロックが効かず、データの矛盾(ルール違反)が起きてしまう。 -
SERIALIZABLE の役割:
すべてのトランザクションを監視し、「もし1人ずつ順番(直列)に処理していたら、絶対にこんな結果にはならないよね?」という矛盾のニオイを検知した瞬間、エラーを出して処理を弾く究極の安全装置。 -
実務での使い分け:
SERIALIZABLE は安全ですが、エラーで弾かれる確率が高く、システムの処理速度が落ちます。基本はREAD COMMITTEDを使い、シフト管理や高度な在庫引当など「複数のデータ条件が絡む厳密なルール」がある処理にだけSERIALIZABLEを使うのがプロの設計です。