系统设计 26:占座到期与支付回调的库存边界
同一个座位的最后一张票,甲乙同时点击购买。排队系统可以让请求平稳进入,却不能代替库存判断;缓存中“锁住 A1”也无法回答这样的问题:甲占座到期释放给乙后,甲的支付成功回调才到,应该给谁出票?答案取决于座位与订单的权威状态,而不是谁最先收到回调。
本文只讨论实名/价格之外的单座位库存、限时占座、支付结果和重复回调。支付调用是本地模拟;并发与唯一约束由实际 SQLite 文件中的事务验证,不把它外推为真实票务或第三方支付的端到端保证。
谁能作出“卖出”承诺
接口按状态划分:POST /queue/entries {event_id} 返回有期限的准入票据;POST /holds {seat_id, request_key} 返回 order_id, expires_at 或明确的不可占用;GET /orders/{id} 查询权威状态;POST /payment-callbacks {event_id, order_id, result} 仅供验证签名的支付系统调用。排队票据限制到达率,不是库存所有权;request_key 只限定一次购买意图,不能由同一个键同时买两张座位。
不变量:seats(event_id, seat_id) 在库存中唯一;同一时刻一座至多被一笔未过期的订单持有或已经卖出;orders.request_key 唯一;订单从 held 转 paid 必须和所持座位变为 sold 同事务提交;过期订单的成功付款通知不得卖出已转让座位。回调可能重复且乱序,callback_id 去重不是唯一保障,第二个事件 ID 仍要检查订单终态。教学 SLO:放行后占座 p99 < 250 ms、售卖终态对用户可见 p95 < 5 s、席位争抢超卖数恒为 0。实验没有测这些延迟目标,也没有验证支付完成时间或排队公平性。排除换座、拼单、退票、实名核身、多币种与真实收款。
最小数据模型:seats(seat_id PK, state, order_id UNIQUE, expires_at);orders(id PK, request_key UNIQUE, state);callbacks(event_id PK, order_id)。固定活动需要把 event_id 纳入座位复合主键,实验只有一场活动的一号座位;request_key 以及复合业务键是完整设计字段,本地最小表只保存座位 owner、state、expires,订单 id、state 和回调 id。占座事务读取座位、若旧 hold 过期则先把旧订单置 expired,再通过条件更新把 A1 指向新订单并插入订单;只凭先读后写而不检查更新命中行数会超卖。SQLite CREATE TABLE定义主键与唯一约束,SQLite Transaction描述写事务行为;本篇对唯一性与写序列化的结论只适用于实际运行的 SQLite 版本(见证据)。缓存和搜索索引只做只读展示,不能作为最终库存判定。
峰值不是日均除以一天
教学假设一场活动 10 万个有座位编号的席位,放票第一分钟有 5 万次不同购买意图:均值 50,000 / 60 s ≈ 833.3 intent/s,按秒级峰值系数 4 预留 ≈ 3,333 intent/s。假定排队/占座往返合计 1 KiB,峰值有效载荷约 3,333 KiB/s ≈ 3.25 MiB/s;数据库写日志、TLS 和支付回调另计。100,000 座位各 128 B 是 12.8 MB 逻辑量;50,000 订单各 256 B 是另 12.8 MB,合计 25.6 MB,三副本 76.8 MB(不含索引、回调、WAL 与备份)。若每次尝试产生 96 B 回调记录,50,000 个记录又是 4.8 MB;支付方重投会增加事件日志但不能增加已售库存。
假设只占 10% 的座位被 80% 请求同时抢夺,即 40,000 / 10,000 = 4 个请求/热座位的平均竞争强度;剩下的座位几乎闲置。将请求量增加十倍而库存不变,单热座争用、排队时间和拒绝率都会增长,增加查询缓存副本不能扩容单座位写入者。将教学占座时长设为 10 分钟,假设稳定每秒 1,000 笔尚未付款的新 hold,按在途数近似 1,000 hold/s × 600 s = 600,000 hold;本场只有 100,000 座位,所以稳态持续占座实际上受库存上限限制,不能机械套用这个值声称系统有 60 万可用座位。实验为紧凑起见使用 10 个逻辑秒 到期,与该教学时长不是同一配置。
flowchart LR
B[买家] --> Q[排队 限流]
Q --> H[占座服务]
H --> D[(SQLite 权威座位与订单)]
H --> P[本地模拟支付]
P --> C[回调 验签去重 终态检查]
C --> D
D --> R[用户查询订单状态]
C --> X[过期成功付款待对账]
两个设计选择并不对等。单个权威库事务 + 条件更新先保证库存正确性,适合有限座位数;写热点与数据库故障会降低可用性,应在入口排队或拒绝,不能悄悄改为直接放行。分区库存可按活动或座位区划分,降低无关座位之间的竞争;但跨区选座、订单迁移和多区配额重新分配需要新协议。如果允许无座次普通入场票,可预分库存给入口减少单键冲突,但要有回收和防重复发放机制;指定 A1 不能同时存在两份可承诺的权威余额。单独 Redis 锁不作为最终保障:锁租期结束、进程停顿或回调晚到时仍须用数据库条件更新裁定。SQLite 冲突解决文档说明约束冲突可按语句策略处理,但本文不会把 INSERT OR IGNORE 的回调去重效果当成支付幂等性证明。
支付晚到与座位回收
sequenceDiagram
participant A as 甲
participant D as 座位与订单事务
participant P as 本地模拟支付
participant B as 乙
A->>D: t=100 占 A1 至 t=110
D-->>A: held 订单 o1
A->>P: 支付请求
P-->>A: 超时 结果未知
B->>D: t=110 抢 A1
D->>D: o1 过期 事务转给 o3
D-->>B: held 订单 o3
P->>D: t=111 o1 成功回调
D-->>P: 不卖 A1 标记待对账退款
P->>D: t=111 o3 成功回调
D->>D: o3 paid 与 A1 sold 同事务
付款超时意味着“结果未知”,不是“付款失败”。超过占座截止时间,事务须先检查是否已 sold;若未成交,明确把旧订单置 expired 并释放座位。过期后收到真实支付成功,不能默默给旧订单出票,也不能把 refund_pending 写成“已退款”:应冻结相关自动出票、持久化待对账事项,按支付单号查询/核验实际收款,重试补偿直到取得可审计结果。重复同一 callback ID 返回 duplicate 标志;不同 ID 的成功通知若订单已 paid,标记 already_paid_reconcile 而非直接再次退款——可能只是相同付款的通知重投。正式回调须验签并关联支付意图;本地函数没有签名或资金动作。恢复条件包括过期回收任务可重试、库存与订单状态一致、未决支付事件全部进入可追踪的对账队列;主库失联时停止发出占座成功与出票承诺。
实验运行 python3 -B examples/system-design/labs/26/tickets.py,版本、输入、哈希、输出与退出码见 examples/system-design/evidence/26/RUN.md。16 个线程各自连接同一 SQLite 文件争抢一号座位,仅一方占位成功。t=10 后订单 99 接手,旧订单 t=11 成功回调进入 refund_pending,新订单回调使 seat=sold、order=paid 同事务提交。同事件 ID 再来返回 duplicate;不同事件 ID 再来先读 paid 终态,返回 already_paid_reconcile,最终已售座位和 paid 订单各 1。脚本未实现请求幂等键、真实付款或退款,也没有测试分布式数据库。
[PATTERN] 两阶段资源承诺:先用 {资源 ID, 持有者, 到期时间} 取得有界占位,再以 {订单终态, 权威资源终态} 原子确定成交;外部付款结果只能作为触发信号,不能绕过资源当前状态。这个模型可迁移到限量房间和预约档期,但具体退款/仲裁规则必须由业务定稿。
面试追问与速查
如果在截止时间同一毫秒收到支付回调和回收任务,谁获胜?使用同一数据库时间与事务排序,文中约定仅 now < expires_at 可成交。支付方通知成功但无法查询其交易,能否直接退款?不能,先持久化不确定状态并对账。改成无座位票、预售 100 万张时能否用多个库存桶?可以讨论预分配,但每次发放与回收的总额必须能核对。
| 追问 | 可迁移模式 | 未解决代价 |
|---|---|---|
| 大量请求涌入 | 排队限流 不代表库存所有权 | 排队公平与放行速率 |
| 同座并发抢占 | 唯一键 + 事务内条件转移 | 热点锁等待 |
| 到期后支付成功 | 终态校验 + 待对账补偿 | 真实退款与客户通知 |
25 司机派单处理有限资源的迟到确认;票务另加支付这种不能随订单事务回滚的外部事实。E05 状态机继续分析状态转移与模块接口。






