IT技術ブログ

予約のキャンセル待ちを実装する。空きが出たら順番に案内して席を取り置く方式と、全員に知らせて早い者勝ちにする方式を、同時の取り消しと返事の期限切れのテストで比べる

満席の枠にキャンセル待ちを付けるときの実装です。順番に案内して席を取り置く方式と、全員に知らせる早い者勝ちの方式を、同時の取り消しや返事の期限切れと重なる場面で動かし、定員超えと二重の案内が起きる作りと防ぐ作りを比べました。

この記事の結論:キャンセル待ちは、「空きを見てから案内を書き込む」という素朴な作りにすると、同時に届いた取り消しや、返事の期限切れと「予約する」が重なった瞬間に壊れます。手元で同時のリクエストを送るテストでは、素朴な作りは50回中50回、同じ人への二重の案内や、定員1の枠に2件の予約という結果になりました。状態の判定と書き換えを1つの条件つきUPDATEにまとめ、取り消し・案内・期限切れを同じトランザクションで行う作りでは、どの回も定員と申込順が守られました。案内の連絡はトランザクションの中では送らず、「送る連絡の控え」に書いて、確定した後に送ります。

満席の枠にキャンセル待ちを付けたい場面は、業種を問わずあります。ジムやスタジオの人気のクラス、整体院の指名の枠、結婚式場の人気のフェア、説明会の座席などです。この記事は、予約の仕組みを作る・作り直す開発の方と、キャンセル待ちを入れたいと考えている店舗や式場の方に向けて、2つの方式の作りと、壊れやすい場面のテストをまとめました。同じ枠への同時の申込で定員を守る基本は、予約の二重受付を防ぐ記事で扱ったので、ここではキャンセル待ちに特有の「案内」「取り置き」「返事の期限」に絞ります。コードとテストは2026年10月6日に、手元のMacで実際に動かしています。

キャンセル待ちは「順番に案内」か「全員に知らせる」かを先に決める

キャンセル待ちの作りは、空きが出たときに誰に知らせるかで大きく2つに分かれます。どちらにするかで、テーブルの持ち方も、お客様に見せる画面も変わるので、作り始める前に決めます。

決めること 順番に案内する(取り置きあり) 全員に知らせる(早い者勝ち)
空きが出たとき 申込の早い1人に案内し、その人のために席を取り置く 待っている全員に「空きが出ました」と知らせる
席の扱い 返事の期限まで、ほかの人は予約できない すぐに一般の予約にも開く
返事の期限 必要。切れたら次の人に案内する 不要
定期の処理 期限切れを締めて次へ回す処理が要る 要らない
断られる人 期限を過ぎた人だけ 知らせた人のうち、1人を除いた全員
向いている場面 申込順を守ると約束している、枠の数が少ない 開始が近く、取り置きの時間がない

順番に案内する方式は、申込順という約束を守れる代わりに、返事が来ない間は席が空いたままになります。開始の直前に空いた席は、取り置きの期限が切れるころには開始してしまうので、「開始の何時間前からは全員に知らせる方式に切り替える」など、時刻で方式を切り替える決め方もあります。予約の締め切りを日本時間で正しく判定する作りは、予約の空き枠と締め切りを日本時間で扱う記事にまとめています。

テーブルは、枠・予約・キャンセル待ち・送る連絡の控えの4つ

テーブルは4つにしました。枠の booked には「予約済みの数」と「キャンセル待ちの人のために取り置いている数」を合わせて持たせ、一般の予約の受付は、これまでどおり booked < capacity のときだけ通します。取り置き中の席は満席に見えるので、一般の予約に横取りされません。

sql
-- 予約枠。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
);

キャンセル待ちの状態は、待っている(waiting)・案内中(offered)・予約になった(accepted)・期限切れ(expired)の4つです。状態を1つの列で持ち、書き換えるときは必ず「今の状態がこれなら」という条件を付けます。この条件が、同時の処理で壊れないための要になります。

outbox は、LINEやメールで送る連絡の控えです。取り消しのトランザクションの中で「誰に、何を送るか」をこの表に書き、トランザクションが確定した後で、別の処理が読んで送ります。ロックを持ったまま外部のAPIを待たずに済み、トランザクションが失敗したときには控えも一緒に消えるので、「記録にない案内を送ってしまう」ことがありません。

取り消し・案内・期限切れは、条件つきのUPDATEで1回で書き換える

順番に案内する方式の処理は、取り消し(cancel)・予約する(accept)・期限切れを締める(sweep)の3つです。どれも BEGIN IMMEDIATE で書き込みのロックを先に取り、状態の判定と書き換えを、WHERE に条件を付けた1つのUPDATEで行います。書き換えた行は RETURNING で受け取ります。

js
// 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)) }];
  });

取り消しでは、予約を cancelled にした同じトランザクションで、待っている人の先頭を offered にします。待っている人がいなければ、取り置きをやめて booked を1つ減らし、席を一般の予約に戻します。どの道を通っても、「取り消された席」は「誰かへの取り置き」か「一般に戻した空き」のどちらかになり、宙に浮きません。

予約する(accept)と期限切れを締める処理(sweep)は、同じ行を取り合います。accept は「案内中で、期限前」のときだけ accepted に、sweep は「案内中で、期限を過ぎた」ときだけ expired に書き換えるので、先に書き換えた方が勝ち、後の方は条件に合わずに何も書き換えません。

SQLiteのドキュメントでは、BEGIN IMMEDIATE はトランザクションの最初から書き込みを始める指定で、ほかの接続が書き込み中なら SQLITE_BUSY になり得ると説明されています。テストでは PRAGMA busy_timeout = 5000 を設定し、ロックされていたら合わせて5秒まで待ち、それでも空かなければ SQLITE_BUSY を返すようにしました。RETURNING は、SQLite 3.35.0 から使える書き方です。

全員に知らせる方式は、空きを数える条件つきのUPDATEだけで守れる

全員に知らせる方式では、取り消しで booked を1つ減らして席を開き、待っている全員分の控えを outbox に書きます。知らせを受けた人の「予約する」は、一般の予約と同じく booked < capacity を条件にしたUPDATEで受け、更新できたときだけ予約にします。

js
// 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 }];
  });

この方式で決めておきたいのは、断られる人への見せ方です。20人に知らせれば、19人は「先に埋まりました」を見ます。お知らせの文面に「先着順です」と書く、断られた人をキャンセル待ちのまま残す、などを決めておかないと、お客様には「案内が来たのに予約できない」不具合に見えます。

同時の取り消しと、期限ちょうどの「予約する」で、素朴な作りと比べた

比べるために、同じ処理を「先にSELECTで状態を読み、少し処理を挟んでからUPDATEで書く」素朴な作りでも書きました。読んでから書くまでの間に20ミリ秒の待ちを入れているのは、本番ではここで通知の文面を作ったり、外部のAPIを呼んだりするからです。

js
// 比較用:状態の判定を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 でHTTPサーバーのプロセスを4つ立て、それぞれが別の接続でSQLiteのファイルを開きます。そこへ同時にリクエストを送り、1回ごとにデータを入れ直して、同じシナリオを50回繰り返しました。時刻は、期限の前後を狙って送れるよう、テストではリクエストの中で渡しています。

  • シナリオ1:定員1の枠で予約が取り消され、待ち1に案内した後、待ち1の「予約する」(期限の1ミリ秒前)と、期限切れを締める処理(期限ちょうど)を同時に送る。案内が待ち2に回っていれば、待ち2も期限内に「予約する」を押す
  • シナリオ2:定員2の枠で、2件の予約の取り消しを同時に送る(待っている人は5人)
  • シナリオ3:全員に知らせる方式で、定員1の枠の取り消しの後、待っている20人が同時に「予約する」を押す

結果です。全体を5回流し、下に載せたのは1回目の出力です。5回で内訳が揺れたのは、シナリオ1の条件つきの更新だけでした。

シナリオ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の素朴な作りでは、待ち1の「予約する」と期限切れの処理が、どちらも「案内中」を読んでから書いたため、待ち1が予約になったうえに待ち2にも案内が回り、定員1の枠に有効な予約が2件できました。条件つきの更新では、待ち1の「予約する」が先に通った回(1回目は34回)は案内が止まり、期限切れの処理が先に通った回(同16回)は待ち1が断られて待ち2に回りました。どちらでも有効な予約は1件です。5回の内訳は、34と16、33と17、36と14、34と16、35と15でした。同じ日にほかの機会に流したときは、期限切れが先に通った回が3〜14回のこともあり、どちらが先にロックを取るかは、そのときのマシンの状態で変わります。

シナリオ2の素朴な作りでは、2つの取り消しが同じ「待っている人の先頭」を読んだため、待ち1に案内が2通送られ、待ち2には案内が行きませんでした。取り置きは2席のままなので、1席は誰にも案内されないまま取り置かれ続けます。条件つきの更新では、2つの取り消しが順番に処理され、待ち1と待ち2に1通ずつ案内が届きました。

シナリオ3は、20人が同時に押しても、受付は1人だけでした。全員に知らせる方式は、空きを数える条件つきのUPDATEがあれば、定員は守れます。

返事の期限は、お客様に見せる時刻を少し早くする

シナリオ1の条件つきの更新で、50回のうち十数回は「期限の1ミリ秒前に押した待ち1」が断られました。押した時刻は期限の前でも、期限切れの処理が先にロックを取れば、その時点で案内は締められているからです。データとしては正しい結果ですが、お客様から見ると「期限内に押したのに断られた」ことになります。

これを避けるには、お客様に見せる期限を、実際に締める時刻より少し早く書きます。たとえば、案内の文面には「30分以内にご予約ください」と書き、締める処理は35分を過ぎてから行う、といった形です。期限切れを締める処理は、Cron などで数分おきに動かすので、締める時刻にはもともと数分の幅があります。その幅も見込んで、文面の時刻を決めます。

返事の期限をどのくらいにするかは、業種で変わります。開始まで日のある結婚式場のフェアなら長めに、当日のクラスなら短めにし、「開始の何時間前を過ぎたら取り置きをやめて全員に知らせる」の線も一緒に決めます。キャンセルの期限や振替のルールとの関係は、ジム・スタジオの予約の運用ルールの記事にまとめています。

案内の連絡は、確定した後に控えから送る

outbox に書いた控えは、トランザクションが確定した後に、別の処理が読んで送ります。送れたら sent_at に時刻を書き、送れなかったものは次の回に送り直します。送る処理が2つ同時に動いても同じ控えを二重に送らないよう、この記事の表に sending_at などの列を足し、送る前に条件つきのUPDATEで「送信中」を書き込めた処理だけが送る作りにします(この部分は今回のテストでは動かしていません)。

LINEで送る場合の、定期の処理の起こし方、二重に送らない工夫、失敗の記録の分け方は、前日リマインドをLINEで送る記事で書いた作りがそのまま使えます。キャンセル待ちの案内は、予約に伴う連絡として送り、ほかのクラスやフェアの宣伝は混ぜません。

キャンセル待ちに登録したお客様の連絡先は、枠が終わった後には使い道がなくなります。いつまで残し、いつ消すかも、テーブルを作るときに一緒に決めておきます。

SQLiteで試して気づいたこと

テストを書く中で、SQLiteとNode.jsの組み合わせで、知らないとつまずく点が1つありました。データを入れ直すために DELETE FROM slots を先に実行したところ、FOREIGN KEY constraint failed で止まりました。

SQLiteのドキュメントでは、外部キーの制約は既定では無効と書かれています。一方、Node.jsの node:sqlite の DatabaseSync は、enableForeignKeyConstraints の既定が true で、開いた時点で外部キーの制約が有効になっています。外部キーの設定はデータベースのファイルではなく接続ごとのものなので、同じデータベースでも、sqlite3 コマンドなど既定のままの接続と node:sqlite の接続とで、同じSQLの動きが変わることがあります。削除の順番を、参照している側の表から消す順に直して進めました。

なお、Node.js 22の node:sqlite は、ドキュメントで「Stability: 1.1 - Active development」とされている開発中の機能です。v22.13.0 からはフラグなしで使えますが、まだ実験的という扱いです。本番の予約の仕組みでは、使うデータベースと接続のライブラリを、この記事の結果とは別に選んで確かめてください。

作る前に、店舗や式場の側で決めておくこと

キャンセル待ちの実装は、方式を決めれば小さく作れますが、決めることの多くは運用の側にあります。予約の仕組みを作る側に渡す前に、次の表を埋めておくと、作ってからの手戻りが減ります。

決めること 選択肢の例
方式 順番に案内/全員に知らせる/開始の何時間前で切り替える
返事の期限 案内から何分(何時間)か。文面に書く期限と、実際に締める時刻
待てる人数 枠ごとに上限を設けるか
案内の手段 メール・SMS・LINEのどれか。届かなかったときの代わり
期限切れの人の扱い キャンセル待ちから外すか、最後尾に戻すか
電話や窓口での取り消し 窓口の取り消しでも、同じ処理を通して案内が出るか
連絡先を消す時期 枠が終わってから何日後か

最後から2行目の窓口での取り消しは、見落としやすいところです。電話で受けた取り消しを台帳だけで直すと、キャンセル待ちの人に案内が出ません。取り消しの入口がいくつあっても、同じ cancel の処理を通るようにします。

株式会社bundlyzeでは、複数の業種で使う予約管理システムや、Lステップのように公式LINEと連携するサービスを開発してきました。キャンセル待ちや案内の連絡を、今の予約の仕組みに組み込みたい場合の窓口は、システム開発のページです。

動作確認した環境

確認日は2026年10月6日です。

  • 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とNode.jsの動きについての説明は、次の公式のドキュメントの記載に基づいています。

  • 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やメールで案内を送ってもよいですか?

避けます。データベースのロックを持ったまま外部のAPIを待つと、ほかの予約の処理が待たされます。また、送った後でトランザクションが失敗すると、案内したのに記録が残らない状態になります。この記事の例では、案内を送る予定を「送る連絡の控え」の表に同じトランザクションで書き、確定した後に別の処理が読んで送ります。

返事の期限ちょうどに「予約する」が押されたら、どちらが優先されますか?

データベースが先に受け付けた方です。この記事の作りでは、期限より前に押された「予約する」と、期限切れを締める定期の処理が同時に届くと、先に書き込みのロックを取った方が通り、どちらの結果でも定員は守られました。ただ、期限の直前に押した人が断られることはあるので、お客様に見せる期限を、実際に締める時刻より少し早く書いておく運用をおすすめします。

SQLiteで試した結果は、PostgreSQLでも同じですか?

考え方は同じですが、この記事ではPostgreSQLでは試していません。SQLiteでは書き込みのロックを先に取るBEGIN IMMEDIATEで、処理を順番に並べています。PostgreSQLでも、条件つきのUPDATEで「案内中のときだけ予約にする」と書けば、データベースが判定する形にできますが、同時の動きは使うデータベースで確かめてください。