cswork / 系统设计

第 51 章 / 共 57 章 · 约 10 分钟

LLD 案例:外卖订单

本页目录
  1. 需求澄清
  2. 核心类
  3. 用到的设计模式
  4. 核心代码
  5. 派单
  6. 扩展讨论
  7. 章节测验

外卖平台的系统设计题考的是规模;放到 LLD 里,考点变成订单的生命周期:一个订单从下单到送达要经过哪些状态,谁可以推动哪一步,每一步触发什么动作。把这条状态机写清楚,餐厅、骑手、顾客三方的交互就都有了着落。

需求澄清

功能需求

  1. 顾客从一家餐厅的菜单里选菜加入购物车,下单时预授权付款。
  2. 餐厅接单或拒单;接单后开始制作,做好后标记出餐。
  3. 系统为订单分配骑手;骑手取餐、送达。
  4. 餐厅接单前,顾客可以取消并全额退款;接单后取消走单独的售后流程。
  5. 每次状态变化都通知相关的三方。
  6. 价格:菜品小计 + 配送费 − 优惠。

非功能需求

  • 非法的状态跳转(比如还没出餐就送达)一律拒绝。
  • 一个骑手同一时刻只被分配给一个订单(简化版,不考虑顺路拼单)。

核心类

类职责
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 每几秒上报位置,派单需要“附近的骑手”查询,可以用地理哈希分格索引。规模上去以后属于系统设计问题,参考网约车一章。
  • 一单多店:订单拆成多个子订单,每个子订单有自己的状态机,父订单的状态由子订单汇总。

章节测验

3 道选择题
  1. 为什么订单行要复制菜名和单价,而不是只存菜品 ID?
  2. 用转移表管理订单状态,相比在各个方法里写 if-else,主要好处是什么?
  3. 两个订单同时被餐厅接单,派单程序都选中了同一个最近的空闲骑手。根本的解决办法是?

章节讨论

正在加载讨论…