⏱️

PostgreSQLのNOWAITを使ったロック競合対策

に公開

背景

本番環境のログでcould not obtain lock on rowというエラーを発見した。調査すると、既存コードにFOR UPDATE NOWAITが設定されており、ロック競合時に即座にエラーを返す動作になっていた。

当時の自分は「PostgreSQLはMVCCだから、いい感じにロック競合しないんじゃないの?」程度の知識しかなく、なぜこのエラーが出るのかピンとこなかった。これをきっかけにロック競合について調べ、NOWAITの役割と、そもそもの根本対策を理解することができた。

この記事では、そのとき学んだことを整理する。

ロック競合とは

PostgreSQLはMVCC(Multi-Version Concurrency Control)により、読み取りと書き込みは基本的に競合しない[1]。しかし、同一行に対する書き込み同士(Writer vs Writer)は競合する[2]

これは、データの整合性を保つために必要な動作だ。例えば、同じ口座から同時に引き落としが発生した場合、一方が完了するまで他方は待機する必要がある。

Tx-Bは、Tx-Aがコミット(またはロールバック)するまでブロックされる。Tx-Aが長時間ロックを保持していると、Tx-Bはその間ずっと待ち続ける。

実際にロック競合を起こしてみる

以下は、Goで2つのトランザクションを並行実行し、ロック競合を観察するコード。

// ===== Tx-A: 先にロックを取得し、11秒間保持 =====
go func() {
    tx, _ := db.Begin()
    logf("Tx-A", "BEGIN")

    logf("Tx-A", "UPDATE実行(ロック取得)")
    tx.Exec("UPDATE users SET money = money + 100 WHERE id = 1")

    logf("Tx-A", "ロックを11秒保持中...")
    time.Sleep(11 * time.Second)

    tx.Commit()
    logf("Tx-A", "COMMIT完了")
}()

// ===== Tx-B: 1秒後に開始(Tx-Aのロック解放を待つ) =====
go func() {
    time.Sleep(1 * time.Second) // Tx-Aがロック取得した後に開始

    tx, _ := db.Begin()
    logf("Tx-B", "BEGIN")

    logf("Tx-B", "UPDATE実行(ロック待ち...)")
    start := time.Now()
    tx.Exec("UPDATE users SET money = money + 200 WHERE id = 1") // ← ここでブロック!
    logf("Tx-B", "ロック取得!(%.2f秒待った)", time.Since(start).Seconds())

    tx.Commit()
    logf("Tx-B", "COMMIT完了")
}()

実行結果

[11:25:31.036] [Tx-A] BEGIN
[11:25:31.036] [Tx-A] UPDATE実行(ロック取得)
[11:25:31.036] [Tx-A] ロックを11秒保持中...
[11:25:32.046] [Tx-B] BEGIN
[11:25:32.046] [Tx-B] UPDATE実行(ロック待ち...)
[11:25:42.061] [Tx-A] COMMIT完了
[11:25:42.061] [Tx-B] ロック取得!(10.02秒待った)
[11:25:42.065] [Tx-B] COMMIT完了

Tx-Bは約10秒間ブロックされた。本番環境でTx-Aが何分もロックを保持していたら、Tx-Bはその間ずっと待ち続けることになる。

NOWAITで待たずに即エラーにする

FOR UPDATE NOWAITを使うと、ロックが取得できない場合に待機せず即座にエラーを返す。

SELECT * FROM users WHERE id = 1 FOR UPDATE NOWAIT;

ロックが取得できなければ、以下のエラーが即座に返る。

ERROR: could not obtain lock on row in relation "users"

NOWAITを使ったコード

// ===== Tx-B: 1秒後に開始(NOWAITで即座にエラー) =====
go func() {
    time.Sleep(1 * time.Second)

    tx, _ := db.Begin()
    logf("Tx-B", "BEGIN")

    // ★ FOR UPDATE NOWAIT はロック取得できなければ即エラー
    logf("Tx-B", "SELECT FOR UPDATE NOWAIT 実行...")
    var money int
    err := tx.QueryRow("SELECT money FROM users WHERE id = 1 FOR UPDATE NOWAIT").Scan(&money)
    if err != nil {
        logf("Tx-B", "★ NOWAIT エラー: %v", err)
        tx.Rollback()
        return
    }

    // ロック取得成功した場合のみ、ここに到達
    logf("Tx-B", "ロック取得成功! money=%d", money)
    tx.Exec("UPDATE users SET money = money + 200 WHERE id = 1")
    tx.Commit()
    logf("Tx-B", "COMMIT完了")
}()

実行結果

[11:26:15.309] [Tx-A] BEGIN
[11:26:15.309] [Tx-A] UPDATE実行(ロック取得)
[11:26:15.309] [Tx-A] ロックを11秒保持中...
[11:26:16.323] [Tx-B] BEGIN
[11:26:16.323] [Tx-B] SELECT FOR UPDATE NOWAIT 実行...
[11:26:16.324] [Tx-B] ★ NOWAIT エラー: pq: could not obtain lock on row in relation "users"
[11:26:26.322] [Tx-A] COMMIT完了
[Main] Final money: 1100 (Tx-Bは失敗したので +100 のみ)

Tx-Bは待機せず、即座にエラーとなった。これにより、後続の処理でリトライしたり、ユーザーにエラーを返したりといった対応ができる。

NOWAITのユースケース

ユースケース 説明
在庫確保・座席予約 競合時は「他の人が処理中です」と即座にエラーを返す
リアルタイムAPI SLAが厳しく、待機時間を許容できない場合
ジョブキュー ロック済みの行はスキップし、別の行を処理
デッドロック回避 待機せずエラーにすることで、デッドロックのリスクを軽減

根本解決も忘れずに

NOWAITは便利だが、あくまで応急処置だ。根本的にはロック競合を減らす設計が重要。

トランザクションを短く保つ

// ❌ 悪い例:トランザクション内で外部API呼び出し
tx.Begin()
tx.Exec("UPDATE ...")
callExternalAPI()  // ← これが遅いとロック保持時間が長くなる
tx.Commit()

// ✅ 良い例:外部APIはトランザクション外で
data := callExternalAPI()
tx.Begin()
tx.Exec("UPDATE ... SET data = ?", data)
tx.Commit()

lock_timeoutを設定する

PostgreSQLのlock_timeoutを設定すると、指定時間を超えてロック待ちしている場合にエラーになる。

SET lock_timeout = '5s';

これにより、NOWAITを使わなくても「永遠に待ち続ける」状況を防げる。

まとめ

項目 内容
ロック競合 MVCCでもWriter vs Writerは競合する
根本対策 トランザクションを短く保つ、lock_timeout設定
NOWAIT ロック取得できなければ即エラー(対処法の一つ)

今回作成したコードは以下のリポジトリで公開している。

https://github.com/yasuaki640/pg-lock-demo

以上。

脚注
  1. PostgreSQL Documentation - 13.1. Introduction ↩︎

  2. PostgreSQL Documentation - 13.3. Explicit Locking ↩︎

GitHubで編集を提案
dipテックブログ

Discussion