ロックとデッドロック ── 同時実行制御の最後の砦
このレイヤーの役割:MVCC だけでは防げない異常(Lost Update, Write Skew)をロックで防ぐ。デッドロックの検出と回避。

この章で何ができるようになるか:PostgreSQL のロックの種類と粒度を理解し、デッドロックの発生条件と対策を説明できるようになる。
MVCC でもロックが必要なケース
MVCC は「読み取りが書き込みをブロックしない」を実現するが、書き込み同士の衝突は防げない。
-- Lost Update の例
-- TX1 と TX2 が同時に残高を更新
TX1: SELECT balance FROM accounts WHERE id = 1; -- 1000
TX2: SELECT balance FROM accounts WHERE id = 1; -- 1000
TX1: UPDATE accounts SET balance = 1000 + 500 WHERE id = 1; -- 1500
TX2: UPDATE accounts SET balance = 1000 - 200 WHERE id = 1; -- 800
-- TX1 の +500 が失われた!(Lost Update)
対策:行レベルロック or SELECT ... FOR UPDATE
-- SELECT FOR UPDATE で行をロック
TX1: SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 1000(行ロック取得)
TX2: SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- TX1 の完了を待つ
TX1: UPDATE accounts SET balance = 1000 + 500 WHERE id = 1;
TX1: COMMIT; -- ロック解放
TX2: SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 1500(最新値)
TX2: UPDATE accounts SET balance = 1500 - 200 WHERE id = 1; -- 1300(正しい)
PostgreSQL のロック粒度
テーブルレベルロック
-- 主なテーブルロックモード(弱い順)
ACCESS SHARE -- SELECT が取得。他の全てと共存
ROW SHARE -- SELECT FOR UPDATE/SHARE が取得
ROW EXCLUSIVE -- INSERT/UPDATE/DELETE が取得
SHARE UPDATE EXCLUSIVE -- VACUUM, ANALYZE が取得
SHARE -- CREATE INDEX (CONCURRENTLY なし) が取得
SHARE ROW EXCLUSIVE -- トリガー作成などが取得
EXCLUSIVE -- ほぼ全てをブロック
ACCESS EXCLUSIVE -- ALTER TABLE, DROP TABLE, VACUUM FULL が取得
-- 全ての他のロックと競合
-- 現在のロック状況を確認
SELECT
l.locktype,
l.relation::regclass AS table_name,
l.mode,
l.granted,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.relation IS NOT NULL
ORDER BY l.relation;
行レベルロック
-- FOR UPDATE: 排他ロック(他の FOR UPDATE/UPDATE/DELETE をブロック)
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- FOR SHARE: 共有ロック(他の FOR UPDATE をブロックするが、FOR SHARE は許可)
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- FOR UPDATE NOWAIT: ロック取得できなければ即座にエラー
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- → ERROR: could not obtain lock on row
-- FOR UPDATE SKIP LOCKED: ロック中の行をスキップ(キューイングパターンに最適)
SELECT * FROM tasks WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
Advisory Lock(アドバイザリーロック)
アプリケーションレベルのカスタムロック。テーブルや行に紐づかない。
-- セッションレベルのアドバイザリーロック
SELECT pg_advisory_lock(12345); -- ロック取得(ブロッキング)
-- ... 排他処理 ...
SELECT pg_advisory_unlock(12345); -- ロック解放
-- トランザクションレベル(COMMIT で自動解放)
SELECT pg_advisory_xact_lock(12345);
-- ノンブロッキング版
SELECT pg_try_advisory_lock(12345); -- 取得できれば true、できなければ false
ユースケース:分散ロック(アプリケーションレベルの排他制御)、Cron ジョブの重複実行防止、レートリミットなど。
デッドロック
2つ以上のトランザクションが互いにロックの解放を待ち合う状態。
-- TX1
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1; -- id=1 をロック
-- TX2
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- id=2 をロック
-- TX1
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- id=2 のロック待ち ← TX2 が持っている
-- TX2
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- id=1 のロック待ち ← TX1 が持っている
-- → デッドロック!
PostgreSQL のデッドロック検出
PostgreSQL は deadlock_timeout(デフォルト 1秒)ごとにデッドロックを検出する。
Wait-for Graph(待ちグラフ)を構築し、サイクルがあればデッドロックと判定。
サイクル内の1つの TX を選んでアボートする。
→ ERROR: deadlock detected
→ DETAIL: Process 1234 waits for ShareLock on transaction 5678;
blocked by process 5679.
デッドロックの防止策
1. ロックの順序を統一する
常に id の昇順でロックを取得
→ サイクルが発生しない
2. トランザクションを短くする
ロックの保持時間を最小化
→ デッドロックの確率が下がる
3. SELECT FOR UPDATE NOWAIT を使う
ロック取得できなければ即座にリトライ
→ 待ち状態に入らない
4. 楽観的ロック(アプリ側)
UPDATE ... WHERE version = ?
→ ロックを取らずに衝突を検出
# 楽観的ロック(アプリケーション側の実装)
def transfer(from_id, to_id, amount):
# 1. 現在の残高とバージョンを読む
from_account = db.query(
"SELECT balance, version FROM accounts WHERE id = %s", from_id
)
# 2. ビジネスロジック
new_balance = from_account.balance - amount
# 3. バージョンが変わっていなければ更新
updated = db.execute(
"UPDATE accounts SET balance = %s, version = version + 1 "
"WHERE id = %s AND version = %s",
new_balance, from_id, from_account.version
)
if updated == 0:
raise OptimisticLockError("Concurrent modification detected, retry")
ロック待ちの監視
-- ブロッキングクエリの特定
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocked.wait_event_type,
now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks gl ON gl.locktype = bl.locktype
AND gl.relation = bl.relation AND gl.granted
JOIN pg_stat_activity blocking ON gl.pid = blocking.pid
WHERE blocked.pid != blocking.pid;
-- ロックタイムアウトの設定
SET lock_timeout = '5s'; -- 5秒以上ロック待ちしたらエラー
SET statement_timeout = '30s'; -- 30秒以上実行中のクエリをキャンセル
まとめ
| ロック種別 | 粒度 | 用途 |
|---|---|---|
| テーブルロック | テーブル全体 | DDL(ALTER TABLE)、VACUUM FULL |
| 行ロック(FOR UPDATE) | 行単位 | 残高更新、在庫引き当て |
| 行ロック(FOR SHARE) | 行単位 | 外部キー参照の保護 |
| Advisory Lock | アプリ定義 | 分散ロック、ジョブ重複防止 |
| 楽観的ロック | アプリ側 | 競合が少ないケースの軽量な制御 |
| デッドロック対策 | 効果 |
|---|---|
| ロック順序の統一 | サイクルを構造的に防止 |
| TX を短くする | ロック保持時間の最小化 |
| NOWAIT / SKIP LOCKED | 待ち状態に入らない |
| 楽観的ロック | そもそもロックを取らない |