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 | ロック取得できなければ即エラー(対処法の一つ) |
今回作成したコードは以下のリポジトリで公開している。
以上。
Discussion