目次を表示する

RDB内部構造完全ガイド

ロックとデッドロック ── 同時実行制御の最後の砦

ロックとデッドロック ── 同時実行制御の最後の砦

このレイヤーの役割: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待ち状態に入らない
楽観的ロックそもそもロックを取らない