---
note: ACID・分離レベル・MVCC。ロストアップデートやデッドロックを再現して正しく防ぐ
created: 2026-08-14T10:00:00+09:00
---

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

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

## この回のねらい

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

## 到達目標

- ACID（原子性・一貫性・分離性・永続性）の各性質を、一言ずつ自分の言葉で説明できる。
- PostgreSQL の3つの実効的な分離レベル（READ COMMITTED / REPEATABLE READ / SERIALIZABLE）と、各アノマリー（ダーティリード・ノンリピータブルリード・ファントムリード・ロストアップデート・直列化異常）の対応を表で説明できる。
- 「PostgreSQL では READ UNCOMMITTED が READ COMMITTED として振る舞い、ダーティリードは起きない」ことを説明できる。
- MVCC の仕組み（各行の `xmin` / `xmax` とスナップショットによる可視性判定）を説明できる。
- READ COMMITTED でロストアップデートが起きる条件を、2セッションの時系列で説明できる。
- ロストアップデートを `SELECT ... FOR UPDATE` / 原子的な `UPDATE ... SET x = x - 1` / `SERIALIZABLE` のそれぞれで防ぐ方法と、そのトレードオフを説明できる。
- デッドロックの発生条件を説明し、PostgreSQL がどちらかを `deadlock detected` で中止することと、その回避策を説明できる。
- SERIALIZABLE は「上げれば安全」ではなく、`serialization_failure`（SQLSTATE 40001）での中止を前提としたリトライ設計とセットであることを説明できる。

## 前提と準備

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

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

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

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

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

```bash
psql -d postshop -c "SHOW default_transaction_isolation;"
```

```text
 default_transaction_isolation
-------------------------------
 read committed
(1 row)
```

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

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

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

## 本編

### ACID とは何か

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

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

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

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

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

- **ダーティリード（dirty read）**: 他トランザクションが**まだコミットしていない**変更を読んでしまう。
- **ノンリピータブルリード（non-repeatable read）**: 同じ行を2回読むと、間に他トランザクションが**更新・確定**したせいで値が変わる。
- **ファントムリード（phantom read）**: 同じ条件で2回検索すると、間に他トランザクションが**行を挿入・確定**したせいで該当件数が変わる。
- **ロストアップデート（lost update）**: 2つのトランザクションが同じ行を読んで更新し、片方の更新が上書きで消える。
- **直列化異常（serialization anomaly / write skew）**: 個々のトランザクションは正しく見えるのに、同時実行の結果が「どんな順番で1つずつ実行しても得られない」不整合な状態になる。

**分離レベル**（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 COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| ダーティリード | 起きない | 起きない | 起きない | 起きない |
| ノンリピータブルリード | 起こりうる | 起こりうる | 起きない | 起きない |
| ファントムリード | 起こりうる | 起こりうる | **起きない** | 起きない |
| ロストアップデート（同一行） | 起こりうる | 起こりうる | 起きない（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つの隠しシステム列を持つ。

- `xmin`: この行版を**作成した**トランザクションの ID（xid）。
- `xmax`: この行版を**無効化（更新・削除）した**トランザクションの ID。まだ有効なら 0。

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

- `xmin` のトランザクションが**すでにコミット済み**で、かつスナップショットに含まれる → 作成が見える。
- `xmax` が 0、または `xmax` のトランザクションが**まだコミットしていない／スナップショットに含まれない** → まだ無効化されていないものとして見える。

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

```sql
SELECT xmin, xmax, id, stock FROM products WHERE id = 1;
```

```text
  xmin  | xmax | id | stock
--------+------+----+-------
 349201 |    0 |  1 |   100
(1 row)
```

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

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

- **READ COMMITTED**: **文（ステートメント）ごと**に新しいスナップショットを取る。だから同じトランザクション内でも、文をまたぐと他トランザクションのコミット済み変更が見える → ノンリピータブルリードが起こりうる。
- **REPEATABLE READ / SERIALIZABLE**: トランザクションの**最初の問い合わせ時に1度だけ**スナップショットを取り、最後まで使い続ける → 何度読んでも同じ、ファントムも起きない。

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

### ロストアップデートを再現する（READ COMMITTED）

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

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

```sql
UPDATE products SET stock = 100 WHERE id = 1;
```

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

| 時刻 | セッションA | セッションB |
|---|---|---|
| T1 | `BEGIN;` | |
| T2 | `SELECT stock FROM products WHERE id=1;` → **100** | |
| T3 | | `BEGIN;` |
| T4 | | `SELECT stock FROM products WHERE id=1;` → **100** |
| T5 | `UPDATE products SET stock=99 WHERE id=1;`（100−1 と計算した） | |
| T6 | `COMMIT;` | |
| T7 | | `UPDATE products SET stock=99 WHERE id=1;`（B もまだ 100 のつもり。ブロックされず 99 を書く） |
| T8 | | `COMMIT;` |

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

```sql
SELECT stock FROM products WHERE id = 1;
```

```text
 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 |
|---|---|---|
| T1 | `BEGIN;` | |
| T2 | `SELECT stock FROM products WHERE id=1 FOR UPDATE;` → **100**（行ロック取得） | |
| T3 | | `BEGIN;` |
| T4 | | `SELECT stock FROM products WHERE id=1 FOR UPDATE;` → **ここで待たされる**（A がロック中） |
| T5 | `UPDATE products SET stock=99 WHERE id=1;` | （待機中） |
| T6 | `COMMIT;`（ロック解放） | |
| T7 | | 待機解除。**最新の 99 を読む** → **99** |
| T8 | | `UPDATE products SET stock=98 WHERE id=1;` |
| T9 | | `COMMIT;` |

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

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

```sql
-- 在庫が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 |
|---|---|---|
| T1 | `BEGIN ISOLATION LEVEL SERIALIZABLE;` | |
| T2 | `SELECT stock FROM products WHERE id=1;` → **100** | |
| T3 | | `BEGIN ISOLATION LEVEL SERIALIZABLE;` |
| T4 | | `SELECT stock FROM products WHERE id=1;` → **100** |
| T5 | `UPDATE products SET stock=99 WHERE id=1;` | |
| T6 | | `UPDATE products SET stock=99 WHERE id=1;` → **A の行ロック待ちでブロック** |
| T7 | `COMMIT;` | |
| T8 | | **エラーで中止**（下記） |

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

```text
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 - 1` | 1文に閉じて割り込みを排除 | 在庫・残高の単純な増減 | 複雑な計算ロジックには使えない |
| `SERIALIZABLE` | 衝突を検出して片方を中止 | write skew を含む複雑な不変条件 | 40001 前提のリトライ実装が必須 |

### 行ロックとデッドロック

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

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

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

| 時刻 | セッションA | セッションB |
|---|---|---|
| T1 | `BEGIN;` | `BEGIN;` |
| T2 | `UPDATE products SET stock=stock-1 WHERE id=1;`（行1をロック） | |
| T3 | | `UPDATE products SET stock=stock-1 WHERE id=2;`（行2をロック） |
| T4 | `UPDATE products SET stock=stock-1 WHERE id=2;` → **行2待ち**（B が保持） | |
| T5 | | `UPDATE products SET stock=stock-1 WHERE id=1;` → **行1待ち**（A が保持）→ 循環成立 |
| T6 | （検出後）片方が中止される | もう片方は続行できる |

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

```text
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` の昇順）でロックする**のが最も確実な回避策である。複数行をまとめてロックするなら、順序を固定する。

```sql
-- 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、既定）の結果。

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

```text
 stock
-------
    99
(1 row)
```

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

```text
 stock
-------
    98
(1 row)
```

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

```text
ERROR:  could not serialize access due to concurrent update
```

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

```sql
-- 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` 昇順でロックする」なら膠着が起きないことを確かめる。

**想定解答**

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

```text
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` 昇順でロックすれば循環は生じない。

```sql
-- 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` で読み直す |

## 到達度チェックリスト

- [ ] ACID の4性質を、それぞれ一言で説明できる。
- [ ] PostgreSQL では READ UNCOMMITTED が READ COMMITTED として振る舞い、ダーティリードが起きないことを説明できる。
- [ ] 分離レベル（RC / RR / SERIALIZABLE）とアノマリーの対応表を再現でき、RR がファントムも防ぐこと・SERIALIZABLE だけが直列化異常を防ぐことを説明できる。
- [ ] MVCC の `xmin` / `xmax` とスナップショットによる可視性判定を説明でき、RC と RR でスナップショットを取る単位（文ごと／トランザクションごと）の違いを言える。
- [ ] 2セッションの時系列で READ COMMITTED のロストアップデートを再現できる。
- [ ] `SELECT ... FOR UPDATE` / 原子的 `UPDATE ... SET x = x - 1` / `SERIALIZABLE` の3つで競合を防げ、それぞれのトレードオフを説明できる。
- [ ] デッドロックを逆順ロックで再現でき、`deadlock detected` の意味とロック順序固定による回避策を説明できる。
- [ ] SERIALIZABLE と行ロックは「リトライ設計」あるいは「順序設計」とセットである、と説明できる。

## 宿題（次回までの自習）

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回](10-roles-rls-audit.md)では、視点を「誰がその操作をしてよいのか」に移し、ロールと権限、行レベルセキュリティ（RLS）、監査を扱う。トランザクションが「いつ・どう」変更を確定するかの制御だったのに対し、次回は「誰が」変更できるかの制御であり、両者が揃って初めて実務のデータ保護が成り立つ。

## 参考

- PostgreSQL 公式ドキュメント: "Concurrency Control" > "Transaction Isolation"（分離レベルとアノマリーの定義、PostgreSQL の実挙動）
- PostgreSQL 公式ドキュメント: "Concurrency Control" > "Explicit Locking"（行ロック・`FOR UPDATE`・デッドロック）
- PostgreSQL 公式ドキュメント: "Concurrency Control" > "Serializable Snapshot Isolation (SSI)"（SERIALIZABLE と 40001）
- PostgreSQL 公式ドキュメント: "Internals" > "MVCC"（`xmin`/`xmax` とスナップショットの可視性）
- PostgreSQL 公式ドキュメント: "SQL Commands" > "SET TRANSACTION" / "BEGIN"（分離レベルの設定構文）
