MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第4回】ダブルブッキングを防ぐ(PostgreSQLの排他制約と同時アクセスのテスト)

    予約サービスで最も避けたいトラブルのひとつが、同じ部屋・同じ時間に2人の予約が入ってしまう「ダブルブッキング(二重予約)」です。起きてしまうと、どちらかのお客様にお詫びと返金をすることになり、信頼にも関わります。前回の予約フォーム(第3回)では、入力チェックをして予約を保存できるようになりました。ところが、実はまだ二重予約を完全には防げていません。

    「予約システムを作るとき、同じ時間に2人が予約してしまう二重予約はどうやって防ぐのですか?『空いているか確認してから保存』では足りないのですか?」

    先に結論をお伝えします。「空きを確認してから保存する」だけでは、ほぼ同時に2人が操作したときにすり抜けます。確実に防ぐには、データベース自身に「重なる予約は保存させない」というルールを持たせます。今回は PostgreSQL の「排他制約」を使い、実際に10人が同時に同じ枠を予約しても1件しか通らないことをテストで確かめます。

    目次

    「空き確認してから保存」が二重予約を防げない理由

    第3回の予約処理は、次の2段階で動いていました。

    1. その日の予約済みの時間を読み出して、重なっていないか確認する
    2. 重なっていなければ、予約を保存する

    1と2のあいだには、わずかな時間があります。この隙間に、別の人が同じ枠で1から2まで進んでしまうと、どちらの確認も「空いている」という結果になり、両方が保存されます。人がボタンを押すタイミングが偶然そろう、というより、スマホの通信が遅れた、画面を開いたまま放置していた、といったごく普通の理由で起こります。

    実際に、第3回までのコードで5件を同時に送るテストを書いて動かしたところ、次の結果になりました(開発環境での確認です)。

    成功 5  DB件数 5

    5件とも成功し、同じ時間の予約が5件保存されました。画面の入力チェックを増やしても、サーバー側の確認を丁寧にしても、「確認してから保存」の形である限り、この隙間は埋まりません。

    仕組み:重なりを禁止する「排他制約」

    そこで、確認の役目をアプリではなくデータベースに任せます。PostgreSQL には、排他制約(=「この条件に当てはまる行を同時に2つ持ってはいけない」というルール)という仕組みがあります。

    📰 出典:PostgreSQL 16 公式ドキュメント「Constraints(排他制約)」

    予約では、次の条件を「同時に存在してはいけない組み合わせ」として登録します。

    条件意味
    スペースが同じ(space_id WITH =)同じ部屋の予約どうし
    時間帯が重なる(tstzrange(starts_at, ends_at) WITH &&)開始から終了までの範囲が少しでも重なる

    この2つが両方成り立つ予約が入ろうとすると、データベースが保存を拒否します。データベースは同時に来た保存を内部で順番に処理するため、「確認と保存のあいだの隙間」がそもそも存在しません。

    時間帯の範囲には tstzrange(タイムゾーンつきの時間範囲を表す型)を使います。これは「開始を含み、終了を含まない」範囲として扱われるので、10〜12時の予約と12〜14時の予約は重なりません。連続して借りたい場合が普通に通ります。

    📰 出典:PostgreSQL 16 公式ドキュメント「Range Types」

    実装1:マイグレーションに制約を追加する

    Drizzle ORM のスキーマ定義では排他制約を書けないため、自分で SQL を書くマイグレーション(=データベースの構造を変更する手順書)として追加します。drizzle-kit generate --custom で空のファイルが作られるので、そこに SQL を書きます。

    📰 出典:Drizzle ORM 公式ドキュメント「Custom migrations」

    npx drizzle-kit generate --custom --name=prevent_double_booking

    drizzle/0003_prevent_double_booking.sql:

    CREATE EXTENSION IF NOT EXISTS btree_gist;--> statement-breakpoint
    ALTER TABLE "reservations" ADD CONSTRAINT "reservations_time_order_check" CHECK ("ends_at" > "starts_at");--> statement-breakpoint
    ALTER TABLE "reservations" ADD CONSTRAINT "reservations_no_overlap" EXCLUDE USING gist ("space_id" WITH =, tstzrange("starts_at", "ends_at") WITH &&);

    ポイントは3つです。

    • btree_gist 拡張:「space_id が等しい」という比較と「範囲が重なる」という比較を、ひとつの排他制約で組み合わせるために必要です。PostgreSQL に標準で同梱されている拡張ですが、有効化の操作が要ります。
    • CHECK 制約:終了が開始より前になっている予約を保存できなくします。時間が逆転したデータは、重なりの判定が意図どおりにならないためです。
    • --> statement-breakpoint:Drizzle が SQL を1文ずつ実行するための区切りです。

    なお、schema.ts には制約そのものを書けないので、「制約は SQL ファイルにある」とコメントを残しておきます。後から読んだ人が見落とさないための工夫です。

    // lib/schema.ts(抜粋)
    // 二重予約を防ぐ制約(排他制約)は Drizzle のスキーマでは書けないため、
    // drizzle/0003_prevent_double_booking.sql に SQL で書いてある(第4回)
    export const reservations = pgTable(

    マイグレーションを適用します。

    DATABASE_URL=postgres://yoyaku:yoyaku@localhost:5432/yoyaku npm run db:migrate

    実装2:制約違反を「想定内の失敗」として扱う

    制約を付けると、重なる予約を保存しようとしたとき、データベースがエラーを返します。PostgreSQL のエラーコードでは 23P01(排他制約の違反)にあたります。

    📰 出典:PostgreSQL 16 公式ドキュメント「Error Codes」

    これは「先に誰かが予約した」という起こりうる出来事なので、画面を壊すエラーにはせず、利用者に伝わるメッセージに変えます。lib/reservations.ts を次のように変更しました。

    // lib/reservations.ts(抜粋)
    const EXCLUSION_VIOLATION = "23P01";
    
    // Drizzle は DB のエラーを別のエラーで包むため、cause をたどって元のエラーコードを探す
    export function isExclusionViolation(error: unknown): boolean {
      for (let e: unknown = error; e instanceof Error; e = e.cause) {
        if ((e as { code?: unknown }).code === EXCLUSION_VIOLATION) return true;
      }
      return false;
    }
    
    export type InsertResult = { ok: true; id: number } | { ok: false; reason: "conflict" };
    
    export async function insertReservation(spaceId: number, booking: BookingInput, range: TimeRange): Promise<InsertResult> {
      try {
        const [row] = await getDb().insert(reservations).values({ /* 省略 */ }).returning({ id: reservations.id });
        return { ok: true, id: row.id };
      } catch (error) {
        if (isExclusionViolation(error)) return { ok: false, reason: "conflict" };
        throw error; // 想定外のエラー(接続切れなど)は握りつぶさずそのまま投げる
      }
    }

    つまずきやすいのが cause をたどる部分です。Drizzle は DB のエラーをそのまま返さず、別のエラーで包んで投げます。包みの外側を見ただけでは 23P01 が見つからないため、cause を順にたどって探しています。

    呼び出し側の Server Action では、conflict のときにフォームへエラーを返します。

    // app/spaces/[slug]/reserve/actions.ts(抜粋)
    const saved = await insertReservation(space.id, result.booking, result.range);
    if (!saved.ok) {
      return { errors: { form: "ほぼ同時に別の予約が入り、選んだ時間は埋まってしまいました。別の時間をお選びください" }, values };
    }
    redirect(`/spaces/${space.slug}/reserve/done?id=${saved.id}`);

    事前の空き確認(第3回で作った validateBooking)はそのまま残します。保存前に「重なっています」と教えられるので親切ですし、確認で弾ければデータベースに無駄な保存を試させずに済みます。早見としてのアプリの確認と、最後の番人としてのデータベース制約、という役割分担です。

    動作確認:10人が同時に予約するテスト

    制約が本当に効いているかは、同時アクセスを再現して確かめます。tests/double-booking.test.ts では、同じ枠への保存を10件同時に実行します。

    // tests/double-booking.test.ts(抜粋)
    it("同じ時間への同時の予約は、ちょうど1件だけ成功する", async () => {
      await db.delete(reservations).where(eq(reservations.spaceId, spaceId));
      const results = await Promise.all(Array.from({ length: 10 }, () => insertReservation(spaceId, booking, range(10, 2))));
      expect(results.filter((r) => r.ok)).toHaveLength(1);
      expect(results.filter((r) => !r.ok)).toHaveLength(9);
      const rows = await db.select().from(reservations).where(eq(reservations.spaceId, spaceId));
      expect(rows).toHaveLength(1);
    });

    同じファイルには、次のケースも入れています。

    ケース期待する結果
    10〜12時が入っている部屋に 11〜13時拒否(1時間重なる)
    同じく 9〜11時拒否(1時間重なる)
    同じく 12〜13時、8〜10時通る(隣り合うだけ)
    別のスペースの同じ時間通る
    終了が開始より前の時間CHECK 制約で拒否

    このテストは DB につながないと動かないため、DATABASE_URL が無い環境では自動的に飛ばす作りにしています。実行は次のとおりです。

    DATABASE_URL=postgres://yoyaku:yoyaku@localhost:5432/yoyaku npm test

    開発環境(PostgreSQL 16)では、lint・型チェック・ビルドが通り、テスト34件(今回追加した6件を含む)がすべて成功しました。制約を付ける前は5件すべて成功してしまった同時テストが、制約のあとは10件中1件だけの成功になります。

    また、PostgreSQL に直接、重なる予約を入れようとすると、次のように拒否されます(開発環境での確認結果)。

    ERROR:  conflicting key value violates exclusion constraint "reservations_no_overlap"

    なお、Docker での起動(docker compose up)は、今回の作業環境では Docker が使えず確認できていません。ローカルにインストールした PostgreSQL 16 で確認しています。

    つまずきやすい点・注意

    • 既存データに重なりがあると、制約を付けられません。すでに二重予約が入っているデータベースに後から制約を付けるときは、先に重なりを解消する必要があります。この連載のサンプルは新規なので問題ありませんが、運用中のシステムに追加するなら事前調査が欠かせません。
    • 「仮予約」を持つ場合は条件を足します。第6〜7回で決済待ちの仮予約を扱うので、キャンセルされた予約や期限切れの仮予約は制約の対象外にする必要があります。そのときは、排他制約に WHERE 条件を付けます。次回以降で扱います。
    • 事前の空き確認と制約のメッセージ両方を残す。制約だけだと、利用者は保存ボタンを押してから初めて埋まっていたと知ります。確認は親切のために、制約は確実性のために、と役割が違います。

    発注者向けメモ:予約システムでこの話が出たら

    二重予約の防止は、見た目の画面からは分からない部分ですが、予約・チケット・在庫・座席のように「早い者勝ちで数が限られるもの」を扱うシステムでは共通の重要論点です。

    • 工数が増えやすい条件:複数の店舗・部屋を同時に扱う、仮押さえ(支払い待ち)の時間がある、キャンセル待ちがある、既存の予約データを引き継ぐ、など。
    • リスクが増えやすい条件:アクセスが一時的に集中する(人気枠の販売開始など)、複数の画面・外部の予約サイトと同じ在庫を共有する、など。
    • 「二重予約はどう防ぐのですか」という質問に、「確認してから保存します」だけで答える会社と、「データベース側でも防ぐ仕組みがあり、同時アクセスのテストをしています」と答える会社では、安心感が違います。

    発注者がやることチェックリストと、開発会社への質問例は次のとおりです。

    • ☐ 「同じものを同時に取り合う」業務(枠・席・在庫・チケット)があるか洗い出す
    • ☐ 万一ダブルブッキングが起きたときの対応(お詫び・返金・代替手配)を決めておく
    • ☐ 仮押さえの時間(支払い待ちを何分にするか)の希望を決めておく
    • ☐ 同時アクセスのテストを納品の確認項目に入れてもらうか相談する

    開発会社への質問例:

    • 「二重予約は、アプリの確認だけでなくデータベース側でも防ぐ設計になっていますか?」
    • 「同じ枠への同時アクセスを再現するテストは、納品物に含まれますか?」
    • 「万一重なって予約が入ってしまった場合、利用者にはどのような画面・メッセージが出ますか?」
    • 「仮押さえやキャンセルがある場合、枠はいつ解放されますか?」

    まとめと次回予告

    今回は次のことをしました。

    • 「確認してから保存」だけでは、同時アクセスで二重予約がすり抜けることを、テストで再現した
    • PostgreSQL の排他制約(btree_gist + tstzrange)で、同じスペースの重なる予約を DB に拒否させた
    • 制約違反(23P01)を想定内の失敗として扱い、利用者に別の時間を案内する形にした
    • 10件同時の保存でも1件しか通らないことを、テストで確かめた

    次回の第5回では、会員ログインを付けます。メールで送るリンクから入る方式で、予約履歴を会員ごとに見られるようにする予定です。

    この連載の記事一覧

    この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (3件)

      目次