ホテル予約システム設計:日単位在庫と二重予約防止

1. 概要

ホテルの予約システムの課題は一見簡単に思えます。部屋が利用可能かチェックして、あれば予約を許可する。しかし、2人のゲストが12月31日の最後の部屋に同時に「予約」ボタンを押すと、システムは簡単に1つの部屋を2人に販売してしまう可能性があります。

このガイドでは実際の設計例を紹介します。要件、夜ごとの在庫モデル、API、ホールド(保留)から確認までのフロー、PostgreSQLを使用した二重予約防止の方法、突発的なトラフィック処理までを見ていきます。このガイドを読み終わった後、Spring Boot 3.xで意図しないオーバーブッキングなしに予約システムの核となる部分を構築できるようになります。

2. 要件

2.1. 機能要件

  • ホテル、部屋タイプ、チェックイン日、チェックアウト日で利用可能な部屋を検索します。
  • 支払いのために10分間部屋を保留します。
  • 支払いが成功した後、予約を確認します。
  • 予約をキャンセルして、在庫を返却します。

2.2. スケールと制約

  • 約50,000のホテル。各ホテルに平均5つの部屋タイプ。システムは365日前まで販売開始。
  • 読み取りがはるかに多く、読み取り:書き込みの比率は約100:1です。
  • ピーク時に約50の予約/秒。フラッシュセール時にはこの数値が20倍になる可能性があります。
  • 厳しい制約: 設定された部屋数を超えて販売してはいけません。検索結果は数秒のズレを許容します。

在庫行数は約50,000 × 5 × 365 ≈ 91百万行です。良いインデックスを持つPostgreSQLテーブルはこの規模を処理できます。

3. データモデル

この設計で最も重要な決定:ゲストは特定の部屋(302号室)を予約するのではなく、一定期間における部屋タイプを予約します。 システムはチェックインの時だけ部屋番号を割り当てます。したがって、在庫は(ホテル, 部屋タイプ, 夜)というキーで保存されます。

CREATE TABLE room_inventory (
    hotel_id      BIGINT  NOT NULL,
    room_type_id  BIGINT  NOT NULL,
    stay_date     DATE    NOT NULL,
    total_rooms   INT     NOT NULL,
    reserved      INT     NOT NULL DEFAULT 0,
    PRIMARY KEY (hotel_id, room_type_id, stay_date),
    CONSTRAINT chk_not_oversold CHECK (reserved >= 0 AND reserved <= total_rooms)
);

CREATE TABLE booking (
    id               BIGSERIAL PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    customer_id      BIGINT      NOT NULL,
    hotel_id         BIGINT      NOT NULL,
    room_type_id     BIGINT      NOT NULL,
    check_in         DATE        NOT NULL,
    check_out        DATE        NOT NULL,
    quantity         INT         NOT NULL,
    status           VARCHAR(16) NOT NULL, -- HOLD, CONFIRMED, CANCELLED, EXPIRED
    expires_at       TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_booking_expiry ON booking (expires_at) WHERE status = 'HOLD';

主要なポイントの説明:

  • 12月10日から13日までの滞在は3泊です:10日、11日、12日。チェックアウト日は含まれません。すべてのクエリは半開区間[check_in, check_out)を使用します。
  • CHECK (reserved <= total_rooms)は最後の防御線です。コードにバグがあっても、データベースが拒否してしまいます。
  • 部分インデックスidx_booking_expiryはHOLD状態の予約のみを含みます。クリーンアップジョブは高速に実行でき、インデックスが履歴で膨らみません。

注意: 多くのホテルはキャンセルが3~5%発生することを知っているので、意図的にオーバーブッキングします。total_roomsを実際の部屋数より多く設定することでこのポリシーを表現してください。二重予約防止ロジックは緩くしないこと。

4. API

GET  /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings                      (ヘッダー: Idempotency-Key)  -> 201, status=HOLD
POST /bookings/{id}/confirm         (支払い成功後に呼び出し)
POST /bookings/{id}/cancel

予約作成リクエスト:

public record HoldRequest(long customerId, long hotelId, long roomTypeId,
                          LocalDate checkIn, LocalDate checkOut, int quantity) {
    public HoldRequest {
        if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("checkOutはcheckInより後でなければなりません");
        if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
    }

    public int nights() {
        return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
    }
}

ヘッダーIdempotency-Keyはクライアントが生成します(UUID)。ネットワークが不安定な場合、クライアントは安心して再試行でき、2つの予約は作成されません。

5. 処理フロー

ゲスト ──► POST /bookings ──► すべての夜の在庫を減らす (1トランザクション) ──► 予約HOLD、10分後に期限切れ
     ──► 支払い ──► POST /confirm ──► HOLD → CONFIRMED
                     └► 10分以内に支払わない ──► クリーンアップジョブ: HOLD → EXPIRED、在庫を戻す

在庫は支払い成功後ではなく、ホールド作成時に減らします。これにより、ゲストが支払いページに進んだ場合、部屋が確実に残っていることが保証されます。その代わり、システムはホールド期限切れ時に部屋を返却するメカニズムが必要です。

6. 課題1:二重予約防止

6.1. 「読み取ってから書き込む」が安全でない理由

素朴なアプローチはSELECTで残り部屋数を取得し、Javaでチェックして、UPDATEします。2つのトランザクションが同時にreserved = 9, total = 10を読んで、両方がreserved = 10に書き込む可能性があります。その結果、2人のゲストが同じ部屋を保留します。これは古典的なlost update問題です。

6.2. 順序付きロック後の条件付き更新

依存関係を追加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

リポジトリがネイティブクエリを使用:

public interface InventoryRepository extends Repository<RoomInventory, InventoryId> {

    @Query(value = """
        SELECT stay_date FROM room_inventory
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        ORDER BY stay_date
        FOR UPDATE
        """, nativeQuery = true)
    List<LocalDate> lockNights(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved + :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
          AND total_rooms - reserved >= :qty
        """, nativeQuery = true)
    int reserve(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved - :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        """, nativeQuery = true)
    int release(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
}

ホールドを作成するサービス:

@Service
public class BookingService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);
    private final InventoryRepository inventory;
    private final BookingRepository bookings;
    private final Clock clock;

    public BookingService(InventoryRepository inventory, BookingRepository bookings, Clock clock) {
        this.inventory = inventory;
        this.bookings = bookings;
        this.clock = clock;
    }

    @Transactional
    public Booking hold(HoldRequest req, String idempotencyKey) {
        Optional<Booking> existing = bookings.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) return existing.get();

        inventory.lockNights(req.hotelId(), req.roomTypeId(), req.checkIn(), req.checkOut());
        int updated = inventory.reserve(req.hotelId(), req.roomTypeId(),
                req.checkIn(), req.checkOut(), req.quantity());

        if (updated != req.nights()) {
            throw new RoomUnavailableException(req.hotelId(), req.checkIn(), req.checkOut());
        }
        return bookings.save(Booking.hold(req, idempotencyKey, Instant.now(clock).plus(HOLD_TTL)));
    }
}

ステップ順の動作:

  1. lockNightsは**stay_dateの順序で**在庫行をロックします。ゲストAが10~13日、ゲストBが11~14日を予約するとします。ロックが固定順序でなければ、Aが10日目を保有して11日目を待ち、Bが12日目を保有して11日目を待つため、デッドロックの可能性があります。すべてのトランザクションが同じ順序でロックすれば、この状況は起きません。
  2. reserveは十分な空きがある夜だけreservedを増やします。更新された行数を返します。
  3. 行数が夜数より少ない場合、ある夜がもう満室か、まだ販売開始していない(行がない)可能性があります。サービスはRoomUnavailableExceptionをスロー。これはRuntimeExceptionなので、@Transactionalが全体をロールバック。すでに追加された夜も含めて。

最後の部屋で2つのリクエストが同時に到着した場合の結果:

T1: lockNights (ロック取得) → reserve = 3行 → INSERT booking → COMMIT
T2: lockNights (T1を待機)  → reserve = 0行 → RoomUnavailableException → ROLLBACK → HTTP 409

ここではisolation levelをSERIALIZABLEに上げる必要はありません。READ COMMITTEDとロー・レベルロックで十分です。

6.3. 同時再試行時のべき等性

同じIdempotency-Keyを持つ2つのリクエストが両方ともfindByIdempotencyKeyをパスする可能性があります。UNIQUE制約が2番目のリクエストをDataIntegrityViolationExceptionで止めます。コントローラー層でこの例外をキャッチして、キーで予約を読み直し、古い結果を返します。敗北したトランザクションはロールバックしたので、差し引いた在庫も戻ります。

7. 課題2:ホールド期限切れと確認の競争

クリーンアップジョブは30秒ごとに実行:

@Component
public class HoldExpiryJob {

    private final BookingRepository bookings;
    private final InventoryRepository inventory;

    public HoldExpiryJob(BookingRepository bookings, InventoryRepository inventory) {
        this.bookings = bookings;
        this.inventory = inventory;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expireHolds() {
        // SELECT ... WHERE status='HOLD' AND expires_at < now() LIMIT 500 FOR UPDATE SKIP LOCKED
        for (Booking b : bookings.lockExpiredHolds(500)) {
            inventory.release(b.getHotelId(), b.getRoomTypeId(), b.getCheckIn(), b.getCheckOut(), b.getQuantity());
            b.markExpired();
        }
    }
}

FOR UPDATE SKIP LOCKEDにより、複数インスタンスで実行可能で、同じ予約が重複処理されません。各バッチは500行のみを取得し、トランザクション時間を短くします。

リソースを解放するときは、ロック時と同じ夜の順序でreleaseを呼び出して、デッドロックを防ぎます。

最も危険な競争状況は:ゲストが支払いを完了するまさにその時にジョブがホールドを期限切れにしている場合です。これは状態に対する条件付きのUPDATEで解決:

UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();

ステートメントが0行を返す場合、ホールドはすでに期限切れで、部屋は他の誰かのものかもしれません。この場合、サービスは返金する必要があるか、再度holdを試してから確認する必要があります。在庫を差し引かずにEXPIREDからCONFIRMEDに変更しないでください。

8. 課題3:突発トラフィック

読み取りが書き込みの100倍なので、検索がロック中の行に触れてはいけません。Redisで短いTTLで部屋の状態をキャッシュできます:

@Service
public class AvailabilityCache {

    private final StringRedisTemplate redis;

    public AvailabilityCache(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Optional<Integer> get(long hotelId, long roomTypeId, LocalDate night) {
        String v = redis.opsForValue().get(key(hotelId, roomTypeId, night));
        return Optional.ofNullable(v).map(Integer::valueOf);
    }

    public void put(long hotelId, long roomTypeId, LocalDate night, int available) {
        redis.opsForValue().set(key(hotelId, roomTypeId, night), String.valueOf(available), Duration.ofSeconds(30));
    }

    private String key(long h, long rt, LocalDate d) {
        return "avail:%d:%d:%s".formatted(h, rt, d);
    }
}

守るべき原則:

  • Redisは読み取りのみ提供。単一の情報源はPostgreSQL。 検索結果が「1室残り」を表示していても、ホール失敗ステップで失敗すると、ゲストは「たったいま満室になりました」というHTTP 409を受け取ります。誤った予約は作成されません。
  • hold、releaseまたはcancelのたびに、同じトランザクション内のoutboxテーブルにイベントを書きます。リレイはこのイベントをKafka topicinventory-changedにプッシュ。コンシューマーはRedis キーを削除するか、検索インデックスを更新。Outboxはアプリケーションがコミット直後にクラッシュしてもイベントを失わないことを保証。
  • フラッシュセール時、ロックは「ホット」なホテルのいくつかに集中。API gatewayでhotelIdごとのレート制限を設定し、予約トランザクションにlock_timeoutを約2秒設定。このようにリクエストは迅速に失敗し、接続プールを独占しません。

9. 二重予約防止テスト

最も重要なテストは多くのスレッドが最後の部屋を争う場合です。実際のPostgreSQLが必要です。H2は正確にロック動作をシミュレートしません。依存関係を追加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BookingService bookingService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void onlyOneGuestGetsTheLastRoom() throws Exception {
        for (int d = 10; d <= 12; d++) {
            jdbc.update("INSERT INTO room_inventory VALUES (1, 1, ?, 1, 0)", LocalDate.of(2026, 12, d));
        }
        var req = new HoldRequest(42L, 1L, 1L, LocalDate.of(2026, 12, 10), LocalDate.of(2026, 12, 13), 1);

        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = IntStream.range(0, 20)
                .mapToObj(i -> pool.submit(() -> {
                    try {
                        bookingService.hold(req, "key-" + i);
                        success.incrementAndGet();
                    } catch (RoomUnavailableException ignored) { }
                }))
                .toList();
            for (Future<?> f : futures) f.get();
        }

        assertThat(success.get()).isEqualTo(1);
        assertThat(jdbc.queryForList("SELECT reserved FROM room_inventory", Integer.class))
            .containsOnly(1);
    }
}

20個の仮想スレッドが、異なるキーでholdを呼び出します。テストは正確に1つのスレッドが成功し、3泊すべてでreserved = 1であることを確認。ロールバック漏れで行がダブル カウントされることはありません。テーブルはセクション3のスクリプトschema.sql、またはFlyway migrationで作成。

10. よくあるエラー

  • チェックアウト日を1泊として数える。 このバグはゲストが1泊多く在庫から差し引かれます。すべての場所で範囲[check_in, check_out)を使用してください。
  • JavaでSELECT後の在庫をチェック(ロックなし)。 これはlost updateを引き起こします。条件はUPDATE内またはその後のFOR UPDATE内に含まれる必要があります。
  • 更新された行数を夜数と比較しない。 ある夜がまだ在庫行を持たないと、UPDATEは他の夜に「成功」します。
  • ロック保持中にAPI支払い呼び出し。 ロック数秒保持され、ホテル全体が停止。ステップを分離:hold (commit)、支払い、confirm。
  • RedisのDECRを単一の情報源として使用。 Redis フェイルオーバーとデータ喪失で在庫も失う。このアプローチはDB性能がはるかに超える場合のみ検討、その場合もDBで照合。

注意: 部屋タイプ単位のカウントモデルは、ゲストが特定の部屋番号(ホームステイやプライベートヴィラの302号室など)を選択する場合には適していません。その場合はroom_night(room_id, stay_date)テーブルにUNIQUE制約、またはPostgreSQLのdaterangeの排他制約が必要。

11. 結論

ホテル予約システムの核は夜ごとの在庫テーブルです。その上に短いトランザクション:夜を順序付きでロック、条件付き更新、影響を受ける行数を確認。CHECK制約は最後の安全ネット。時間制限付きホールド、SKIP LOCKEDを使用したクリーンアップジョブで、支払い放棄のゲスト問題を解決。状態に対する条件付きUPDATEで、確認と期限切れ間の競争を解決。

読み取り部分はRedisとoutbox+Kafkaで同期、検索は大負荷に耐えますが、書き込みの一貫性は損いません。hotel_idで分割する必要があるのは、単一のPostgreSQLノードが本当に処理できなくなったときだけです。

system-designspring-bootpostgresqlconcurrencyredis