第 47 章 / 共 57 章 · 约 11 分钟
LLD 案例:酒店预订
酒店预订的难点在“日期”这个维度:一间房不是简单的“空”或“满”,而是每一晚各自空或满。把库存按“房型 × 晚”来记,很多问题会简单得多:防超订、入住时分房、占房超时释放都建立在这个模型上。
需求澄清
功能需求
- 酒店有若干房型(标准间、大床房、套房),每种房型有若干间房。
- 按入住、退房日期查询各房型是否有房,以及总价。
- 预订流程:选房型和日期 → 占房 10 分钟 → 支付成功后确认;超时自动释放。
- 入住前 24 小时可以免费取消。
- 入住时前台分配具体房间号。
- 价格:房型基础价,周五、周六晚上加价,旺季另有价格。
非功能需求
- 绝不超订:任何一晚,确认加占房中的预订数都不超过该房型的房间数。
- 占房过期后库存要能回收。
核心类
| 类 | 职责 |
|---|---|
BookingService | 占房、确认、取消、释放过期占房 |
Inventory | 每个(房型,晚)已占用多少间,原子地占用和归还 |
Reservation | 一次预订:房型、日期、状态、占房截止时间、入住后分到的房号 |
Status | 预订状态:占房中、已确认、已入住、已退房、已取消、已过期 |
PricingRule | 价格规则(策略),可以叠加 |
查看图表源码
classDiagram
accTitle: 酒店预订类图:预订服务、库存、预订与价格规则
class BookingService {
+hold(String type, Date in, Date out) Reservation
+confirm(String id) Reservation
+cancel(String id)
+releaseExpired()
}
class Inventory {
-Map total
-Map booked
+take(String type, List nights) boolean
+giveBack(String type, List nights)
}
class Reservation {
-String id
-String roomType
-Date checkIn
-Date checkOut
-Status status
-DateTime expiresAt
-String roomNumber
}
class Status {
<<enumeration>>
HELD
CONFIRMED
CHECKED_IN
CHECKED_OUT
CANCELLED
EXPIRED
}
class PricingRule {
<<interface>>
+apply(Date night, int cents) int
}
BookingService --> Inventory
BookingService "1" o-- "many" Reservation
BookingService ..> PricingRule : quote
Reservation --> Status
PricingRule <|.. WeekendRule
PricingRule <|.. SeasonRule
两个建模决定
日期用左闭右开区间。10 日入住、12 日退房,占用的是 10 日和 11 日两晚;12 日退房后,另一位客人当天就能入住。用 [check_in, check_out) 表示,相邻的两个预订天然不冲突,代码里也不会出现“退房日算不算”的特判。
预订时只扣房型库存,入住时才分配房间号。 如果下单时就指定具体房间,日期会被切碎:比如房型共 2 间,101 房 10 日有人,102 房 11 日有人,那么每一晚都还剩一间房,但没有一间能连住 10 日和 11 日两晚。按“房型 × 晚”计数就没有这个问题。只要每一晚的预订数不超过房间数,按到店顺序分配房间一定分得开(这是区间图着色的经典结论:需要的房间数等于同一晚最多有几个预订)。
用到的设计模式
- 状态模式:预订有一条明确的状态机:占房中 → 已确认 → 已入住 → 已退房,以及占房中 → 已过期、已确认 → 已取消。每个操作先检查当前状态是否允许。
- 策略模式:价格规则是可叠加的函数列表,加一个“连住 7 晚打九折”只是多一条规则。
核心代码
import threading
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from enum import Enum
class Status(Enum):
HELD = "held"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
EXPIRED = "expired"
def nights(check_in: date, check_out: date) -> list[date]:
"""[check_in, check_out): the check-out date is not a night of the stay."""
return [check_in + timedelta(days=i) for i in range((check_out - check_in).days)]
@dataclass
class Reservation:
id: str
room_type: str
check_in: date
check_out: date
status: Status
expires_at: datetime | None = None
class Inventory:
def __init__(self, rooms: dict[str, int]):
self.rooms = rooms
self.booked: dict[tuple[str, date], int] = {} # (type, night) -> rooms taken
self.lock = threading.Lock()
def take(self, room_type: str, stay: list[date]) -> bool:
with self.lock:
if any(self.booked.get((room_type, n), 0) >= self.rooms[room_type] for n in stay):
return False # every night or none
for n in stay:
self.booked[(room_type, n)] = self.booked.get((room_type, n), 0) + 1
return True
def give_back(self, room_type: str, stay: list[date]) -> None:
with self.lock:
for n in stay:
self.booked[(room_type, n)] -= 1
class BookingService:
HOLD = timedelta(minutes=10)
def __init__(self, inventory: Inventory, clock=datetime.now):
self.inventory, self.clock = inventory, clock
self.reservations: dict[str, Reservation] = {}
self.lock = threading.Lock()
def hold(self, room_type: str, check_in: date, check_out: date) -> Reservation:
if check_out <= check_in:
raise ValueError("check-out must be after check-in")
if not self.inventory.take(room_type, nights(check_in, check_out)):
raise RuntimeError("no rooms of this type for those dates")
r = Reservation(uuid.uuid4().hex, room_type, check_in, check_out,
Status.HELD, self.clock() + self.HOLD)
with self.lock: # the expiry job iterates this dict
self.reservations[r.id] = r
return r
def confirm(self, rid: str) -> Reservation:
with self.lock: # confirm and expiry must not interleave
r = self.reservations[rid]
if r.status is not Status.HELD or self.clock() > r.expires_at:
raise RuntimeError("hold expired")
r.status, r.expires_at = Status.CONFIRMED, None
return r
def release_expired(self) -> None: # a job every minute
with self.lock:
now = self.clock()
for r in self.reservations.values():
if r.status is Status.HELD and now > r.expires_at:
r.status = Status.EXPIRED
self.inventory.give_back(r.room_type, nights(r.check_in, r.check_out))
Inventory.take 在锁里先检查所有晚,再一起扣减:要么每一晚都占上,要么一晚都不占。如果逐晚扣、扣到一半发现某晚满了,就得回滚前面几晚,容易出错。
confirm 和 release_expired 用同一把锁,避免“刚判断完没过期、还没改成已确认,就被释放任务回收了库存”的交错。hold 加入新预订时也要拿这把锁,因为释放任务正在遍历同一个字典。换成数据库,可以写成条件更新:WHERE status = 'HELD' AND expires_at > now。
支付晚于占房过期
用户在第 11 分钟才付完款,这时占房已经过期,库存可能已经被别人占了。合理的处理:
- 收到支付成功的回调后,尝试重新占同样的库存;
- 占到了就直接确认;
- 占不到就自动退款,并告诉用户房间已经没有了。
不要直接确认(可能超订),也不要无视这笔款项。
计价
def quote(base: int, stay: list[date], rules) -> int:
total = 0
for night in stay:
price = base
for rule in rules: # each rule: (night, cents) -> cents
price = rule(night, price)
total += price
return total
def weekend(night: date, cents: int) -> int:
return cents * 120 // 100 if night.weekday() in (4, 5) else cents # Friday and Saturday nights
价格按晚计算再加总,因为同一次住宿里不同晚的价格可能不同(周末、旺季)。金额用整数(分)。
扩展讨论
- 允许少量超订:一些酒店会按历史取消率故意超订几间,把
rooms换成“房间数 × (1 + 超订比例)”即可,但要配套“客满时安排到附近酒店”的流程。 - 多家酒店:库存的键加上酒店 ID;搜索“某城市某日期有房的酒店”需要按城市建索引,属于系统设计范畴,可以参考订票系统一章的思路。
- 取消政策:抽成
CancellationPolicy策略,不同价格方案(可退、不可退)对应不同策略。