在庫チェックしてから1つ引いただけなのに、なぜ在庫が負になるのか?
「在庫を確認してから減らす」処理を BEGIN〜COMMIT で囲んでおけば安全だと思っていないだろうか。フラッシュセールで在庫1個の商品が2人に同時に売れてしまった――そんな障害を経験したことがあるなら、原因はトランザクションの使い方ではなく分離レベルの選択にある。
- PostgreSQL のデフォルトである Read Committed は文が始まるたびに新しいスナップショットを取るだけで、読んでから書くまでの間に他のトランザクションが割り込むのを防がない。
- Repeatable Read はトランザクション開始時点のスナップショットに固定するが、別々の行を書き換えて全体の整合性を壊す write skew(書き込みスキュー)はそれでも防げない。
- Serializable だけが書き込みの依存関係を検知して片方をエラーで突き返すが、アプリ側にリトライ処理を書く前提になる。
トランザクションの「原子性」だけでは同時実行は守れない
トランザクションを支える ACID のうち、原子性(Atomicity)は「途中で失敗したら全部なかったことにする」という保証であり、分離性(Isolation)は「同時に走る他のトランザクションからどう見えるか」という別の保証だ。BEGIN で囲めば自動的に両方が最強レベルで手に入る、と考えるのが最初のつまずきになる。
分離性には強さの段階があり、どこまで守るかは SET TRANSACTION ISOLATION LEVEL や ORM の設定で選ぶものだ。PostgreSQL は SQL 標準の 4 段階のうち Read Uncommitted・Read Committed・Repeatable Read・Serializable の名前を受け付けるが、内部的には Read Uncommitted は Read Committed と同じ挙動になり、実質 3 段階しか存在しない。
Read Committed ―― 文が実行されるたびに新しい像を見る
PostgreSQL は MVCC(Multiversion Concurrency Control、多版型並行制御)という仕組みで分離性を実現している。行を上書きせず、更新のたびに新しいバージョンを追加で書き込み、各バージョンに「このバージョンを作ったトランザクション ID」と「消したトランザクション ID」を記録する。読み手はこの ID を自分のスナップショット(ある時点での committed 状態の一覧)と突き合わせ、見えるべきバージョンだけを選び出す。
Read Committed では、このスナップショットを SQL 文が始まるたびに取り直す。つまり同じトランザクション内でも、1 つ目の SELECT と 2 つ目の SELECT の間に他のトランザクションが commit すれば、2 つ目にはその変更が見える。在庫確認の例で言えば、次のように 2 つのトランザクションが両方とも成功してしまう。
T1: BEGIN T2:
T1: SELECT qty → 1
T2: BEGIN
T2: SELECT qty → 1
(T1 未コミットでも見える)
T1: UPDATE qty = 0
T1: COMMIT ✅
T2: UPDATE qty = 0
T2: COMMIT ✅
(分離レベル次第で通ってしまう)
└──── 結果: 在庫は実質 -1 個 ────┘
T2 の SELECT は T1 の COMMIT より前に実行されているため、T1 の変更をまだ見ていない。この状態で T2 も「在庫は 1」と判断して更新すれば、直前の T1 の更新は上書きされて消える。これがロストアップデート(lost update)と呼ばれる現象だ。
Read Committed は毎秒シャッターを切る防犯カメラの映像を見るようなものだ。見るたびに最新の状態が映る。一方 Repeatable Read や Serializable は入店時に 1 枚だけ撮った記念写真を最後まで見続けるようなもので、あとから店内の様子が変わっても写真の中身は変わらない。
Repeatable Read ―― 開始時の一枚に固定されても防げない食い違い
Repeatable Read は、トランザクション内で最初の実質的な文を実行した瞬間のスナップショットを最後まで使い続ける。そのため同じ SELECT を 2 回打っても結果は変わらず(non-repeatable read の防止)、他のトランザクションが途中で挿入した行が後から見える phantom read も、PostgreSQL の実装ではこのレベルで防げる。
ここまで聞くと「Repeatable Read にしておけば安心」と思えるが、実際には write skew(書き込みスキュー)という別の食い違いが残る。オンコール担当医が最低 1 人必要な病院システムで、当番医 2 人が「もう 1 人いるから大丈夫」と同時に休暇申請するケースが典型例だ。互いに読む行は同じでも書き込む行(自分の担当行)は別々なので、行単位の競合検知では引っかからず、両方 commit が通って当番医が 0 人になる。読んでから書くまでの間に「前提となった全体像」が変わりうる、という点は Read Committed のロストアップデートと構造が同じだ。
Serializable ―― 矛盾をあとから検知して片方を突き返す
Serializable は「複数のトランザクションを同時に流しても、結果はどれか 1 つの順序で順番に実行したのと同じになる」という最も強い保証だ。PostgreSQL はこれを事前のロック待ちではなく、読み書きの依存関係を追跡する predicate locking で実現している。矛盾が生じうる依存関係の連鎖(サイクル)を検出すると、関わったトランザクションのどちらかを SQLSTATE 40001(could not serialize access due to read/write dependencies)で強制的に失敗させる。
事前にブロックせず、あとから片方を落とす設計なのは、待ち合わせによるデッドロックを避けつつ、実際には矛盾が起きなかった実行まで律儀に待たせずに済ませるためだ。裏を返せば、Serializable を選んだアプリケーションは 40001 を捕まえてトランザクションを最初からリトライする実装が前提になる。これを書かずに Serializable にすると、負荷が上がった瞬間にユーザー操作がエラーで弾かれ続けることになる。
Rails の ActiveRecord::Base.transaction(isolation: :serializable) や Go の sql.TxOptions{Isolation: sql.LevelSerializable} のように、多くの ORM・ドライバは分離レベルを明示指定できるが、指定しなければ接続先 DB のデフォルト(PostgreSQL は Read Committed)がそのまま使われる。在庫・予約・残高更新のように「読んで判断してから書く」処理では、SELECT ... FOR UPDATE で対象行を明示的にロックするか、対象箇所だけ Serializable にしてリトライを実装するかを選ぶことになる。
Read Committed で実際にロストアップデートが起きる様子を、ローカルの PostgreSQL と 2 つのターミナルで確認する。
# ターミナル 1: PostgreSQL を起動してテーブル作成 docker run --rm --name pgdemo -e POSTGRES_PASSWORD=pass -p 5433:5432 -d postgres:16 sleep 3 docker exec -it pgdemo psql -U postgres -c \ "CREATE TABLE stock(id int PRIMARY KEY, qty int); INSERT INTO stock VALUES (1, 1);" # ターミナル 1 (このまま対話セッションに入る) docker exec -it pgdemo psql -U postgres # BEGIN; # SELECT qty FROM stock WHERE id = 1; -- ここで 1 が見える # ターミナル 2 (別ウィンドウで新しい対話セッション) docker exec -it pgdemo psql -U postgres # BEGIN; # SELECT qty FROM stock WHERE id = 1; -- ここも 1 が見える # UPDATE stock SET qty = 0 WHERE id = 1; # COMMIT; # ターミナル 1 に戻って続きを実行 # UPDATE stock SET qty = 0 WHERE id = 1; # COMMIT; # SELECT qty FROM stock WHERE id = 1; -- 0 のまま。両方成功している
2 つのセッションがどちらも「在庫は 1」という前提で更新を成功させ、片方の更新が黙って消えたことが確認できるはずだ。ターミナル 2 の BEGIN の前に SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; を足して同じ手順を踏むと、後から COMMIT した側が 40001 エラーで弾かれる違いも見える。
- BEGIN〜COMMIT で囲めば同時実行でも安全 — 原子性(失敗時に全部なかったことにする)と分離性(他のトランザクションからどう見えるか)は別の保証であり、分離レベルを何も指定しなければデフォルトの Read Committed で動く。
- Repeatable Read にしておけば常に安全 — 同じ行を読み直しても結果が変わらないことは保証されるが、別々の行を書き換えて全体の整合性を壊す write skew は Serializable でなければ防げない。
- Serializable にすれば何も考えなくてよい — Serializable は矛盾をエラーとして検知する仕組みであり、防ぐわけではない。アプリ側でリトライを実装しない限り、負荷が上がるほどエラーがユーザーに露出する。
- MVCC
- Multiversion Concurrency Control。行を上書きせず新バージョンを追加し、読み手がスナップショットに応じて見えるバージョンを選ぶ並行制御方式。
- スナップショット
- ある時点で commit 済みだったデータの一覧。分離レベルによって取り直すタイミングが異なる。
- ロストアップデート
- 2 つのトランザクションが同じ前提で読み込み、後から書いた側が先の更新を上書きして消してしまう現象。
- write skew(書き込みスキュー)
- 別々の行を書き換える複数のトランザクションが、互いの変更を考慮せずに commit してしまい全体の整合性が崩れる現象。
- SELECT ... FOR UPDATE
- 読み込んだ行に書き込みロックをかけ、他のトランザクションがその行を更新しようとするとブロックされるようにする構文。
- 13.2. Transaction Isolation - PostgreSQL Documentation — 4 段階の分離レベルと、それぞれが防ぐ/防がない異常のパターンを公式が一覧表で整理している。
- 13.1. Introduction - PostgreSQL Documentation — MVCC がなぜロックを最小化しながら分離性を実現できるのかの前提説明。
- What Write Skew Looks Like - Cockroach Labs — write skew がなぜ行単位の競合検知をすり抜けるのかを図解している。