系统设计 28:订单支付与邮件收据的三种确认
支付接口没回包,数据库写着 PENDING,用户却看到扣款:客服该查哪一个真相?订单库不是支付机构的账本,邮件“已接收”也不是收件人“已收到”。本篇处理支付受理后回包丢失、重复与乱序回调,并用合成收据状态表示本地接收与退信;邮件是旁路通知,退信不能回滚已确认的付款。
先钉住不可逆的动作
请求键 request-1 只对应一个本地订单 o1,订单金额为 2500 分;一次订单只有一个稳定的网关扣款键 charge-o1。只有确认网关对应扣款成功,订单才从 PENDING 单向转到 PAID;乱序到达的 pending 不能把 PAID 降回去。CANCELLED 只允许在确认没有成功扣款时进入;退款是独立状态机而非把历史付款改为未发生。收据意图与本地订单状态在同一事务提交;本地记录 PAID 不等于用户收件箱已出现邮件。
教学 SLO:入口合法请求 99.9% 在 2 秒内返回订单 ID 和当前状态(30 天窗口,入口接收到响应发出);待核查支付 99% 在 10 分钟内被网关查询或人工队列接管(只测本地状态变化,排除外部长期不可用)。不承诺“2 秒内扣款”或邮件投递成功。税务发票、跨币种换算、风控、真实清结算及合规账务不在这个最小案例中,生产上不能据此上线收款。
若假设每天 30 万订单、每订单一次支付请求和一封收据,平均约 300000/86400=3.47 订单/秒,峰值系数 15 对应 52 订单/秒。订单、支付、两种 outbox 状态各按假设 1 KiB 总计,保留 365 天、两份副本约 300000×1 KiB×365×2≈209 GiB,不含索引和账务归档。若异常重试率从 1 增至 4 次/订单,外部调用峰值可能由 52 升至 208 次/秒,成功扣款笔数不应随之增长;这比扩数据库先逼迫网关配额与核对流程改变。
POST /orders:Idempotency-Key、商品及报价版本、金额分;相同键与相同参数返回同一个订单,不同参数须拒绝并提示冲突。GET /orders/{id} 返回订单状态和“支付待核查”标志,不返回猜测的支付成功。回调入口记录网关事件 ID、签名校验结果、扣款键、状态和事件序号;只有已验证身份且与本地金额、订单匹配的终态才可更新订单。签名、网关序号是生产候选字段,本地假网关不证明其真实语义。
候选表:orders(id, request_key UNIQUE, amount_cents, state)、payments(order_id UNIQUE, gateway_key UNIQUE, state)、outbox(kind, order_id, state, PRIMARY KEY(kind,order_id))。操作先在本地事务插入订单、支付意图和 charge outbox;投递者用稳定 gateway_key 请求假支付对象。实际 SQLite 对 唯一约束 的拒绝、事务 的边界是本次组件实测,其余跨服务原子性没有被 SQLite 承诺。
flowchart LR
U[下单请求] --> D[(订单 支付 Outbox 同库)]
D --> W[扣款投递者]
W --> G[本地假支付对象]
G --> R[回调或主动查询]
R --> D
D --> M[收据 Outbox 投递者]
M --> E[本地假邮件接收对象]
E --> B[退信事件 / 人工核查]
sequenceDiagram
participant D as 订单数据库
participant W as 投递者
participant G as 假支付对象
participant E as 假邮件接收者
W->>G: charge-o1 请求扣款
G->>G: 按键只记一笔成功扣款
G--xW: 成功回复丢失
W->>D: 支付状态 UNKNOWN,订单仍 PENDING
G-->>D: success 回调,事务置 PAID 并入收据 outbox
G-->>D: 迟到 pending 和重复 success 均不回退
D->>E: 收据 receipt:o1
E-->>D: 已接收,随后退信
D->>D: 收据 BOUNCED,不回滚 PAID
同步在下单 HTTP 线程里调用网关,看似简单,回包丢失时容易把用户重试变成第二笔扣款;事务 outbox + 稳定网关键将“本地有意图但尚未发出”变成可核查状态,但不能把两家系统拼成一个 ACID 事务。另一方案是网关支持查询原扣款键并等待确认再向客户端报告终态;可降低未决窗口,却可能超过 2 秒入口预算,仍必须保留重试键。真实邮件中 SMTP 对消息的接收和最终送达也不同;RFC 5321 §6.1 规定接收后的投递责任与失败通知,RFC 3464 定义投递状态通知格式;本地模拟并未进行 SMTP 交换或收件箱确认。
把账对起来,而不是靠回调顺序猜
运行 python3 -B examples/system-design/labs/28/payment.py:本地订单 o1 金额为 100 分,另一笔 rollback 在 outbox 插入前故障,订单随事务回滚为零。独立假网关 SQLite 以订单 ID 去重,扣款后抛出 TimeoutError;本地订单库关闭重开,再用相同键重试,仍只有一笔扣款,不同金额 200 分被拒绝。success、重复 success、迟到 pending 回调后,订单 paid、支付 succeeded,outbox 保留 request_payment 和 order_paid 各一条。合成收据从 accepted_by_fake_channel 改为 bounced 后订单仍 paid。原始结果见 examples/system-design/evidence/28/RUN.md;脚本不实现 request_key HTTP 层、真实 SMTP、资金动作或消息投递者确认,outbox 记录存在不表示已经外发。
恢复流程以本地 UNKNOWN 列表主动查询同一扣款键,对照网关流水和金额,成功则原子推进订单与 outbox;对账不一致则停止自动重试、人工处置,不能换新扣款键“试一次”。收据退信后允许更正地址再生成新投递尝试,但不能因邮件失败再扣款。面试追问:成功扣款而本地更新前宕机时靠什么重放?取消与迟到成功并发如何定义订单与退款?网关查不到刚才的请求时是立即重试还是等待?三个答案分别落在 stable key、领域状态和查询一致性承诺上。
[PATTERN] 把“本地受理、外部扣款、附属通知”拆成三条可分别核对的确认线;从不可逆动作反推幂等键与单向状态转换。
实验源码 examples/system-design/labs/28/payment.py,证据 examples/system-design/evidence/28/RUN.md。系列导读;本篇是自拟工程扩展题。





