第9回|トランザクションと並行制御

「同時に走る複数の処理」が壊す一貫性を、アノマリーとして自分の手で再現し、分離レベルとロックで正しく設計する回。

この回のねらい

ここまでは1つのセッションから見た SQL を扱ってきた。実際のアプリケーションでは、複数の接続が同じ行を同時に読み書きする。今回は、その「同時」が引き起こす不整合(アノマリー)を、2つの psql セッションで実際に再現しながら理解する。ACID の4つの性質を押さえ、PostgreSQL の分離レベルが各アノマリーをどこまで防ぐのかを実挙動で確かめ、MVCC という PostgreSQL の並行制御の仕組みを説明できるようにする。最後に、在庫減算のような典型的な競合を、行ロック・原子的更新・SERIALIZABLE のどれで守るべきかを、トレードオフとともに設計できるようになることを目指す。

到達目標

前提と準備

第1回で構築した PostgreSQL 16 系のデータベース postshop に、共通スキーマとデータが投入済みであることを前提にする。今回は在庫減算の競合を扱うため、共通スキーマの products に、この回の実験用として在庫列 stock を追加する(共通スキーマの拡張)。

-- 本回の並行制御実験のための在庫列。既存の共通スキーマにはない列を追加する
ALTER TABLE products
  ADD COLUMN IF NOT EXISTS stock int NOT NULL DEFAULT 100 CHECK (stock >= 0);

以降の例では products.id = 1 と id = 2 を使う。実験のたびに初期値へ戻せるよう、リセット用の1文を用意しておく。

UPDATE products SET stock = 100 WHERE id IN (1, 2);

今回の実験は2つの独立した接続を並行に操作するのが要点である。ターミナルを2つ開き、それぞれで psql -d postshop を実行しておく。本文ではこれらをセッションA・セッションBと呼ぶ。現在の既定の分離レベルは次で確認できる。

psql -d postshop -c "SHOW default_transaction_isolation;"
 default_transaction_isolation
-------------------------------
 read committed
(1 row)

この回で新しく出てくる用語

いずれも本編で詳しく扱うが、実験の途中で意味を取り違えないよう先に一覧で置く。 回をまたいで使う語は用語集にもまとめている。

用語一行での説明
トランザクションまとめて成功させるか、まとめてなかったことにするかの単位
分離レベル同時に走るトランザクションが互いの途中結果をどこまで見せ合うかの段階
アノマリー同時実行によって生じる、単独では起こり得ない不整合
MVCC(Multiversion Concurrency Control)行を上書きせず新しい版を足していくことで並行性を得る仕組み。多版並行制御
スナップショットどのトランザクションの結果を見えるものとするかの境界
xmin / xmax各行版を作成した/無効化したトランザクションIDを持つ隠しシステム列
デッドタプル無効化されたまま残っている古い行版。VACUUM が回収する
SQLSTATEエラーの種類を表す5文字の標準コード。40001 は直列化失敗、40P01 はデッドロック
VACUUM不要になったデッドタプルの領域を回収する保守処理(第12回)

本編

ACID とは何か

トランザクション(transaction)とは、「まとめて成功するか、まとめてなかったことにするか」のどちらかにしたい一連の操作をひとくくりにする単位である。BEGIN で開始し、COMMIT で確定、ROLLBACK で破棄する。トランザクションが満たすべき性質を頭文字で ACID と呼ぶ。

性質英語一言でいうと
原子性Atomicity全部やるか、全部やらないか。途中状態を残さない
一貫性Consistency制約(PK・FK・CHECK など)を破った状態では確定させない
分離性Isolation同時に走る他のトランザクションの途中結果が見えない
永続性DurabilityCOMMIT したら、電源が落ちても失われない

このうち今回の主題は**分離性(Isolation)**である。原子性・永続性は「1つのトランザクションが最後までやり切れるか/消えないか」の話だが、分離性は「複数のトランザクションが同時に走ったとき、互いにどこまで影響を見せ合うか」の話であり、ここだけ強さを段階的に選べる。分離を強くするほど不整合は減るが、待ちや失敗が増える。このトレードオフを扱うのが今回である。

分離レベルとアノマリー ―― PostgreSQL の実挙動

分離が不十分だと、同時実行によって単独では起こり得ない不整合が観測される。これをアノマリー(anomaly)と呼ぶ。代表的なものは次の5つである。

分離レベル(isolation level)とは、この分離性の強さをどこまで求めるかを選ぶ段階の名前であり、段階ごとに「どのアノマリーを許すか」が決まる。SQL 標準は分離レベルを4段(READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE。以降 READ COMMITTED を RC、REPEATABLE READ を RR と略す)と定めるが、PostgreSQL の実挙動は3段しかない。PostgreSQL は READ UNCOMMITTED を要求されても READ COMMITTED として扱い、どの分離レベルでもダーティリードは発生しない。これは MVCC(後述)が「未コミットの行版は他トランザクションから見えない」ように作られているためで、PostgreSQL では「未コミットを覗く」こと自体ができない。

PostgreSQL 16 系での分離レベルとアノマリーの対応は次のとおりである。

アノマリーREAD UNCOMMITTED(=RC 扱い)READ COMMITTEDREPEATABLE READSERIALIZABLE
ダーティリード起きない起きない起きない起きない
ノンリピータブルリード起こりうる起こりうる起きない起きない
ファントムリード起こりうる起こりうる起きない起きない
ロストアップデート(同一行)起こりうる起こりうる起きない(40001 で中止)起きない(40001 で中止)
直列化異常(write skew)起こりうる起こりうる起こりうる起きない(SSI が検出)

この表で、初学者の直感とずれる箇所を3つ強調しておく。

READ UNCOMMITTED の列が READ COMMITTED と同一である。 PostgreSQL には「未コミットを読む」モードが実質存在しない。ダーティリードを再現しようとしても、この DBMS ではできない。

REPEATABLE READ がファントムも防ぐ。 SQL 標準では REPEATABLE READ はファントムリードを許すが、PostgreSQL の REPEATABLE READ は**スナップショット分離(snapshot isolation)**で実装されており、トランザクション開始時点のスナップショットを最後まで使い続ける。したがって途中で他トランザクションが行を挿入・確定しても見えず、ファントムも起きない。標準より1段強い。

SERIALIZABLE だけが直列化異常を防ぐ。 REPEATABLE READ は同じ行への競合(同一行のロストアップデート)は防ぐが、「別々の行を更新するが、両方合わせると業務ルールを破る」タイプの異常(write skew)は防げない。SERIALIZABLE は SSI(Serializable Snapshot Isolation) という仕組みでトランザクション間の read/write 依存を追跡し、直列実行と等価にならない組み合わせを検出して、片方を serialization_failure で中止する。SQLSTATE とはエラーの種類を表す5文字の標準コードで、この中止は 40001 として返る。アプリはエラーメッセージの文面ではなくこのコードで分岐する。「安全」の代償は「失敗して再試行が必要になる」ことである。

MVCC とスナップショット

PostgreSQL の分離性を支えているのが **MVCC(Multiversion Concurrency Control、多版並行制御)**である。核心は「行を上書きしない」ことにある。UPDATE は既存の行を書き換えるのではなく、新しい行版(tuple)を追加し、古い版に「ここで無効化された」という印を付ける。各行版は、次の2つの隠しシステム列を持つ。

各トランザクションは開始時(または文の実行時)にスナップショット、すなわち「どのトランザクションの結果を見えるものとするか」の境界を得る。ある行版が見えるかどうかは、ざっくり次で決まる。

この仕組みのおかげで、読み手は書き手を待たず、書き手は読み手を待たない。更新は古い版を残したまま新しい版を足すだけなので、古いスナップショットを持つ読み手は古い版を見続けられる。システム列は明示的に SELECT できる。

SELECT xmin, xmax, id, stock FROM products WHERE id = 1;
  xmin  | xmax | id | stock
--------+------+----+-------
 349201 |    0 |  1 |   100
(1 row)

UPDATE すると xmin が新しいトランザクション ID に変わる(=新しい行版になった)ことが確認できる。古い版はまだ物理的にテーブルに残っており、後で VACUUM(不要になった古い行の領域を回収する保守処理。第12回で扱う)が回収する。

分離レベルの違いは、スナップショットをいつ取り直すかの違いに帰着する。

なお MVCC には代償がある。無効化された古い行版(デッドタプル)は自動で消えず、VACUUM が回収するまでテーブルに残る。長時間開きっぱなしのトランザクションがあると、その古いスナップショットにまだ見えるかもしれない行版を VACUUM が消せず、テーブルとインデックスが肥大化(ブロート)する。これは今回のつまずき「長時間トランザクションの放置」の正体であり、ストレージ観点は第7回につながる。

ロストアップデートを再現する(READ COMMITTED)

在庫の減算は「読んで、引いて、書き戻す(read-modify-write)」の典型である。アプリが stock を読み、-1 した値を計算し、UPDATE で書き戻す。これを2つのセッションが同時にやると、既定の READ COMMITTED では片方の減算が消える。

まず在庫を初期化しておく。

UPDATE products SET stock = 100 WHERE id = 1;

セッションA・Bで次の順に操作する。「アプリが計算した値」を直接 UPDATE に書く(=アプリのロジックを模す)ことで、read-modify-write を再現する。

時刻セッションAセッションB
T1BEGIN;
T2SELECT stock FROM products WHERE id=1; → 100
T3BEGIN;
T4SELECT stock FROM products WHERE id=1; → 100
T5UPDATE products SET stock=99 WHERE id=1;(100−1 と計算した)
T6COMMIT;
T7UPDATE products SET stock=99 WHERE id=1;(B もまだ 100 のつもり。ブロックされず 99 を書く)
T8COMMIT;

終わった後の在庫を確認する。

SELECT stock FROM products WHERE id = 1;
 stock
-------
    99
(1 row)

2件の注文で在庫を2つ減らしたはずなのに、stock は 100 から 99 にしか減っていない。あるべき値は 98 である。B の UPDATE(T7)は、A の読んだ 100 を知らずに「100−1=99」を書き込み、A のコミット済みの結果を上書きした。最後の書き込みが勝つ(last write wins)——これがロストアップデートである。

ポイントは、B が T4 で読んだ 100 と、T7 で書き戻す 99 の間に A の更新が確定していること、そして READ COMMITTED では B の SELECT(ロックなし)と UPDATE が別々のスナップショットで動くため、B は自分の読んだ値が古くなったことに気づけない点にある。エラーは一切出ない。だからこそ質が悪い。

ロストアップデートを防ぐ3つの方法

防ぎ方は3つある。それぞれ守り方とトレードオフが異なる。

方法1: SELECT ... FOR UPDATE(行ロックで直列化する)。 読む時点でその行に排他ロックを掛け、後続のトランザクションを待たせる。read-modify-write を一列に並べる、最も直接的な方法である。

時刻セッションAセッションB
T1BEGIN;
T2SELECT stock FROM products WHERE id=1 FOR UPDATE; → 100(行ロック取得)
T3BEGIN;
T4SELECT stock FROM products WHERE id=1 FOR UPDATE; → ここで待たされる(A がロック中)
T5UPDATE products SET stock=99 WHERE id=1;(待機中)
T6COMMIT;(ロック解放)
T7待機解除。最新の 99 を読む → 99
T8UPDATE products SET stock=98 WHERE id=1;
T9COMMIT;

結果は stock = 98 で正しい。重要なのは T7 で、READ COMMITTED では待たされていた SELECT ... FOR UPDATE が解除されると、A がコミットした最新版(99)を読み直すことである。だから B は 100 ではなく 99 を土台に減算できる。トレードオフは、行ロックによる待ちが発生し、ロックを長く保持すると並行度が落ちること。ロックの取得順序を誤ると次節のデッドロックも招く。

方法2: 原子的な UPDATE ... SET x = x - 1(そもそもアプリに読ませない)。 「読んでから引く」を DB の1文にまとめてしまえば、read と write の間に他トランザクションが割り込む隙がなくなる。UPDATE 自体が対象行に行ロックを掛けながら現在値を読み直して計算するため、同時実行は自動的に直列化される。

-- 在庫が1以上あるときだけ、原子的に1減らす
UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock >= 1;

2セッションが同時にこれを実行しても、片方が行ロックを持つ間もう片方は待ち、待機解除後に更新後の値を読み直してから stock - 1 を計算する。100 → 99 → 98 と正しく減る。WHERE ... AND stock >= 1 を付け、更新された行数(psql なら UPDATE 1 / UPDATE 0)で在庫切れを判定すれば、CHECK (stock >= 0) 違反を待たずに「売り切れ」を扱える。トレードオフは、読んだ値をアプリ側で加工してから書き戻す複雑なロジックには使えないこと。単純な増減にはこれが最も速く、最も安全で、第一候補になる。

方法3: SERIALIZABLE(衝突したら失敗させ、アプリで再試行する)。 ロックで待たせる代わりに、危険な同時実行を検出して片方を中止する。アプリは中止(SQLSTATE 40001)を捕まえてトランザクションごとやり直す。

時刻セッションAセッションB
T1BEGIN ISOLATION LEVEL SERIALIZABLE;
T2SELECT stock FROM products WHERE id=1; → 100
T3BEGIN ISOLATION LEVEL SERIALIZABLE;
T4SELECT stock FROM products WHERE id=1; → 100
T5UPDATE products SET stock=99 WHERE id=1;
T6UPDATE products SET stock=99 WHERE id=1; → A の行ロック待ちでブロック
T7COMMIT;
T8エラーで中止(下記)

B は T8 で次のエラーを受け取る。

ERROR:  could not serialize access due to concurrent update

SQLSTATE は 40001(serialization_failure)である。B はこのトランザクションを ROLLBACK し、最初からやり直す。やり直せば今度は stock を 99 と読み、98 を書いて成功する。トレードオフは明確で、アプリ側にリトライ処理がないと、この 40001 が単なる実行時エラーとしてユーザーに露出する。SERIALIZABLE は「分離レベルを上げれば勝手に安全になる」魔法ではなく、「失敗を返すので、あなたが再試行してください」という契約である。この点は後半で改めて強調する。

なお、この同一行の競合は REPEATABLE READ でも同じ 40001 で中止される(先に更新した側が勝つ)。SERIALIZABLE が REPEATABLE READ より真に強いのは、別々の行を更新するが業務ルールを合わせて破る write skew を検出できる点にある。たとえば「カテゴリ内の在庫合計を必ず1以上に保つ」という不変条件を、2つのトランザクションがそれぞれ別の商品を減らして同時に破るケースは、REPEATABLE READ では両方コミットできてしまう(互いのスナップショットに相手の更新が見えず、行の衝突もないため)。SERIALIZABLE の SSI はこの read/write 依存の循環を検出し、片方を 40001 で止める。

3つの方法を整理する。

方法守り方向いている場面主なトレードオフ
SELECT ... FOR UPDATE読む時点で行ロックし直列化読んだ値を加工して書き戻す複雑な更新ロック待ちで並行度低下・順序次第でデッドロック
UPDATE SET x = x - 11文に閉じて割り込みを排除在庫・残高の単純な増減複雑な計算ロジックには使えない
SERIALIZABLE衝突を検出して片方を中止write skew を含む複雑な不変条件40001 前提のリトライ実装が必須

行ロックとデッドロック

行ロックは強力だが、複数の行を、複数のトランザクションが食い違う順序でロックするとデッドロック(deadlock、相互待ち)になる。A が行1→行2 の順、B が行2→行1 の順でロックしようとすると、互いに相手の持つ行を待って永遠に進めなくなる。PostgreSQL は deadlock_timeout(既定1秒)ごとに待ちグラフを調べ、循環を見つけると片方を強制的に中止して膠着を破る。

在庫を戻してから再現する。

UPDATE products SET stock = 100 WHERE id IN (1, 2);
時刻セッションAセッションB
T1BEGIN;BEGIN;
T2UPDATE products SET stock=stock-1 WHERE id=1;(行1をロック)
T3UPDATE products SET stock=stock-1 WHERE id=2;(行2をロック)
T4UPDATE products SET stock=stock-1 WHERE id=2; → 行2待ち(B が保持)
T5UPDATE products SET stock=stock-1 WHERE id=1; → 行1待ち(A が保持)→ 循環成立
T6(検出後)片方が中止されるもう片方は続行できる

中止された側は次のエラーを受け取る。

ERROR:  deadlock detected
DETAIL:  Process 12345 waits for ShareLock on transaction 349310; blocked by process 12346.
Process 12346 waits for ShareLock on transaction 349311; blocked by process 12345.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,5) in relation "products"

同じ内容がサーバーログにも deadlock detected として記録される。どちらが犠牲(victim)になるかは実装依存で、アプリからは指定できない。中止された側は ROLLBACK して、必要ならやり直す。

デッドロックの根本原因は「ロック順序の不一致」なので、すべてのトランザクションで行を同じ順序(たとえば id の昇順)でロックするのが最も確実な回避策である。複数行をまとめてロックするなら、順序を固定する。

-- id 昇順で確定的にロックすれば、相互待ちの循環が生じない
SELECT id, stock FROM products
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

それでも競合が皆無にはならないので、アプリ側は deadlock detected(SQLSTATE 40P01)を捕まえてトランザクションを再試行できるようにしておく。40001(直列化失敗)と 40P01(デッドロック)は、いずれも「あなたのせいではない、もう一度やって」というシグナルであり、例外として握りつぶすのではなくリトライで扱う。

ハンズオン

課題1: READ COMMITTED のロストアップデートを再現し、FOR UPDATE と SERIALIZABLE で防ぐ

2つの psql セッションを使い、まずロストアップデートを再現し、次に2通りの方法で防ぐ。

やること

  1. products.id = 1 の stock を 100 に初期化する。
  2. 本編「ロストアップデートを再現する」の時系列(T1〜T8)どおりに、セッションA・Bを交互に操作し、最終値が 99(本来 98)になることを確認する。
  3. 在庫を 100 に戻し、SELECT ... FOR UPDATE を使った時系列で、最終値が 98 になることを確認する。
  4. 在庫を 100 に戻し、両セッションを SERIALIZABLE にして同じ read-modify-write を行い、片方が 40001 で中止されること、リトライすれば 98 に到達することを確認する。

想定解答

再現(READ COMMITTED、既定)の結果。

-- 手順どおり操作した後、セッションのどちらかで確認
SELECT stock FROM products WHERE id = 1;
 stock
-------
    99
(1 row)

FOR UPDATE で防いだ場合。セッションB の SELECT ... FOR UPDATE は A のコミットまで待ち、解除後に 99 を読み直してから 98 を書く。

 stock
-------
    98
(1 row)

SERIALIZABLE で防いだ場合。後からコミットしようとした側が中止される。

ERROR:  could not serialize access due to concurrent update

中止された側の擬似的なリトライ手順は次のとおり。実アプリではこのループを例外処理として実装する。

-- 40001 を受けたら、ROLLBACK してトランザクションごとやり直す
ROLLBACK;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT stock FROM products WHERE id = 1;   -- 今度は 99 が見える
UPDATE products SET stock = 98 WHERE id = 1;
COMMIT;                                    -- 成功

3通りとも最終的に正しい値(98)へ導けるが、守り方が違う。FOR UPDATE は待たせて直列化、SERIALIZABLE は失敗させて再試行。そして最も単純な UPDATE stock = stock - 1 WHERE id = 1 AND stock >= 1 なら、そもそもアプリに読ませないので競合の窓自体が消える。単純増減ならこれが第一候補である。

課題2: デッドロックをわざと起こし、ログで確認する

やること

  1. products.id IN (1, 2) の stock を 100 に初期化する。
  2. 本編「行ロックとデッドロック」の時系列(T1〜T5)どおりに、A・Bを逆順にロックさせる。
  3. どちらか一方が ERROR: deadlock detected で中止されることを確認する。
  4. 生き残った側を COMMIT し、回避策として「両者とも id 昇順でロックする」なら膠着が起きないことを確かめる。

想定解答

逆順ロックで一方が受け取るエラー。

ERROR:  deadlock detected
DETAIL:  Process 12345 waits for ShareLock on transaction 349310; blocked by process 12346.
Process 12346 waits for ShareLock on transaction 349311; blocked by process 12345.
CONTEXT:  while updating tuple (0,5) in relation "products"

中止された側は自動的にそのトランザクションが失効するので ROLLBACK し、生き残った側は COMMIT する。回避策として、両セッションが行を id 昇順でロックすれば循環は生じない。

-- A も B も、まず id=1、次に id=2 の順で触る(順序を固定する)
BEGIN;
SELECT id FROM products WHERE id IN (1, 2) ORDER BY id FOR UPDATE;  -- 昇順で一括ロック
UPDATE products SET stock = stock - 1 WHERE id = 1;
UPDATE products SET stock = stock - 1 WHERE id = 2;
COMMIT;

先に ORDER BY id FOR UPDATE で必要な行をまとめて昇順ロックしておけば、後続のトランザクションはその解放を待つだけで、相互待ちの循環にはならない。デッドロックは「ロック順序の設計問題」であることが体感できる。

よくあるつまずきと対処

症状・誤解原因対処
「分離レベルを上げれば全部安全」と思い込むREPEATABLE READ は write skew を防げず、SERIALIZABLE も 40001 を返すだけで自動修復はしない競合の種類に応じて FOR UPDATE・原子的 UPDATE・SERIALIZABLE を選ぶ。SERIALIZABLE は必ずリトライとセットにする
SERIALIZABLE にしたら本番で could not serialize access が多発40001 を例外として握りつぶし、リトライを実装していない40001(と 40P01)を捕捉し、トランザクションを丸ごと再実行する。読み取り専用や単純増減は分離レベルを上げない
ロストアップデートに気づけないREAD COMMITTED では上書きがエラーなしで成立する競合しうる read-modify-write は FOR UPDATE か原子的 UPDATE にする。金額・在庫は特に注意
PostgreSQL でダーティリードを再現できないPostgreSQL は READ UNCOMMITTED を READ COMMITTED として扱い、未コミット行は見えない再現不能が正しい。ダーティリードは PostgreSQL では設計上起きないと理解する
deadlock detected が時々出る複数トランザクションが行を食い違う順序でロックしているロック順序を id 昇順などに固定する。40P01 はリトライで扱う
VACUUM してもテーブルが縮まない/肥大化する長時間開いたトランザクションが古いスナップショットを保持し、xmin ホライズンが進まずデッドタプルを回収できないトランザクションを短く保つ。放置された idle in transaction を監視し、idle_in_transaction_session_timeout を設定する
REPEATABLE READ なのに同じクエリで結果が変わらず戸惑うRR は開始時のスナップショットを固定するので、他トランザクションのコミットが見えないのが正しい挙動最新値が必要なら READ COMMITTED を使うか、FOR UPDATE で読み直す

到達度チェックリスト

宿題(次回までの自習)

  1. products.id = 1 の在庫を 1000 にリセットし、2セッションで原子的な UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1; をそれぞれ複数回実行して、最終在庫が減算回数どおりに一致することを確認する。次に同じことを「SELECT で読む → アプリで計算 → UPDATE に定数を書く」方式でやり、ずれることと対比する。
  2. SHOW deadlock_timeout; で既定値を確認し、課題2のデッドロックを起こしたとき、検出まで約1秒かかることを体感する。さらに SET deadlock_timeout = '100ms'; をセッションに設定して、検出が速くなることを確かめる。
  3. 一方のセッションで BEGIN; だけして放置(idle in transaction)し、別セッションで SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction'; を実行して、長時間トランザクションがどう観測されるかを見る。これが次回以降の運用・監視の話につながる。

次回への接続

今回は「同時に走る複数トランザクションを、いかに正しく/安全に協調させるか」を、分離レベル・MVCC・ロックの観点から扱った。次の第10回では、視点を「誰がその操作をしてよいのか」に移し、ロールと権限、行レベルセキュリティ(RLS)、監査を扱う。トランザクションが「いつ・どう」変更を確定するかの制御だったのに対し、次回は「誰が」変更できるかの制御であり、両者が揃って初めて実務のデータ保護が成り立つ。

参考