cswork / 系统设计

第 47 章 / 共 57 章 · 约 11 分钟

LLD 案例:酒店预订

本页目录
  1. 需求澄清
  2. 核心类
  3. 两个建模决定
  4. 用到的设计模式
  5. 核心代码
  6. 支付晚于占房过期
  7. 计价
  8. 扩展讨论
  9. 章节测验

酒店预订的难点在“日期”这个维度:一间房不是简单的“空”或“满”,而是每一晚各自空或满。把库存按“房型 × 晚”来记,很多问题会简单得多:防超订、入住时分房、占房超时释放都建立在这个模型上。

需求澄清

功能需求

  1. 酒店有若干房型(标准间、大床房、套房),每种房型有若干间房。
  2. 按入住、退房日期查询各房型是否有房,以及总价。
  3. 预订流程:选房型和日期 → 占房 10 分钟 → 支付成功后确认;超时自动释放。
  4. 入住前 24 小时可以免费取消。
  5. 入住时前台分配具体房间号。
  6. 价格:房型基础价,周五、周六晚上加价,旺季另有价格。

非功能需求

  • 绝不超订:任何一晚,确认加占房中的预订数都不超过该房型的房间数。
  • 占房过期后库存要能回收。

核心类

类职责
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 分钟才付完款,这时占房已经过期,库存可能已经被别人占了。合理的处理:

  1. 收到支付成功的回调后,尝试重新占同样的库存;
  2. 占到了就直接确认;
  3. 占不到就自动退款,并告诉用户房间已经没有了。

不要直接确认(可能超订),也不要无视这笔款项。

计价

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 策略,不同价格方案(可退、不可退)对应不同策略。

动手实现(可判题):酒店预订

章节测验

3 道选择题
  1. 客人 10 日入住、12 日退房,这次住宿占用哪几晚的库存?
  2. 为什么下单时只扣“房型 × 晚”的库存,而不是直接锁定具体房间?
  3. 用户付款成功时,他的 10 分钟占房已经过期、库存已被释放。最合理的处理是?

章节讨论

正在加载讨论…