PostgreSQL の advisory lock を排他制御に使うとき、pg.Pool から取った接続でロックを取り、処理が終わったら unlock して接続をプールへ返す、という書き方をします。advisory lock は、行やテーブルではなくアプリケーションが決めた整数キーに対してかける協調的なロックで、「同じジョブを 2 重に実行しない」「同じリソースへの同時アクセスを防ぐ」といった用途に使えます。
次のコードは、よくある最小形です。finally で unlock してから client.release() で接続をプールへ返します。
const lockKey = 12345n;
const client = await pool.connect();
try {
await client.query("SELECT pg_try_advisory_lock($1::bigint)", [lockKey]);
// ロックが必要な処理
await client.query("SELECT pg_advisory_unlock($1::bigint)", [lockKey]);
} finally {
client.release();
}
ここで使っている pg_try_advisory_lock は、接続(セッション)が生きている間ロックを保持し続ける「セッションレベル」の advisory lock です。明示的に pg_advisory_unlock を呼ぶか、接続が切れるまでロックは残ります。正常系、つまり unlock が成功する限りはこのコードで問題ありません。unlock でロックが解放され、きれいな状態の接続がプールへ戻ります。
問題は、unlock が成功しなかったときに起きます。unlock に失敗した接続をそのまま client.release() でプールへ戻すと何が起きるのか、なぜそれが次のリクエストに波及するのかを、この記事で扱います。あわせて client.release(true) で接続を破棄する解決策と、その方法で防げないことを示します。対象は PostgreSQL 12 以降と node-postgres (pg) 8.x です。
unlock に失敗した接続をそのまま release() するとどうなるか
先ほどのコードを、unlock の失敗を考慮した形に少し書き換えます。unlock の結果を確認せず、finally でただ release() するパターンです。
const client = await pool.connect();
try {
await client.query("SELECT pg_try_advisory_lock($1::bigint)", [lockKey]);
// ロックが必要な処理
} finally {
// unlock の成否を見ずにプールへ戻している
await client.query("SELECT pg_advisory_unlock($1::bigint)", [lockKey]);
client.release();
}
このコードには、unlock が成功しないケースが 2 つあり、どちらも release() でプールへ戻ってしまいます。
1 つは、unlock クエリ自体が例外を投げるケースです。処理の途中で接続が切れかけていたり、ネットワークが一時的に切断されたりすると、pg_advisory_unlock のクエリがエラーになります。この場合、finally 内の client.query(...) が例外を投げるため、client.release() まで到達しません。つまり接続はプールにも戻らず宙ぶらりんになるか、別の try/catch の組み方によってはロックを解放できたか不明なままプールへ戻ります。例外が投げられたケースでは、ロックが解放できたかどうかは確認できません。
もう 1 つは、pg_advisory_unlock が例外ではなく false を返すケースです。pg_advisory_unlock は、対象のロックをその接続が保持していなかった場合、エラーではなく false を返します。上のコードは戻り値を見ていないので、false が返っても気づかずに release() してしまいます。
この 2 つは意味が違います。false が返ったケースは「その接続はそのロックをそもそも保持していなかった」ことを示すので、ロックが残っているわけではありません。一方、例外が投げられたケースは解放の成否そのものが不明です。どちらも「unlock が確実に成功したとは言えない」状態であり、後述するようにまとめて安全側に倒して扱います。
ロックを保持した接続が次のリクエストで再利用される
unlock が確実に成功していない接続をプールへ戻すと、その接続は次のリクエストに渡されます。pg.Pool は、release() で戻された接続を別の pool.connect() の呼び出し元に再利用するためです。
Request 1:
pool.connect() で接続 C を取得
pg_try_advisory_lock(key) に成功 → 接続 C がロックを保持
処理中に接続が一時切断、pg_advisory_unlock(key) が例外で失敗
client.release() で接続 C がプールへ戻る(ロックを保持したまま)
Request 2:
pool.connect() で同じ接続 C を取得
pg_try_advisory_lock(key) を呼ぶ
接続 C は同じ key のロックをすでに保持しているので true が返る
→ 排他のつもりが、ロックを取れていないのに「取れた」と判定される
ここで起きているのは、参照カウントの話ではなく「前のリクエストのロックが残った接続を、別のリクエストが取得してしまう」ことです。Request 2 は新しくロックを取ったつもりですが、実際には Request 1 が残したロックを引き継いだだけです。pg_try_advisory_lock は、同じ接続がすでにそのキーのロックを保持していると true を返すため、排他が働いているように見えて実際には働いていません。
排他制御として使っている以上、これは「ロックが取れていないのに取れたと判定される」という形で後続の処理に影響します。
セッションレベル advisory lock は接続にひもづき、参照カウントで管理される
再利用問題を理解するうえで、セッションレベル advisory lock の 2 つの性質を分けて確認しておきます。これらは別々の事象です。
1 つ目は、ロックが接続(セッション)にひもづくことです。セッションレベルの advisory lock は、その接続が生きている間ずっと保持されます。明示的に unlock するか、接続が切れて初めて解放されます。前節の再利用問題は、この「接続が生きている限りロックが残る」性質から来ています。接続がプールで使い回される限り、その接続のロックも使い回されます。
2 つ目は、同一接続・同一キーの多重 lock が参照カウント(保持回数)で管理されることです。同じ接続で同じキーを複数回ロックすると、保持回数が増え、その回数分だけ unlock しないと解放されません。
同じ接続・同じキーで:
1 回目の lock: 保持回数 0 → 1
2 回目の lock: 保持回数 1 → 2
1 回 unlock: 保持回数 2 → 1(まだ解放されていない)
もう 1 回 unlock: 保持回数 1 → 0(ここで解放)
この参照カウントは、コード側で同じ接続・同じキーをわざと多重ロックする実装でのみ問題になります。1 接続あたり 1 回しか lock しない実装なら、保持回数が 2 以上になることはありません。前節の再利用問題は参照カウントとは独立した事象です。保持回数が 1 のままでも「ロックが残った接続を再利用する」だけで起きます。両者を混同すると、「unlock を 2 回呼べば直る」といった見当違いの対処に向かいやすいので、切り分けて理解しておきます。
unlock の失敗の 2 形態
pg_try_advisory_lock と pg_advisory_unlock は、いずれも失敗を例外ではなく戻り値で表す場合があります。
| 呼び出し | 成功時 | 失敗時 |
|---|---|---|
pg_try_advisory_lock(key) |
true(ロック取得) |
false(他セッションが保持中で取れない) |
pg_advisory_unlock(key) |
true(解放した) |
false(その接続がそのロックを保持していない) |
| いずれか(接続断・通信エラー時) | — | クエリが例外を投げる |
pg_try_advisory_lock は、他のセッションがそのキーのロックを保持していて取れないとき、待たずに false を返します(try の付かない pg_advisory_lock は取れるまで待ちます)。pg_advisory_unlock は、その接続が対象のロックを保持していないとき false を返します。これらに加えて、接続断や通信エラーが起きるとクエリ自体が例外になります。
unlock 側で重要なのは、false と例外で「ロックが残っているか」の意味が違うことです。false は「そもそもこの接続は保持していなかった」なので残っていません。例外は「解放できたか不明」です。コードでは戻り値の確認と例外の捕捉の両方を行い、どちらの場合も「unlock が確実に成功したとは言えない」として扱います。
unlock 失敗時の client.release(true) による接続の破棄
unlock が確実に成功しなかったときだけ、接続をプールへ戻さず破棄します。node-postgres では、client.release(true) のように truthy な引数を渡すと、その接続はプールへ返されず破棄されます。引数なしの client.release() はプールへの返却です。
破棄するかどうかをフラグで持ち、unlock の戻り値が false のときと例外が投げられたときの両方でフラグを立てます。
const client = await pool.connect();
let shouldDestroyClient = false;
let lockAcquired = false;
try {
const result = await client.query(
"SELECT pg_try_advisory_lock($1::bigint) AS locked",
[lockKey],
);
if (!result.rows[0].locked) {
// 他のセッションが保持中。ロックは取れていないので破棄は不要
throw new Error("lock_not_acquired");
}
lockAcquired = true;
// ロックが必要な処理
} finally {
if (lockAcquired) {
try {
const unlockResult = await client.query(
"SELECT pg_advisory_unlock($1::bigint) AS unlocked",
[lockKey],
);
if (!unlockResult.rows[0].unlocked) {
// false: この接続はこのロックを保持していなかった
shouldDestroyClient = true;
console.error("advisory_unlock_returned_false", { lockKey });
}
} catch (err) {
// 例外: 解放の成否が不明
shouldDestroyClient = true;
console.error("advisory_unlock_failed", err);
}
}
client.release(shouldDestroyClient);
}
shouldDestroyClient が true になるのは、unlock が false を返したか、unlock クエリが例外を投げたときだけです。正常に unlock できたときは false のままなので、client.release(false) 相当となり、接続は通常どおりプールへ戻ります。毎回破棄するわけではありません。
接続を破棄すると、PostgreSQL 側ではその接続のセッションが終了します。セッションレベルの advisory lock は接続が切れた時点で自動的に解放されるため、破棄によってロックも確実に解放されます。これはセッションレベルロックが接続(セッション)にひもづくという PostgreSQL の仕様に依存した解決策です。false(残っていない)と例外(成否不明)のどちらも安全側に倒して破棄することで、ロックを保持したままの接続がプールへ戻る経路をなくします。
この方法で防げること
client.release(true) で破棄することで、unlock が確実に成功していない接続がプールへ戻る経路がなくなります。これにより、次の連鎖を断ち切れます。
- ロックを保持したままの接続がプールに残ること
- その接続が次のリクエストで再利用され、
pg_try_advisory_lockが「すでに自分が保持している」ためにtrueを返してしまうこと - 結果として、排他が働いていないのに働いているように見える誤判定が後続のリクエストへ波及すること
unlock が例外で落ちて解放成否が不明なケースも、破棄すればセッション終了でロックが解放されるため、汚染された接続を後続へ渡さずに済みます。
この方法で防げないこと
この方法は「unlock が確実に成功していない接続をプールへ戻さない」という後始末であり、次のものは防げません。
プールの枯渇は防げません。release(true) は接続を 1 つ破棄するため、破棄された分はプールが新しい接続を張り直すことになります。release(true) を毎回呼ぶ運用にするとプールを使う利点が消え、接続確立のコストが増え、設定によっては接続数の上限に近づきます。だからこそ破棄は unlock 失敗時に限定し、正常系では通常の release() でプールへ戻します。
ロック取得そのものの失敗も、ここで扱う後始末の対象外です。他のセッションがロックを保持していて pg_try_advisory_lock が false を返すケースは、この接続がロックを取れなかっただけで、ロックを保持していません。この場合は破棄不要です(上のコードでは lock_not_acquired を投げ、lockAcquired が false のままなので破棄しません)。
ロックを取った後の業務処理が失敗すること自体も、この後始末とは別の問題です。ここで扱っているのは「ロックを保持した接続を再利用させない」ことだけです。
例外時にロックが残っていたかどうかの判別もできません。unlock が例外で落ちたとき、ロックが解放できていたのか残っていたのかは判別できません。判別する代わりに、安全側に倒して破棄しています。
ロック範囲がトランザクション内なら xact lock を使う
ここまではセッションレベルの advisory lock を前提にしましたが、ロックの有効期間がトランザクション内に収まるなら、トランザクションレベルの advisory lock を使うと、ここまでの後始末そのものが不要になります。これは中心の主張(unlock 失敗時の破棄)とは別の選択肢ですが、問題自体を回避できる発展的な代替として挙げておきます。
pg_try_advisory_xact_lock で取ったロックは、トランザクションが COMMIT または ROLLBACK された時点で自動的に解放されます。pg_advisory_unlock を呼ぶ必要がないため、unlock の失敗という事象自体が発生しません。
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await client.query(
"SELECT pg_try_advisory_xact_lock($1::bigint) AS locked",
[lockKey],
);
if (!result.rows[0].locked) {
throw new Error("lock_not_acquired");
}
// ロックが必要な処理
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
client.release();
}
適用条件は、ロックの保持範囲が 1 つのトランザクションに収まることです。トランザクションをまたいでロックを保持したい場合(複数のトランザクションにわたって排他を維持したい場合)は、トランザクション終了で解放される xact lock では実現できません。その場合はセッションレベルのロックを使い、前述の release(true) による後始末を組み合わせます。
まとめ
セッションレベルの advisory lock は接続にひもづいて保持されるため、unlock が確実に成功していない接続を client.release() でプールへ戻すと、ロックを保持したままの接続が次のリクエストで再利用されます。
unlock の失敗には「pg_advisory_unlock が false を返す(その接続が保持していなかった)」と「クエリが例外を投げる(解放成否が不明)」の 2 形態があります。意味は違いますが、どちらも安全側に倒して扱います。
unlock 成功(true):
client.release() … 通常どおりプールへ戻す
unlock が false / 例外:
client.release(true) … プールへ戻さず破棄(セッション終了でロック解放)
release(true) は接続を破棄するためプール枯渇の要因になりえます。毎回破棄するのではなく、unlock が確実に成功しなかったときだけに限定します。ロックの保持範囲がトランザクション内に収まるなら、pg_try_advisory_xact_lock を使うと unlock 自体が不要になり、この後始末を考えずに済みます。