トランザクション
ACID、Isolation Level、ロック、デッドロック、MVCC、楽観 / 悲観ロックまで。 Postgres の挙動を中心に、現場で詰まりがちな要点を一通り。
ACID
| 性質 | 意味 |
|---|---|
| Atomicity | 全部成功 or 全部失敗 |
| Consistency | 制約・整合性が保たれる |
| Isolation | 並列でも独立して見える |
| Durability | COMMIT したら失われない |
SQL の基本
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
-- 例外があれば
ROLLBACK;
-- 正常なら
COMMIT;
SAVEPOINT(ネスト的)
BEGIN;
INSERT INTO logs (...) VALUES (...);
SAVEPOINT before_email;
UPDATE users SET email = '...' WHERE id = 1;
-- メールが既に存在で失敗
ROLLBACK TO SAVEPOINT before_email;
-- 続きの処理
INSERT INTO audit ...;
COMMIT;
Isolation Level(分離レベル)
並行する複数のトランザクションが互いをどう見るか。SQL 標準で 4 段階。
| レベル | ダーティリード | ノンリピータブルリード | ファントムリード | シリアライゼーション異常 |
|---|---|---|---|---|
| READ UNCOMMITTED | ○ 起こる | ○ | ○ | ○ |
| READ COMMITTED(既定) | × | ○ | ○ | ○ |
| REPEATABLE READ | × | × | ×(Postgres) | ○ |
| SERIALIZABLE | × | × | × | × |
各リード現象
ダーティリード
他のトランザクションの未コミットを読んでしまう。 Postgres は最低でも READ COMMITTED なので、起きない。
ノンリピータブルリード
同じ行を 2 回 SELECT したら値が変わっていた。READ COMMITTED で起きる。
ファントムリード
2 回目の SELECT で新しい行が出現。Postgres は REPEATABLE READ でも起きない(Snapshot Isolation)。
シリアライゼーション異常
並行実行の結果が、どんな逐次実行でも得られない結果になる。SERIALIZABLE のみ防げる。
Postgres の Isolation 設定
-- セッション単位
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- トランザクション単位
BEGIN ISOLATION LEVEL REPEATABLE READ;
MVCC(Multi-Version Concurrency Control)
Postgres / Oracle / MySQL InnoDB が採用。ロックを取らずに読める仕組み:
- UPDATE は新しい行バージョンを作る(古いも残る)
- 各トランザクションは「自分から見える時点」のスナップショットを持つ
- READ は他の書き込みをブロックしない(その逆も)
- 古い行はVACUUMで掃除される
VACUUM
- 古い行(dead tuple)の回収
- autovacuum でほぼ自動
- 大量更新で膨れた場合は手動 VACUUM FULL
- テーブルが膨張(bloat)したら検出 → 対処
ロックの種類
| ロック | 意味 |
|---|---|
| SHARE | 読み取り共有可 |
| EXCLUSIVE | 他の全ロックを排他 |
| FOR UPDATE | 行を排他ロック(更新予定) |
| FOR NO KEY UPDATE | FK 列以外の更新用、緩い |
| FOR SHARE | 行の共有ロック(他の更新は待たせる) |
| FOR KEY SHARE | 最弱、FK 用 |
FOR UPDATE(悲観ロック)
BEGIN;
-- 行をロックして読む
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 他のトランザクションは UPDATE / DELETE / SELECT FOR UPDATE で待たされる
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
COMMIT;
SKIP LOCKED / NOWAIT
-- ロックされてる行はスキップして次へ(ジョブキュー実装)
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- ロックされてたら待たずエラー
SELECT * FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
楽観ロック(Optimistic Locking)
ロックを取らず、更新時に「version が一致するか」で衝突検知:
-- 読み取り
SELECT id, title, version FROM posts WHERE id = 1;
-- version = 5
-- 更新(version 一致を条件に)
UPDATE posts SET title = 'new', version = version + 1
WHERE id = 1 AND version = 5;
-- 更新行数が 0 なら衝突 → リトライ
- ロックを取らない → 並列性が高い
- 衝突時にリトライ
- UI で「他のユーザが先に更新しました」のメッセージ
悲観 vs 楽観
| 項目 | 悲観 | 楽観 |
|---|---|---|
| ロック | 取る | 取らない |
| 並列性 | 低い | 高い |
| 競合頻度 | 高頻度向き | 低頻度向き |
| 実装 | SELECT FOR UPDATE | version 列 + WHERE |
| 失敗時 | 待つ | リトライ |
デッドロック
2 つのトランザクションが互いの解放を待ち合って永久停止:
T1: UPDATE A; → UPDATE B 待ち
T2: UPDATE B; → UPDATE A 待ち
↓
Postgres が検知して 1 つを ROLLBACK
対策
- 常に同じ順序でロックを取る(A → B、B → A は厳禁)
- トランザクションを短く
- 失敗時の指数バックオフリトライ
- SERIALIZABLE 使用時はシリアライゼーション失敗もリトライ対象
典型パターン集
送金
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 残高チェック
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- アプリで 1000 以上か確認
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 1000);
COMMIT;
在庫減らし(楽観)
UPDATE products SET stock = stock - 1
WHERE id = 1 AND stock >= 1
RETURNING stock;
-- 行数 0 なら在庫切れ
ジョブキュー
BEGIN;
SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
UPDATE jobs SET status = 'processing' WHERE id = $1;
COMMIT;
-- 処理してから
UPDATE jobs SET status = 'done' WHERE id = $1;
長時間トランザクションの問題
- 古い行を VACUUM できなくなる → bloat
- 他の書き込みをブロック
- 外部 API 呼び出しはトランザクション外に
- 1 リクエスト 1 トランザクションを基本に、長いものは分割
ロック状態の確認
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle';
-- 待ち状態のロック
SELECT
blocked.pid AS blocked_pid,
blocking.pid AS blocking_pid,
blocked.query AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
-- 強制終了
SELECT pg_cancel_backend(pid);
SELECT pg_terminate_backend(pid);
Read Only トランザクション
BEGIN READ ONLY;
SELECT ...;
COMMIT;
最適化が効いて速くなる場合がある。レポート系で活用。
Two-Phase Commit(2PC)
分散トランザクション用。Postgres は PREPARE TRANSACTION をサポート:
PREPARE TRANSACTION 'tx1';
-- 別 DB でも準備
COMMIT PREPARED 'tx1';
コーディネータの障害に弱い → 現代ではあまり使わない。
Saga パターン
分散環境で各ステップが独立トランザクション。失敗時は逆操作で補償。
注文作成 → 在庫予約 → 決済 → 配送指示
↓ 失敗
逆順に補償(決済キャンセル → 在庫戻し → 注文取消)
Idempotency(冪等性)
- 同じリクエストを 2 回処理しても同じ結果
- idempotency_key を付与して重複実行を検知
- 外部 API(Stripe 等)と組み合わせるとき特に重要
失敗パターンと対処
| 症状 | 対処 |
|---|---|
| 競合エラーが多い | ロック粒度を見直す / 楽観ロック化 |
| デッドロック頻発 | ロック順序を統一 |
| ロック取得タイムアウト | トランザクションを短く / 外部 API を抜き出す |
| テーブル膨張 | VACUUM / 長期トランザクション禁止 |
| SERIALIZABLE 失敗 | リトライ実装 |
| 整合性壊れた | FK / CHECK / トランザクションで防御 |
監視
- 長時間トランザクション(
pg_stat_activity) - ロック待ち時間
- デッドロック発生率
- autovacuum の遅延
「トランザクションは短く、ロックは最小限」。 競合がある場面では悲観ロックで、頻度が低ければ楽観ロックで。 外部 API 呼び出しはトランザクション外に出す、これだけで多くの事故を防げる。