図1:予約ブリッジの現行二重予約防止ガイド。2026年8月31日取得。製品説明の所在だけを裏付ける。最後の1枠への同時申込を扱う競合テストは、別の実行記録で確認する。
予約枠の残数が1、同時申込が2件。どちらのリクエストも次の順に動くと、単純な実装は定員を破る。
T1: confirmed count = 0
T2: confirmed count = 0
T1: INSERT
T2: INSERT
SELECTとINSERTを同じトランザクションへ入れるだけでは十分ではない。PostgreSQLの通常のREAD COMMITTEDでは、二つのトランザクションがどちらも定員未満を観測できるからだ。UI側のボタンを無効にしても、別端末、再試行、二重クリックによる別リクエストには効かない。
予約ブリッジで採った方法は、予約枠ごとのトランザクション単位のアドバイザリーロックと条件付きINSERTの組み合わせだった。検証対象は不変条件、鍵、トランザクション、テストの四点に絞る。
守る不変条件
定員をcapacity、確定予約数をconfirmed(slot)とすると、常に次を満たしたい。
0 <= confirmed(slot) <= capacity(slot)
「空き枠一覧を返した時点」ではなく、「予約を確定してコミットする時点」で評価する。空き枠表示と送信の間に別の予約が入るのは正常な競合であり、表示を絶対的な権利として扱わない。
枠固有のロックキー
同じ日時でも予約ページが違えば別の枠である。そこでキー材料を次のように作る。
const lockKey = `${pageId}:${slotStart}`;
PostgreSQL経路ではトランザクション内で次を先に実行する。
SELECT pg_advisory_xact_lock(hashtext($1));
$1へpageId:slotStartを渡す。pg_advisory_xact_lockはトランザクション単位なので、コミットまたはロールバックで解放される。明示的な解除漏れを管理しなくてよい。PostgreSQLのアドバイザリーロックの種類とトランザクション/セッションの違いは公式ドキュメントにまとまっている。
hashtextの衝突は無関係な枠を一時的に直列化するが、全ての書き込み経路が同じ規則なら定員超過の方向には働かない。規模に応じて64ビットのキーも検討する。
ロック取得後に件数を数える
重要なのは、件数確認をロックより前へ置かないことだ。
BEGIN;
SELECT pg_advisory_xact_lock(hashtext($1));
INSERT INTO bookings (
id, page_id, slot_start, slot_end,
name, email, status, created_at
)
SELECT
$2, $3, $4, $5,
$6, $7, 'confirmed', now()
WHERE (
SELECT count(*)
FROM bookings
WHERE page_id = $3
AND slot_start = $4
AND status = 'confirmed'
) < $8;
COMMIT;
先行トランザクションがロックを保持している間、同じ枠の後続トランザクションは待つ。先行側がコミットすると、後続側がロックを得て、その時点の確定件数を読み直す。定員1なら条件付きINSERTは0行になり、アプリケーションはSlotTakenとして扱う。
図2:現行ガイド内の自社実装図。REQUEST Aの確定後にREQUEST Bが件数を読み直し、定員1で満席応答になる流れとSQLの要点を示す。PostgreSQL 17.11の競合結果そのものは保存済みrunで確認する。
件数確認とINSERTを別クエリにしても、同じロック内で実行すれば直列化はできる。ただし条件付きINSERTへまとめると、アプリケーション側の分岐とDB操作の間を狭くできる。戻り行数が0なら満席である。
定員と競合を分けてテストする
逐次テストでは定員1の二件目と定員2の三件目がSlotTakenになることを固定する。これだけではロック競合を証明できない。
2026年8月31日のPostgreSQL 17.11統合テストでは、異なるbackend PIDの二トランザクションを開始してからbarrierで同時解放した。先行側がadvisory lockを保持している間、pg_stat_activityで後続側のwait_event_type=Lock、wait_event=advisory、先行PIDによるblockを確認した。その後の結果は成功一件、SlotTakenError一件、別queryで確認した最終確定数は一件だった。保存したrun IDはkh-pg-race-20260831-evidence-01である。
このfixtureが直接裏付けるのは、同じ鍵でのロック待機と定員一件の不変条件である。次の境界は別のfixtureとして追加する必要があり、この一回の記録から合格とは扱わない。
- 先行トランザクションをロールバックした場合の後続受付
- 異なるページまたは枠が待たないこと。ただしハッシュ衝突は例外
- キャンセル済み行を確定件数へ数えない条件
- ロックタイムアウト時に予約完了を返さない応答
SQLiteの単一書き込み処理で通った結果をPostgreSQLの排他保証として扱わない。SQL関数、分離レベル、接続の違いを含むため、本番と同じDBドライバーで競合テストを持つ。
アドバイザリーロックで守れない経路
この方式は協調ロックである。管理ツールや別APIがロックなしで確定行を追加すれば不変条件は破れる。全ての書き込み経路を一つのリポジトリ関数へ集約し、slotStartはエポック秒へ正規化する。監視対象はロック待機、0行INSERT、タイムアウト、枠キーであり、氏名やメールアドレスはログへ含めない。
実装対象との関係
この記事のトランザクションとテストは、Fermionが運営する予約ブリッジ for kintoneの実装を題材にしている。所属を隠した一般論ではない。製品側の業務説明と更新履歴は二重予約防止の公式ガイドへ集約する。
公式ガイドを現行の製品説明として参照し、実装例に誤りがある場合はFermionお問い合わせへ、PostgreSQLのバージョン、分離レベル、再現手順を添えて知らせてほしい。デモ画面だけで同時実行の保証を判断せず、競合テストの実行記録を確認する。

