专业书籍精读 · System Design Interview 卷二 · 第 11 章
System Design Interview Vol 2 · Ch 11 · Alex Xu & Sahn Lam · 2022
你在购物 App 上点「立即付款」,一秒后页面说「支付成功」。这一章造的就是那一秒里跑完的东西:把钱从买家那边收进来,再在某一天把钱结给卖家,而且一分不能多、一分不能少。
这套系统慢得出奇。一天一百万笔交易,摊到每秒只有十来笔——比一个中型论坛的刷新还少。可它偏偏是整本书里最难写对的一章。
因为难点根本不在快,在于「钱到底动没动」这句话必须有确定答案。
你按下付款键,手机转了三十秒,跳出一行「网络错误」。钱扣了吗?你不知道,你的服务器也不知道。请求可能压根没送到,也可能银行早扣了、只是回执丢在路上。
这时系统只有两条路:当它没成功——用户可能白白被扣一笔;当它成功了——商家可能白发一件货。更糟的是用户自己会再按一次,于是一次购物扣两回钱。
① 给每次付款发一张号码牌。同一张号码牌无论递上来几次,窗口只办一次事,第二次直接把第一次的收据再抄一份给你。于是「不确定成没成,就再试一次」从危险动作变成安全动作。
② 记账要一笔记两边。每一分钱的移动都同时落在两个账户上,一边减一边加,全部加起来永远是零。漏记一笔,一求和就露馅——钱守恒不是靠会计细心,是靠记账格式本身。
③ 每天晚上对一次账。你自己记的流水,和支付公司发来的流水,逐笔比。对得上的过,对不上的挑出来:差个手续费这种能自动补,剩下说不清的交给人工。这一步不是保险,是必需品——前两招永远盖不住全部情况。
④ 卡号根本不进你家。刷卡那一页是支付公司的,卡号直接进他们的机器,你只拿到一串替代号码。你存不了的东西,就泄露不了。
这一整套把「付款」从一个动作变成了一条带状态的流程:它会卡在「处理中」、会进重试队列、每天还留下一小撮必须人工处理的尾巴——这些麻烦就是买「正确」花的钱。
支付系统慢得离谱却难得离谱,因为它唯一的任务是让「钱动没动」有确定答案。网络超时时你分不清成功与失败,所以每笔付款带一张只认一次的号码牌,让重试重新变安全;账目一笔记两边,让漏账藏不住;每天再和支付公司的流水对一遍,把前两招漏掉的挑出来人工修。至于卡号,压根不让它进你的机器。
想进到具体机制、记号和示意图? → 切到精读版
这一章表面是「接一个支付」,真正教的是:在一个你控制不了、又不能重来的外部世界里,让「钱到底动没动」有唯一答案。第一个反直觉——支付是全书吞吐最低的系统:每天 100 万笔交易折合约 11.6 TPS,一台普通关系数据库闭着眼都扛得住,这一章没有一行设计是为了性能。第二个反直觉——正确性不是「小心就不会错」换来的,而是三层叠着买来的:幂等键让重试变安全,复式记账的账本让钱守恒且可审计,每日对账则直接承认前两层仍会漏,所以每天把账重新数一遍。
卷二第 11 章,全书「钱不能错」这条线的上半场:下一章 Ch12「数字钱包」把本章的账本一路推到事件溯源 + CQRS + Raft 复制状态机;Ch7「酒店预订」则是同类问题的另一面——有限库存的并发争抢。现实对应物是电商结账、打车下单扣款、SaaS 订阅续费,以及一切「调用外部机构、且钱会真的动」的系统。
先把规模摆上桌:每天 100 万笔交易,1,000,000 ÷ 86,400 ≈ 11.6 TPS。这个数字放在本书里几乎可笑——Ch4 的限流器要扛百万 QPS,Ch10 的排行榜峰值 2,500 QPS,支付系统只有两位数。于是「加缓存、分库分表、异步化」那套本能在这一章集体失效:没有性能问题需要解决。
真正的问题只有一个,而且来自边界之外。一次支付要穿过你的服务 → PSP → 收单行 → 卡组织 → 发卡行五道门,你只拥有第一道。调用超时时,「超时」既不等于成功也不等于失败,它是第三种状态:未知。而钱最讨厌的就是「未知」。
不解决会怎样?三件事都会发生:① 重复扣款——重试一次,钱扣两遍;② 记账丢失——PSP 扣成功了,你的账本没记上,卖家拿不到钱;③ 月底对不上——差额确实存在,却没人能定位它是哪几笔。前两件伤用户,第三件伤公司:一个无法解释自己差异的支付系统,在财务和监管眼里等于没有系统。
第一个容易被跳过的决定藏在数据模型里:「一次结账」和「一笔付款」不是同一个东西。购物车里三件商品可能来自三个卖家,于是一次结账(支付事件,主键 checkout_id)要拆成三个支付订单(主键 payment_order_id),各自调一次 PSP、各自成败。
为什么不能打包成一笔?因为它们流向不同的收款方,而且你没有一个能横跨 PSP 的事务可用——第二笔失败时,第一笔已经在银行那边落地了:没有「回滚」,只有「补偿」。
分工也值得记住:支付服务接支付事件、做风控与反洗钱检查;支付执行器只执行单个支付订单、调用 PSP;成功后才轮到账本记账、钱包更新余额。支付订单表上两个不起眼的字段 ledger_updated 与 wallet_updated 是全章最有工程味的细节:它们把「这一步做没做」写成可查询的状态,于是卡在半路的单子能被程序扫出来,而不是靠人猜。
这是全章的心脏。网络给不了恰好一次:不重试可能漏,重试可能重。所以工程上的恰好一次 = 至少一次(重试)+ 幂等(重试不产生新效果)。
做法是:客户端为每次付款生成一个 UUID 放进 HTTP 头(如 Idempotency-Key: 8f3c…),服务端在真正执行之前,先把它以数据库唯一约束的方式原子插入。插得进,说明你是第一个;插不进,说明有人已经在办或办完了——返回第一次的结果,或回 409 让调用方稍后再问。
三个细节决定成败,也都是面试里的分水岭:
它还顺手挡掉一类攻击:带随机数(nonce)的幂等键让重放攻击(replay attack)失效——同一个请求原样重发,系统当它是重试。
这一节是「不做什么」的艺术。只要卡号进过你的进程、日志或数据库,整套系统就落进 PCI DSS 的合规范围。所以标准解法是把收集卡号那一步整个外包给 PSP:用它的托管收银台(整页跳转)或内嵌组件(iframe / JS SDK),卡号从浏览器直连 PSP,你只拿回一个令牌(token)——它只在你的商户账户下有效,被偷走也花不出去。你存不了的东西,就泄露不了。
代价是流程变成异步:用户在 PSP 页面上完成支付(还可能被要求做 3DS 双因素验证),结果由 webhook 回调通知你。于是你得处理「先收到 webhook、后看到用户跳回来」的乱序,还得校验回调签名——否则任何人都能给你的回调地址发一条「支付成功」。
账本用复式记账:每笔交易写两条记录,一借一贷、金额相等,整本账求和恒为零。买家账户减 100,平台应付卖家账户加 100,两条必须同时写进去。
这看着像会计老规矩,其实是一条极硬的工程性质:它把「有没有漏账」变成程序能回答的问题——求和不为零就是出事了,还能顺着分录找到是哪一笔。若只维护一张「余额表」,漏掉一次更新就永久静默。
由此推出另一条:钱包(卖家余额)不是真相源,账本才是。余额是账本累加出的派生视图,坏了可以重算——和 Ch10 里「内存榜是可重建的派生视图」是同一条原则。
支付服务、执行器、账本、钱包是四个服务,PSP 还在公司之外。这条链路上不存在一个能把所有人包住的事务。真要上也只有两条路:两阶段提交(2PC)要求参与者投票后统一提交,协调者一挂就可能把参与者卡在持锁状态——而 PSP 根本不会参与你的投票;Saga 把一串本地事务排好、失败按相反顺序补偿(退款、冲正),代价是补偿逻辑要自己写、中间态还对外可见。
于是书里的默认解法是第三条:每一步做成幂等,失败就重试,剩下的交给对账。失败有标准流水线:支付订单进重试队列(指数退避 + 抖动,免得大家一起重试打垮 PSP),仍不成的进死信队列等人工。每天 PSP 发来的结算文件则与账本逐笔比对:一致的放过;可分类的差异(手续费、跨日时间差、汇率)由规则自动生成调整分录;说不清的差异进人工队列——这一小撮永远存在,承认它、给它正规通道,正是这一章最成熟的地方。
表 1 · 卡数据怎么接:合规范围差一个数量级
| 接法 | 好在哪 | 代价(诚实说) | 什么时候选它 |
|---|---|---|---|
| PSP 托管收银台 (整页跳转) | 合规范围最小:卡号完全不经过你;3DS 与各国支付方式由 PSP 负责 | 用户离开你的域名,转化率与品牌体验受损 | 支付不是核心竞争力时的默认答案 |
| PSP 内嵌组件 (iframe / JS SDK) | 视觉上留在你的页面内,转化率好;卡号仍直连 PSP | 集成更复杂;脚本必须从 PSP 域加载,不能打包进你的构建产物 | 大多数电商的现实选择 |
| 自己收卡号并存储 | 流程完全自主;能做跨 PSP 的路由与议价 | 整套系统落进 PCI DSS 最高范围:审计、隔离、密钥管理、泄露责任全归你 | 规模大到自建收单更划算,且有专职合规团队 |
表 2 · 跨服务、跨公司的一致性:三条路
| 方案 | 能保证什么 | 代价(诚实说) | 适用判据 |
|---|---|---|---|
| 两阶段提交(2PC) | 要么全提交、要么全回滚,语义最强 | 协调者故障会把参与者卡在持锁状态;跨公司根本用不了——PSP 不会参与你的投票 | 同一信任域内的少数强一致场景 |
| Saga(补偿事务) | 一串本地事务顺序执行,失败按相反顺序补偿 | 补偿逻辑要自己写;中间态对外可见,用户可能看到「已扣款但订单未生效」 | 流程长、跨多个服务,且业务上存在合法的补偿动作 |
| 幂等 + 重试 + 对账 (书里的默认) | 最终一致,且差异可被发现、可被解释 | 存在窗口期不一致;要养一条对账流水线和一支盯人工队列的人 | 支付的默认答案——先做到这条,再谈分布式事务 |
表 3 · 一笔支付「未知/失败」时怎么收场
| 手段 | 适合的情形 | 代价(诚实说) |
|---|---|---|
| 同步重试(指数退避 + 抖动) | 连接被拒、5xx、超时这类「可能没到」的错误 | 没有幂等键时就是重复扣款的直接成因;退避不加抖动会让客户端一起冲击 PSP |
| 异步重试队列 | PSP 暂时不可用、要等几分钟到几小时的情形 | 用户看到的是「处理中」,产品与客服得为这个状态准备话术 |
| 死信队列 + 人工 | 重试到上限仍不成、或返回了需要判断的业务错误 | 纯人工成本;必须留痕可审计,否则调整本身会变成新的对不上账 |
| 不处理,留给对账 | 你这边完全没记录、只有 PSP 那边有的「孤儿交易」 | 修复最慢(次日级);但这是唯一能兜住未知未知的网 |
这一章的迁移价值远超支付:任何「调用外部系统、且副作用不可撤销」的场景都是同一道题——发短信、开发票、发货指令、给第三方转账、给模型 API 扣额度。判据也永远是同一条:先问「重复一次会怎样」,答案不可接受就必须有幂等键;再问「漏一次怎么发现」,答不上来就必须有对账。
maxNetworkRetries config option to 0. You can also set a higher number to reattempt multiple times, with exponential backoff. Idempotency keys are added where appropriate to prevent duplication.」——注意这两句的因果:正因为幂等键在,自动重试才敢默认开。Stripe 官方 stripe-node README · github.com/stripe/stripe-nodestripe-js README · github.com/stripe/stripe-js11.6 TPS 不需要任何性能设计。这一章考的是你对「不确定性」的处理能力。① 支付是全书吞吐最低的系统:100 万笔/天 ≈ 11.6 TPS。没有性能问题,只有正确性问题。
② 根源是控制边界:你的服务 → PSP → 收单行 → 卡组织 → 发卡行,你只拥有第一道门。
③ 超时 ≠ 失败,是第三种状态「未知」——盲目重试会重扣,放弃则会漏单。
④ 一次结账拆成 N 个支付订单(一个卖家一单),无法被同一个事务包住:只有补偿,没有回滚。
⑤ 恰好一次 = 至少一次 + 幂等。幂等键要在执行之前用唯一约束原子占位,并存下首次响应。
⑥ 重试发生在哪一跳,幂等就装在哪一跳;带 nonce 的键顺手挡掉重放攻击。
⑦ 复式记账让「钱守恒」变成可程序验证的性质:一笔两条、求和为零;钱包只是账本的派生视图。
⑧ 卡号不进你的服务器:托管收银台或内嵌组件 + 令牌化,把 PCI DSS 范围压到最小。
⑨ 跨公司没有事务:默认答案是幂等 + 重试 + 每日对账,而不是 2PC 或 Saga。
⑩ 失败流水线:重试队列 → 死信队列 → 人工留痕;永远有一小撮说不清的差异,给它正规通道才叫成熟。