第 51 章 / 共 57 章 · 约 10 分钟
LLD 案例:外卖订单
外卖平台的系统设计题考的是规模;放到 LLD 里,考点变成订单的生命周期:一个订单从下单到送达要经过哪些状态,谁可以推动哪一步,每一步触发什么动作。把这条状态机写清楚,餐厅、骑手、顾客三方的交互就都有了着落。
需求澄清
功能需求
- 顾客从一家餐厅的菜单里选菜加入购物车,下单时预授权付款。
- 餐厅接单或拒单;接单后开始制作,做好后标记出餐。
- 系统为订单分配骑手;骑手取餐、送达。
- 餐厅接单前,顾客可以取消并全额退款;接单后取消走单独的售后流程。
- 每次状态变化都通知相关的三方。
- 价格:菜品小计 + 配送费 − 优惠。
非功能需求
- 非法的状态跳转(比如还没出餐就送达)一律拒绝。
- 一个骑手同一时刻只被分配给一个订单(简化版,不考虑顺路拼单)。
核心类
| 类 | 职责 |
|---|---|
OrderService | 下单、推进状态、在状态变化时调用派单、支付和通知 |
Order | 订单:顾客、餐厅、订单行、状态、骑手、状态历史 |
OrderLine | 订单行:下单时复制的菜名、单价、数量 |
Status | 订单状态枚举 |
Dispatcher | 派单策略,默认是“最近的空闲骑手” |
Listener | 状态变化的订阅者:顾客 App、餐厅平板、骑手 App |
图表可横向滚动,展开后可缩放查看。
查看图表源码
classDiagram
accTitle: 外卖订单类图:订单、订单行、状态、派单与通知
class OrderService {
+place(Cart cart) Order
+move(Order o, Status to, Role by)
}
class Order {
-String id
-String customer
-String restaurant
-List lines
-Status status
-String driver
-List history
}
class OrderLine {
-String itemId
-String name
-int unitPrice
-int qty
}
class Status {
<<enumeration>>
PLACED
ACCEPTED
PREPARING
READY
PICKED_UP
DELIVERED
CANCELLED
REJECTED
}
class Dispatcher {
<<interface>>
+pick(List drivers, Location from) Driver
}
class Listener {
<<interface>>
+changed(Order o)
}
OrderService ..> Order
Order "1" *-- "many" OrderLine
Order --> Status
OrderService --> Dispatcher
OrderService --> Listener : notifies
Dispatcher <|.. NearestDriver
用到的设计模式
- 状态机 + 转移表:所有合法的跳转和“谁有权做这一步”写在一张表里,代码只查表。
- 策略模式:派单规则可以替换,比如最近骑手、评分最高、或考虑骑手顺路。
- 观察者模式:三方 App 订阅订单变化,
OrderService不需要知道有几个订阅者。
核心代码
from dataclasses import dataclass, field
from enum import Enum
class Status(Enum):
PLACED = "placed"
ACCEPTED = "accepted"
PREPARING = "preparing"
READY = "ready"
PICKED_UP = "picked_up"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REJECTED = "rejected"
TRANSITIONS = { # (from, to) -> who may make that move; anything missing is illegal
(Status.PLACED, Status.ACCEPTED): "restaurant",
(Status.PLACED, Status.REJECTED): "restaurant",
(Status.PLACED, Status.CANCELLED): "customer",
(Status.ACCEPTED, Status.PREPARING): "restaurant",
(Status.PREPARING, Status.READY): "restaurant",
(Status.READY, Status.PICKED_UP): "driver",
(Status.PICKED_UP, Status.DELIVERED): "driver",
}
@dataclass(frozen=True)
class OrderLine:
item_id: str
name: str # copied from the menu at order time
unit_price: int # cents, copied too: later menu edits leave this order alone
qty: int
@dataclass
class Order:
id: str
customer: str
restaurant: str
lines: list[OrderLine]
status: Status = Status.PLACED
driver: str | None = None
history: list[tuple[Status, str]] = field(default_factory=list)
@property
def subtotal(self) -> int:
return sum(line.unit_price * line.qty for line in self.lines)
class OrderService:
def __init__(self, dispatcher, payments, listeners):
self.dispatcher, self.payments, self.listeners = dispatcher, payments, listeners
def move(self, order: Order, to: Status, role: str) -> None:
allowed = TRANSITIONS.get((order.status, to))
if allowed is None:
raise ValueError(f"{order.status.value} -> {to.value} is not allowed")
if allowed != role:
raise PermissionError(f"only the {allowed} may do that")
order.history.append((order.status, role))
order.status = to
if to is Status.ACCEPTED:
order.driver = self.dispatcher.assign(order) # drive over while the food cooks
if to in (Status.REJECTED, Status.CANCELLED):
self.payments.void(order.id) # release the card authorization
if to is Status.DELIVERED:
self.payments.capture(order.id)
for listener in self.listeners: # customer app, restaurant tablet, driver app
listener.changed(order)
转移表比一串 if-else 好在两点:所有规则一眼能看全,代码评审时很容易发现漏了哪条路径;非法跳转统一在一个地方拒绝。加一个“骑手到店”的状态,只要在表里加两行。
订单行复制菜名和单价,而不是只存菜品 ID。餐厅明天改了价格或下架了菜品,历史订单显示的仍然是顾客当时看到并付款的内容,退款也按当时的价格计算。
付款分两步:下单时预授权,送达后扣款,拒单或取消时撤销预授权。这样顾客取消时不需要走退款流程。
派单
import threading
class NearestDriver:
def __init__(self, drivers):
self.drivers = drivers
self.lock = threading.Lock()
def assign(self, order) -> str | None:
with self.lock: # check-and-claim in one step: two orders never get the same driver
idle = [d for d in self.drivers if d.available]
best = min(idle, key=lambda d: d.distance_to(order.restaurant), default=None)
if best:
best.available = False
return best.id if best else None # None: retry soon, maybe with a wider radius
“找最近的空闲骑手”和“把他标记为忙碌”必须是一步完成的。如果分开做,两个同时被接的订单可能都选中同一个骑手。这里用一把锁;骑手数据在 Redis 或数据库里时,用条件更新或 SETNX 一类的原子操作抢占骑手。
派单放在餐厅接单时,而不是出餐时:骑手赶到餐厅需要时间,和厨房做菜并行,顾客等待的总时间更短。
扩展讨论
- 超时处理:餐厅 5 分钟内不接单就自动取消并撤销预授权;派不到骑手就扩大搜索半径重试。
- 优惠规则:满减、首单、会员折扣可以写成一串规则,依次作用在小计上(责任链或装饰器),每条规则记录自己减了多少,方便对账。
- 骑手位置:骑手 App 每几秒上报位置,派单需要“附近的骑手”查询,可以用地理哈希分格索引。规模上去以后属于系统设计问题,参考网约车一章。
- 一单多店:订单拆成多个子订单,每个子订单有自己的状态机,父订单的状态由子订单汇总。