酒店订房系统设计:按晚计数与防超售
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)));
}
}
逐步工作流程如下:
lockNights按stay_date顺序 加锁库存行。假设客人 A 预订 10-13 日,客人 B 预订 11-14 日。如果加锁不按固定顺序,A 持有 10 日等待 11 日,B 持有 12 日等待 11 日,两方可能死锁。当所有事务按相同顺序加锁时,这种情况就不会发生。reserve仅在有足够空房的晚上递增reserved。它返回更新的行数。- 如果行数小于晚数,可能是某个晚上已满房或尚未开放(无行)。服务抛出
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 topicinventory-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 分片。