酒店订房系统设计:按晚计数与防超售

1. 概述

酒店订房问题听起来很简单。有空房就让客人订。但当两个客人同时点击最后一间房间的“预订”按钮时,特别是在 12 月 31 日晚上,系统很容易把一间房卖给两个人。

本文介绍一个真实的设计方案:需求、按晚计数的库存模型、API、保留再确认的流程、用 PostgreSQL 防止超售、以及应对流量激增。读完后,你可以用 Spring Boot 3.x 构建核心订房模块,不必担心意外的超售。

2. 需求

2.1. 功能需求

  • 按酒店、房间类型、入住日期(check-in)和退房日期(check-out)搜索空房。
  • 为客人保留 10 分钟的房间,允许他们完成支付。
  • 支付成功后确认预订。
  • 取消预订并退回库存。

2.2. 规模与约束

  • 约 50,000 家酒店。每家酒店平均有 5 种房间类型。系统提前 365 天开放预订。
  • 读流量远大于写流量。搜索与预订的比例约为 100:1。
  • 高峰期每秒约 50 笔预订。闪购时可能增加 20 倍。
  • 硬约束: 不能超卖已配置的房间数。搜索结果允许延迟几秒。

库存行数约 50,000 × 5 × 365 ≈ 9100 万行。具有良好索引的 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) 是最后一道防线。如果代码有 bug,数据库会拒绝而不是默默超售。
  • 部分索引 idx_booking_expiry 仅包含状态为 HOLD 的预订。清理作业扫描快速,索引也不会因历史数据而膨胀。

注意: 许多酒店故意超售 3-5% 因为知道会有取消。你可以通过设置 total_rooms 大于物理房间数来表示这一政策。不要放松防超售逻辑。

4. API

GET  /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings                      (Header: 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);
    }
}

Header Idempotency-Key 由客户端生成(UUID)。网络不稳定时,客户端可以安全重试而不会创建两个预订。

5. 处理流程

客人 ──► POST /bookings ──► 扣除每晚库存(1 个事务) ──► 预订为 HOLD,10 分钟后过期
   ──► 支付 ──► POST /confirm ──► HOLD → CONFIRMED
            └► 10 分钟内未支付 ──► 清理作业:HOLD → EXPIRED,库存还原

库存在创建保留时立即扣除,而不是等到支付。这确保客人进入支付页面时确实还有房间。作为交换,系统必须在保留过期时释放房间。

6. 难点 1:防止超售

6.1. 为什么“读后写”不安全

天真的做法是 SELECT 获取剩余房间数,在 Java 中检查,然后 UPDATE。两个事务可能同时读到 reserved = 9, total = 10 并都写入 reserved = 10。结果是两个客人共享一间房。这是经典的丢失更新问题。

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>

Repository 使用 native query:

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 创建保留:

@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 会回滚整个事务,包括已递增的晚上。

两个请求同时争抢最后一间房的结果:

T1: lockNights(持有锁) → reserve 成功 3 行 → INSERT booking → COMMIT
T2: lockNights(等待 T1) → reserve 返回 0 行 → RoomUnavailableException → ROLLBACK → HTTP 409

你不需要将隔离级别提升到 SERIALIZABLE。READ COMMITTED 配合行锁就足够了。

6.3. 并发重试时的幂等性

两个携带相同 Idempotency-Key 的请求可能都通过 findByIdempotencyKey 检查。UNIQUE 约束会用 DataIntegrityViolationException 阻止第二个请求。你在控制器层捕获此异常,重新按 key 读取预订,并返回旧结果。失败的事务已回滚,所以它扣除的库存也被还原。

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 行以保持事务短暂。

记得在 @Configuration 类上启用 @EnableScheduling。释放库存时,也要按加锁时的相同晚间顺序调用 release 以避免与 hold 线程死锁。

最危险的冲突是:客人刚完成支付时清理作业正在过期保留。你用条件状态的 UPDATE 解决:

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

如果语句返回 0 行,保留已过期且房间可能已被他人占用。此时服务必须退款或重新尝试 hold 后再确认。永远不要在不更新库存的情况下从 EXPIRED 转为 CONFIRMED。

8. 难点 3:流量激增

读流量是写流量的 100 倍,所以搜索不应触及被锁定的行。你可以用短 TTL 在 Redis 上缓存房间状态:

@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 间房”但 hold 步骤失败,客人只会收到 HTTP 409 和“刚好满房”的提示。不会创建任何错误的预订。
  • 每次 hold、release 或 cancel 后,在同一事务中将事件写入 outbox 表。relay 将事件推送至 Kafka topic inventory-changed。consumer 删除 Redis key 或更新搜索索引。outbox 确保 app 在 commit 后立即崩溃也不会丢失事件。
  • 闪购时,行锁集中在少数“热门”酒店。你应该在 API gateway 按 hotelId 限流,并为订房事务设置约 2 秒的 lock_timeout。这样请求快速失败,而不是耗尽连接池。

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 个虚拟线程用不同的 key 同时调用 hold。测试断言恰好一个线程成功,且三个晚上都有 reserved = 1。没有任何晚上因回滚不完全而被多计。表通过第 3 节的 schema.sql 脚本或 Flyway migration 创建。

10. 常见错误

  • 把退房日期算作一个晚上。 这个 bug 导致客人库存被多扣。处处使用半开区间 [check_in, check_out)。
  • 在 SELECT 后没有加锁的情况下在 Java 中检查库存。 这会导致丢失更新。条件必须在 UPDATE 语句中或 FOR UPDATE 之后。
  • 不对比更新行数与晚数。 如果某个晚上没有库存行,UPDATE 对其他晚上仍然“成功”。
  • 在持有锁的事务内调用支付 API。 锁被持有数秒,整个酒店都被阻塞。拆分步骤:hold(commit)、支付、然后确认。
  • 用 Redis DECR 作为真实数据源。 Redis 故障转移丢数据时,库存也消失了。这只在负载远超数据库能力时值得考虑,且你仍需定期与 DB 对账。

注意: 按房间类型计数的模型不适合客人选择特定房间号(如 302)的情况,例如民宿或独栋别墅。此时你需要 room_night(room_id, stay_date) 表和 UNIQUE 约束,或 PostgreSQL 的 exclusion constraint(基于 daterange)。

11. 总结

订房系统的核心是按晚计数的库存表。其上是一个短事务:按顺序加锁晚间、条件更新、检查受影响行数。CHECK 约束是最后防线。限时保留加上使用 SKIP LOCKED 的清理作业解决客人放弃支付的问题。条件状态的 UPDATE 解决 confirm 和 expire 之间的冲突。

读操作由 Redis 分担并通过 outbox 与 Kafka 同步,让搜索能承受大流量而不削弱写操作的一致性。你可以从此设计开始,仅当单个 PostgreSQL 节点真正不堪一击时才按 hotel_id 分片。

system-designspring-bootpostgresqlconcurrencyredis