トランザクション

ACID、Isolation Level、ロック、デッドロック、MVCC、楽観 / 悲観ロックまで。 Postgres の挙動を中心に、現場で詰まりがちな要点を一通り。

ACID

性質意味
Atomicity全部成功 or 全部失敗
Consistency制約・整合性が保たれる
Isolation並列でも独立して見える
DurabilityCOMMIT したら失われない

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 が採用。ロックを取らずに読める仕組み:

VACUUM

ロックの種類

ロック意味
SHARE読み取り共有可
EXCLUSIVE他の全ロックを排他
FOR UPDATE行を排他ロック(更新予定)
FOR NO KEY UPDATEFK 列以外の更新用、緩い
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 なら衝突 → リトライ

悲観 vs 楽観

項目悲観楽観
ロック取る取らない
並列性低い高い
競合頻度高頻度向き低頻度向き
実装SELECT FOR UPDATEversion 列 + WHERE
失敗時待つリトライ

デッドロック

2 つのトランザクションが互いの解放を待ち合って永久停止:

T1: UPDATE A; → UPDATE B 待ち
T2: UPDATE B; → UPDATE A 待ち
       ↓
   Postgres が検知して 1 つを ROLLBACK
      

対策

典型パターン集

送金

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;

長時間トランザクションの問題

ロック状態の確認

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(冪等性)

失敗パターンと対処

症状対処
競合エラーが多いロック粒度を見直す / 楽観ロック化
デッドロック頻発ロック順序を統一
ロック取得タイムアウトトランザクションを短く / 外部 API を抜き出す
テーブル膨張VACUUM / 長期トランザクション禁止
SERIALIZABLE 失敗リトライ実装
整合性壊れたFK / CHECK / トランザクションで防御

監視

原則

「トランザクションは短く、ロックは最小限」。 競合がある場面では悲観ロックで、頻度が低ければ楽観ロックで。 外部 API 呼び出しはトランザクション外に出す、これだけで多くの事故を防げる。