高层设计写着“座位最多卖一次”,类图里有 Seat、Order、Payment,并不能证明代码守住了这个约束。真正需要落地的是谁拥有状态、哪条操作能改变它、检查与更新是否处在同一原子边界,以及失败后返回什么可执行结果。

本篇把系列票务场景缩小为单座位占用与支付确认,使用 Python 标准库和实际 SQLite 数据库验证状态机。没有真实支付渠道,没有分布式锁,也不把这个小程序称为完整票务系统。目的是把架构层的数据保证变成模块契约与数据库操作,避免为名词各建一个类后仍然允许非法状态跳转。

对象边界由不变量决定

同一座位至多被一个有效持有人占用,已售座位不能因旧占座或迟到回调重新转给别人;同一支付 ID 重复通知不重复记录,同 ID 换持有人要拒绝。这里的“支付确认”是本地假支付事件,若不能完成销售,返回 refund_required 表示需要外部补偿,不表示已经退款。

状态只有 FREE、HELD、SOLD。占座从 FREE 进入 HELD,或在 HELD 到期后由新持有人重新占用;付款只能在当前 HELD 尚未到期、持有人匹配时进入 SOLD。SOLD 在本实验没有反向边,取消订单和退款后重新售卖属于新的业务流程,不能为了方便测试直接把状态改回 FREE。

候选接口 hold(seat_id, owner, now) 返回成功或竞争失败,confirm_payment(payment_id, seat_id, owner, now) 返回 sold、duplicate、conflict 或 refund_required。返回值体现恢复动作,不能仅返回布尔值把“重复成功”和“不能卖但已经付款”混在一起。系统时间应由服务端权威时钟提供;实验显式传入整数时间是为了固定边界,不接受客户端自行声明的到期时间。

stateDiagram-v2
    [*] --> FREE
    FREE --> HELD: 原子占座并设 expires
    HELD --> HELD: 已到期后新持有人占用
    HELD --> SOLD: 当前持有人且未到期的支付确认
    SOLD --> SOLD: 同支付 ID 同持有人重复确认
    HELD --> HELD: 旧持有人迟到支付转补偿
    SOLD --> SOLD: 其他占座请求被拒绝

模块接口比类数量更重要

应用服务负责验证身份、金额币种与请求形状,库存存储模块负责条件提交,支付适配器负责渠道协议,对账任务处理未知或需补偿状态。它们可以先是同一代码库里的函数与模块,不需要四个部署单元。对象设计不应把必须原子完成的动作拆成两次公共调用,例如先 seat.is_free() 再 seat.reserve()。

库存表 seats(id PRIMARY KEY, owner, state, expires);支付表 payments(id PRIMARY KEY, seat, owner)。简化模型只允许每用户每座位一个有效占座,生产设计应加入不可复用的 hold_id 或版本,防止同一用户过期后重新占座时旧支付被误识别为新占座的支付。这个限制不能靠 owner 字段悄悄消失,本文实验也不覆盖同一用户重新占用这一场景。

条件更新把判断和状态变化合在一起:只有 FREE,或者 HELD 且 expires 小于等于当前时间,才设置新 owner 和截止时间。返回影响行数 1 表示获得席位,0 表示没有获得。支付确认的条件则要求 HELD、owner 相同、expires 严格大于当前时间。等于截止时间时已经过期,两条条件使用互补边界,避免同一时刻两边都认为有效。

SQLite UPDATE 文档描述更新语句的行为,事务文档说明事务边界。本实验用实际数据库执行条件 UPDATE,并让售出状态与支付记录在同一事务提交。数据库是当前状态的裁决点,Python 对象只保存调用上下文;进程内对象缓存不能成为库存真相。

sequenceDiagram
    participant A as 占座应用服务
    participant D as 库存与支付数据库
    participant P as 假支付回调
    A->>D: Alice 在 t=0 占 A1,到期 t=10
    A->>D: Bob 在 t=1 尝试占座
    D-->>A: 影响 0 行,竞争失败
    A->>D: Bob 在 t=11 占座,到期 t=21
    P->>D: Alice 旧付款在 t=12 到达
    D-->>P: 持有人不匹配,refund_required
    P->>D: Bob 的 p_new 在 t=12 确认
    D-->>P: SOLD 与支付记录共同提交
    P->>D: 重复 p_new
    D-->>P: duplicate,无第二笔记录

幂等保护的是业务含义

重复支付先按支付 ID 查询已存在记录。同 ID、同座位、同持有人返回 duplicate;同 ID 绑定另一个持有人返回 conflict。只检查 ID 存在就返回成功,会让攻击者或调用错误借用另一笔支付的成功状态。真实实现还要检查金额、币种、渠道身份和签名,不能把本文的合成支付 ID 当成安全认证。

如果售出更新完成但插入支付记录失败,事务必须回滚,两边不能一边成功一边失败。若提交完成而响应丢失,重复回调应查回原记录;如果数据库连接在提交时断开,调用方不能猜测事务失败,需要按支付 ID 查询终态。将超时直接映射为“可以重新卖”会把网络异常变成超卖路径。

本篇使用单连接、确定性顺序,验证的是状态转移和事务代码路径。它没有通过多线程竞争证明吞吐或所有并发交错。第 26 篇的并发抢座实验应承担并发层验证,而这里关心高层保证是否在每个公开方法里保持一致;两类证据不能互相替代。

什么时候需要更多抽象

一个场馆、一种座位状态、一个支付适配器时,简单函数和条件 SQL 已能表达关键约束。只有第二种库存模型出现,比如不可编号站票或连座组合,才有必要重新审查库存接口。不要提前抽出泛化状态机框架,否则合法转移条件可能藏进字符串配置,反而难以审查。

如果一次订单需要三个连座,单座位原子更新并不足够;需要在一个事务中校验并占用整组座位,失败全部回滚,或明确允许部分成功。若库存分布在多个分片,事务边界又会改变,可能需要按演出集中库存或引入补偿。对象继承层次不能解决跨分片原子性,架构约束会反过来决定对象接口能提供什么承诺。

教学容量为五万席、每行库存 200 B、三份约 30 MB;十万订单每行 1 KiB、三份约 307.2 MB。存储体积不大,开票热点每秒五千次尝试才是压力来源。假设每次事务平均占用数据库执行资源 5 ms,不能据此直接宣称能跑二十万 QPS;单写瓶颈、锁等待、磁盘提交和竞争失败都必须单独测量。

若同一座位占据 80% 的请求,多分片也不能把这一个不变量拆给多台服务器同时独立决定。优先做准入、去重、缓存展示与失败退避,减少无效竞争。若从编号座位改成可交换库存计数,原子条件扣减可以改变模型,但连座选择与精确座位承诺就随之变化,不是无损优化。

代码行为怎样回到架构验收

运行 python3 examples/system-design/labs/E05/run.py,实际 SQLite 版本为 3.51.3。Alice t=0 占座成功,Bob t=1 失败;Bob t=11 在到期后获得座位;Alice t=12 的旧付款返回 refund_required,Bob 的付款售出成功;同付款重复不增记录,同 ID 换用户返回 conflict,已售席位不能再次占用。最终唯一座位归 Bob,状态 SOLD,支付记录一条。

版本、命令、源码哈希与原始输出保存在 examples/system-design/evidence/E05/。实验验证边界时刻、迟到支付、重复和错误载荷四类路径;它没有实现真实退款、签名校验、同用户重新占座的 hold_id,以及高并发或多地域场景。因此结论限定为本状态模型的本地数据库行为,生产完成度不能从退出码 0 推导。

面试追问:如果回调先到而订单还未创建,怎样保存待核对事件;如果 owner 相同但占座版本已变化,为什么仍不能确认旧付款;如果要退款后重新销售,哪些新状态与幂等键必须增加。每个追问都应对应一条状态转移和持久字段,而不是只在类图中多画一个管理器。

[PATTERN] 高层不变量决定原子边界,原子边界决定模块接口,状态机限制对象可执行动作。类的名称表达业务,数据库与失败返回值兑现业务。

实验附件与导航

可运行实验源码 · 本次原始结果

系列导读;容量和数据承诺分别沿用系列的方法,本文数字为独立教学假设。