予約のキャンセル待ちを実装する。空きが出たら順番に案内して席を取り置く方式と、全員に知らせて早い者勝ちにする方式を、同時の取り消しと返事の期限切れのテストで比べる
満席の
この
満席の
キャンセル待ちは「順番に案内」か「全員に知らせる」かを先に決める
キャンセル待ちの
| 決めること | 順番に |
全員に |
|---|---|---|
| 空きが |
申込の |
待っている |
| 席の扱い | 返事の |
すぐに |
| 返事の期限 | 必要。 |
不要 |
| 定期の処理 | 期限切れを |
要らない |
| 断られる人 | 期限を |
知らせた |
| 向いている |
申込順を |
開始が |
順番に
テーブルは、枠・予約・キャンセル待ち・送る連絡の控えの4つ
テーブルはbooked にはbooked < capacity の
-- 予約枠。booked は「予約済み+キャンセル待ちの人に取り置いている数」
CREATE TABLE slots (
id INTEGER PRIMARY KEY,
capacity INTEGER NOT NULL,
booked INTEGER NOT NULL DEFAULT 0 CHECK (booked BETWEEN 0 AND capacity)
);
CREATE TABLE bookings (
id INTEGER PRIMARY KEY,
slot_id INTEGER NOT NULL REFERENCES slots(id),
customer TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' -- active / cancelled
);
-- キャンセル待ち。id の小さい順が申込順
CREATE TABLE waitlist (
id INTEGER PRIMARY KEY,
slot_id INTEGER NOT NULL REFERENCES slots(id),
customer TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'waiting', -- waiting / offered / accepted / expired
offer_expires_at INTEGER -- 案内の返事の期限(UTCのミリ秒)
);
-- 送る連絡の控え。トランザクションの中では書くだけで、送るのは確定した後
CREATE TABLE outbox (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
kind TEXT NOT NULL,
sent_at INTEGER
);キャンセル待ちの
outbox は、
取り消し・案内・期限切れは、条件つきのUPDATEで1回で書き換える
順番にBEGIN IMMEDIATE でWHERE にRETURNING で
// server.mjs(抜粋)。順番に1人ずつ案内する(席を取り置く)
const HOLD_MS = 30 * 60_000; // 案内してから返事を待つ時間(30分)
// 次の人に案内する。待っている人がいなければ、取り置きをやめて席を一般に戻す
// 呼ぶ側のトランザクションの中で使う
function offerNext(slotId, now) {
const next = db
.prepare(`UPDATE waitlist SET status = 'offered', offer_expires_at = ?
WHERE id = (SELECT id FROM waitlist WHERE slot_id = ? AND status = 'waiting' ORDER BY id LIMIT 1)
RETURNING id, customer`)
.get(now + HOLD_MS, slotId);
if (next) {
db.prepare("INSERT INTO outbox (customer, kind) VALUES (?, 'offer')").run(next.customer);
return next.customer;
}
db.prepare("UPDATE slots SET booked = booked - 1 WHERE id = ?").run(slotId);
return null;
}
function tx(fn) {
db.exec("BEGIN IMMEDIATE");
try {
const r = fn();
db.exec("COMMIT");
return r;
} catch (err) {
if (db.isTransaction) db.exec("ROLLBACK");
throw err;
}
}
// 予約の取り消し。取り消せたら、同じトランザクションで次の人に案内する
const cancel = ({ booking, now }) =>
tx(() => {
const b = db
.prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ? AND status = 'active' RETURNING slot_id")
.get(booking);
if (!b) return [409, { error: "取り消し済み" }];
return [200, { offeredTo: offerNext(b.slot_id, now) }];
});
// 案内を受けた人の「予約する」。期限内で、まだ案内中のときだけ通る
const accept = ({ wait, now }) =>
tx(() => {
const w = db
.prepare(`UPDATE waitlist SET status = 'accepted'
WHERE id = ? AND status = 'offered' AND offer_expires_at > ?
RETURNING slot_id, customer`)
.get(wait, now);
if (!w) return [409, { error: "期限切れ、または案内中ではありません" }];
// 席は取り置き済みなので、booked は増やさない
db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(w.slot_id, w.customer);
return [201, { ok: true }];
});
// 期限切れの案内を締めて、次の人へ回す(定期実行の処理)
const sweep = ({ now }) =>
tx(() => {
const expired = db
.prepare(`UPDATE waitlist SET status = 'expired'
WHERE status = 'offered' AND offer_expires_at <= ?
RETURNING slot_id, customer`)
.all(now);
return [200, { expired: expired.map((e) => e.customer), offeredTo: expired.map((e) => offerNext(e.slot_id, now)) }];
});取り消しでは、booked を
予約する
SQLiteのBEGIN IMMEDIATE はSQLITE_BUSY にPRAGMA busy_timeout = 5000 をSQLITE_BUSY をRETURNING は、
全員に知らせる方式は、空きを数える条件つきのUPDATEだけで守れる
全員にbooked をoutbox にbooked < capacity を
// server.mjs(抜粋)。待っている全員に知らせて、早い者勝ちにする
const cancelBroadcast = ({ booking }) =>
tx(() => {
const b = db
.prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ? AND status = 'active' RETURNING slot_id")
.get(booking);
if (!b) return [409, { error: "取り消し済み" }];
db.prepare("UPDATE slots SET booked = booked - 1 WHERE id = ?").run(b.slot_id);
const n = db
.prepare(`INSERT INTO outbox (customer, kind)
SELECT customer, 'vacancy' FROM waitlist WHERE slot_id = ? AND status = 'waiting'`)
.run(b.slot_id).changes;
return [200, { notified: n }];
});
const claim = ({ wait }) =>
tx(() => {
const w = db.prepare("SELECT slot_id, customer FROM waitlist WHERE id = ? AND status = 'waiting'").get(wait);
if (!w) return [409, { error: "受付済み" }];
const r = db.prepare("UPDATE slots SET booked = booked + 1 WHERE id = ? AND booked < capacity").run(w.slot_id);
if (r.changes === 0) return [409, { error: "先に埋まりました" }];
db.prepare("UPDATE waitlist SET status = 'accepted' WHERE id = ?").run(wait);
db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(w.slot_id, w.customer);
return [201, { ok: true }];
});この
同時の取り消しと、期限ちょうどの「予約する」で、素朴な作りと比べた
比べる
// 比較用:状態の判定をSELECTで行い、少し処理を挟んでから書く素朴な作り
async function naiveAccept({ wait, now }) {
const w = db.prepare("SELECT slot_id, customer, status, offer_expires_at FROM waitlist WHERE id = ?").get(wait);
if (w.status !== "offered" || w.offer_expires_at <= now) return [409, { error: "期限切れ" }];
await sleep(DELAY);
db.prepare("UPDATE waitlist SET status = 'accepted' WHERE id = ?").run(wait);
db.prepare("INSERT INTO bookings (slot_id, customer) VALUES (?, ?)").run(w.slot_id, w.customer);
return [201, { ok: true }];
}テストのnode:cluster で
- シナリオ1:定員1の
枠で 予約が 取り消され、 待ち1に 案内した 後、 待ち1の 「予約する」 (期限の 1ミリ秒前)と、 期限切れを 締める 処理 (期限ちょうど)を 同時に 送る。 案内が 待ち2に 回っていれば、 待ち2も 期限内に 「予約する」を 押す - シナリオ2:定員2の
枠で、 2件の 予約の 取り消しを 同時に 送る (待っている 人は 5人) - シナリオ3:全員に
知らせる 方式で、 定員1の 枠の 取り消しの 後、 待っている 20人が 同時に 「予約する」を 押す
結果です。
シナリオ1 素朴な作り
50回 案内中[待ち2] → 最後の有効な予約2件(定員1) 定員超え
シナリオ1 条件つきの更新
40回 案内中[] → 最後の有効な予約1件(定員1)
10回 案内中[待ち2] → 最後の有効な予約1件(定員1)
シナリオ2 素朴な作り
50回 案内中[待ち1]、案内の連絡[待ち1,待ち1]、取り置き2席
シナリオ2 条件つきの更新
50回 案内中[待ち1,待ち2]、案内の連絡[待ち1,待ち2]、取り置き2席
シナリオ3 全員に知らせて早い者勝ち
50回 知らせた20人、受付1人・「先に埋まりました」19人、有効な予約1件
サーバーのエラー(500): 0件シナリオ1の
シナリオ2の
シナリオ3は、
返事の期限は、お客様に見せる時刻を少し早くする
シナリオ1の
これを
返事の
案内の連絡は、確定した後に控えから送る
outbox にsent_at にsending_at などの
LINEで
キャンセル待ちに
SQLiteで試して気づいたこと
テストをDELETE FROM slots をFOREIGN KEY constraint failed で
SQLiteのnode:sqlite の DatabaseSync は、enableForeignKeyConstraints のnode:sqlite の
なnode:sqlite は、
作る前に、店舗や式場の側で決めておくこと
キャンセル待ちの
| 決めること | 選択肢の例 |
|---|---|
| 方式 | 順番に |
| 返事の期限 | 案内から |
| 待てる人数 | 枠ごとに |
| 案内の手段 | メール・SMS・LINEの |
| 期限切れの |
キャンセル待ちから |
| 電話や |
窓口の |
| 連絡先を |
枠が |
最後からcancel の
株式会社bundlyzeでは、
動作確認した環境
確認日は
- macOS 26.6.2、
Node.js 22.23.2 ( node:sqlite、node:cluster、組み込みの fetch) node:sqliteに組み込まれた SQLiteの バージョン:3.51.3 ( SELECT sqlite_version()で確認)- SQLiteの
ファイルは WAL モード、 各プロセスで PRAGMA busy_timeout = 5000 - 3つの
シナリオを 各50回、 全体を 5回流しました。 サーバーの エラー (500)は、 run.mjsで数えて 5回とも 0件でした
確かめていない
- PostgreSQLなど、
ほかの データベースでの 同時の 動き - 複数台の
サーバーや、 サーバーレスの 環境での 動き (手元の 1台で、 プロセスを 4つ立てただけです) outboxから実際に LINEや メールで 送る 処理 (控えを 書く ところまでを 確かめました) - 期限切れを
締める 処理を、 Cron などで 定期的に 起こす部分 (テストでは 直接呼んでいます)
参照した公式ドキュメント(確認日:2026年10月6日)
SQLiteと
BEGIN IMMEDIATEがトランザクションの 最初から 書き込みを 始める こと、 ほかの 接続が 書き込み中なら SQLITE_BUSYになり得る こと → SQLite 「Transaction」 RETURNINGがSQLite 3.35.0 (2021-03-12)から 使える こと → SQLite 「RETURNING」 busy_timeoutで、ロックされている ときに 合わせて 指定した 時間まで 待ち、 過ぎたら SQLITE_BUSYを返す こと → SQLite 「PRAGMA busy_timeout」「Set A Busy Timeout」 - SQLiteでは
外部キーの 制約が 既定で 無効である こと → SQLite 「SQLite Foreign Key Support」 node:sqliteの安定度 (Stability: 1.1 - Active development)、 v22.13.0 から フラグなしで 使えるが まだ 実験的である こと、 enableForeignKeyConstraintsの既定が true である こと → Node.js 「SQLite」
よくある質問
キャンセル待ちは、順番に案内する方式と、全員に知らせる方式のどちらがよいですか?
申込順を
取り消しの処理の中で、そのままLINEやメールで案内を送ってもよいですか?
避けます。
返事の期限ちょうどに「予約する」が押されたら、どちらが優先されますか?
データベースが
SQLiteで試した結果は、PostgreSQLでも同じですか?
考え方は