专业书籍精读 · System Design Interview Vol 2 · 第 12 章
System Design Interview Vol 2 · Ch 12 · Alex Xu & Sahn Lam · 2022
你在支付宝里给朋友转 1 块钱,或者在游戏里把金币送给队友——这一章讲的就是这条转账背后那本账怎么记。它的核心问题很反直觉:难的不是「转得快」,而是「一年以后你还能说清,这 1 块钱当时为什么少了」。
把钱包想成一个存折,记法有两种。
一种是本子上只写一个数字:现在有 100 块,转出去 1 块,就把 100 擦掉改成 99。省地方,一眼看得懂,但擦掉的东西永远找不回来了。
另一种是只往下记流水:「3 月 1 日,转出 1 元」「3 月 2 日,收到 5 元」——余额那一栏干脆不写。要知道现在有多少钱,就把流水从头加一遍。啰嗦,但任何一天的余额都能重新算出来,而且谁来算、算几遍,结果都一样。
这一章的终点,就是把第一种换成第二种。
两件事同时难。
其一,转账要「同时」发生:A 的钱少了、B 的钱多了,这两下得算一次动作。可要是中间断电了呢?何况两个人的账往往根本不在同一台机器上。
其二,钱是要被追问的。对不上账的时候、用户投诉的时候、监管来查的时候,你得说得出「那一刻余额是多少、被哪几笔改成这样的」。只写一个数字的本子,答不了这个问题——它只记得现在,不记得为什么。
这一章分三步把问题解掉。
第一步,先让「同时」成立。做法像餐厅订座:先把 A 的 1 块钱冻结——钱还挂在 A 名下,但花不出去了;再去 B 那边占好一个入账位;两边都答应了,才真正落定。中途谁反悔,就把冻结解开、位子撤掉。座位占住不等于吃上饭,但至少不会两桌人同时坐进去。
第二步,把「擦掉重写」换成「只往下记」。每一笔都是一条永不修改的流水。余额不再是存起来的东西,而是算出来的东西。为了不用每次从创世那天加起,隔一阵拍一张「快照」记下当天余额,之后只加新流水就行。
第三步,让这本流水别丢。同一本流水同时抄给三台机器,每一笔要三台里至少两台点头,才算真的写上了。坏掉任意一台,剩下两台手里的账本一模一样,接着记就是了。
如果你系统里流动的是钱、是库存、是任何「事后会被追问」的东西,这套记法值这个麻烦。代价也很实在:流水只增不减,存储会一直长,而且写错了不能改,只能再记一笔反向的把它冲掉——对习惯了「改一下数据库那行就完了」的团队,这是个不小的转变。
数字钱包的终局是:余额不再是一个被改来改去的数字,而是一串永不修改的流水加出来的结果。正因为它是算出来的,它才能被重新算一遍以自证清白,也才能抄给好几台机器而不走样。
想进到具体机制、记号和示意图? → 切到精读版
你以为设计数字钱包是设计一张余额表,其实这一章从头到尾在做一件事:把「余额」这个可变字段拆掉。它从 Redis 里的一个数字出发,被三条需求(跨分区原子、100 万 TPS、可复现审计)一路逼到终局——不可变事件日志 + 确定性状态机 + Raft 复制 + 分片后跨组 TC/C。读完你拿到的不是一套钱包方案,而是一条思路:正确性靠可重放,不靠可信任。
本章是 Vol 2 的倒数第二章,紧接 Ch11「支付系统」:Ch11 处理的是钱跨出公司边界(对接第三方支付服务商、超时既不是成功也不是失败);Ch12 转身向内,处理同一个系统内部两个账户之间的划拨。它又下启 Ch13「证券交易所」——把「单线程确定性处理 + 可重放」这同一套思路推到微秒级。现实对应:PayPal 余额、支付宝零钱、券商资金账户、交易所的用户余额、游戏内货币。
题面只有一句:支持钱包 A 给钱包 B 转 1 元。难度全在三条非功能需求上——转账必须原子、系统要扛住 100 万 TPS、账目必须可复现(可审计)。
三条各砍一刀。原子性那一刀最直接:A 和 B 大概率落在不同分区,跨机器的「要么全成要么全不成」是分布式系统里最贵的东西之一。100 万 TPS 那一刀砍掉了「每笔都写数据库」——关系数据库的解析、索引维护、行锁与事务日志撑不到这个量级。
可复现那一刀最容易被忽略,却是真正的分水岭:只存「现在余额是多少」,「上周二 21:00 余额是多少、被哪几笔改成这样的」就永远答不了。于是对账对不上时没有第二份口径可比,用户投诉时举不出证,逻辑改错了也没法拿历史数据回放验证。钱这件事上,「说不清」和「算错了」一样致命。
书里先给最直观的方案:钱包服务做成无状态的一层,余额按 userId 哈希分片放进内存键值存储(如 Redis 集群),转账就是两次加减。它逐条失守:Redis 跨节点没有事务,A 减了 B 没加的窗口真实存在;内存里的余额一断电就没了;而且它只记得「现在」,不记得「为什么」。先啃最硬的原子性。
两阶段提交(two-phase commit, 2PC)是教科书答案:准备阶段协调者问每个参与者「能提交吗」,参与者锁住相关行、写好重做日志、回答 yes;提交阶段协调者统一下令提交或回滚。
两条硬伤正好卡在钱包场景上。其一,锁的持有时间等于整个协议的时长——两轮网络往返,同机房毫秒级、跨机房几十毫秒级,热点账户上足以把吞吐锁死。其二,协调者在两阶段之间崩溃,参与者就吊在那儿:锁不敢放、数据不敢动,恢复之前谁也过不去。它语义干净,但对一个目标 100 万 TPS 的系统太重。
Try-Confirm/Cancel 和 2PC 长得像,关键差别只有一句:每个阶段都是一次立刻提交的本地事务,锁在毫秒内就放掉了。
代价是它把三件本来由数据库兜底的事推给了你自己:
还有一条概念上的代价,面试里常被追问:Confirm 之前的中间态对外可见。A 的可用余额已少了 1、B 还没多 1——这 1 块钱短暂地「不在任何人账上」。严肃的账务系统会把它显式建模成在途资金科目,而不是假装这个窗口不存在。
Saga 走另一条路:把转账拆成顺序执行的一串本地事务 T1, T2, …,每个配一个补偿事务 C1, C2, …;中途失败就反向依次跑补偿。协调方式两种——编排(orchestration)由一个协调者发号施令,全链路状态看得见、好排障;编舞(choreography)各服务订阅事件自行反应,耦合最低,但没有任何一处拿得到全局进度。
和 TC/C 的分水岭是一句话:Saga 是「一路做完再往回撤」,TC/C 是「先都预留、最后一起落定」。Saga 的 T1 一提交钱就真动了;TC/C 多付一次 RPC 和一张状态表的开发量,换来「落定前还能整体取消」和各分支可并行。取向也直白:延迟敏感、步骤少 → TC/C;长流程、跨多团队服务 → Saga。
原子性解决了,可复现还欠着。事件溯源(event sourcing)的四件套要一次认清:
now()、随机数或外部调用,否则重放会算出不同结果,整套机制当场作废。读怎么办?余额查询若也去打这台状态机,写路径立刻被拖死。CQRS(命令查询职责分离)把读拆出去:另起一批进程消费事件流、物化成读侧视图,查询只打视图。代价必须写明:读侧是最终一致的,用户可能在转账后的几十到几百毫秒内看到旧余额——所以成功页通常直接回显状态机算出的结果,而不去读视图。
而「可复现」是白给的:从某个快照出发把之后的事件依次喂回,就得到那一刻的余额,以及它被哪几笔改成这样。对账、投诉举证、拿新版本逻辑回放昨天的真实流量做验证——全靠这一条性质。
单台状态机离 100 万 TPS 差得远,书里分三步补齐。
跑快——把数据库换掉。事件日志只需要一个动作:往末尾追加,这恰恰是磁盘最擅长的;而关系数据库为了通用性要付解析、索引维护、行锁、事务日志的多重代价。改成直接追加本地文件、状态常驻内存后,单节点吞吐能上一个量级。粗算就知道瓶颈不在盘上:一条事件按 100 字节计,100 万条/秒也只有约 100 MB/s,而 NVMe SSD 的顺序写带宽在 GB/s 量级。快照负责重启——不必从创世事件重放。
跑稳——单节点内存加本地文件,机器一没就全没。用 Raft 把状态机复制成 3 或 5 副本:leader 把日志条目发给 follower,多数派落盘才算提交;leader 挂了,follower 选出新 leader,日志一模一样,接着跑。这就是复制状态机——确定性逻辑 + 相同的输入序列 = 相同的状态。事件溯源和 Raft 是天生一对,因为两者的核心是同一样东西:一条有序的、不可变的日志。
扩上去——一个 Raft 组的吞吐终究受限于它那个 leader。按钱包 ID 哈希分成多个 Raft 组,组内串行、组间完全并行,要更高吞吐就加组。于是跨组转账又变回了分布式事务,用前面那套 TC/C 在组间做——绕了一整圈,两条线在这里合上。
表 1 · 三种跨分区事务方案:转 1 元这件事交给谁兜底
| 维度 | 2PC | TC/C | Saga |
|---|---|---|---|
| 谁来实现 | 数据库 / 事务管理器 | 你自己的业务代码 | 你自己 + 一个协调者或事件总线 |
| 锁持有时长 | 整个协议(两轮往返) | 单次本地事务(毫秒级) | 单次本地事务 |
| 失败怎么办 | 数据库回滚,无中间态 | Cancel 补偿 | 逆序跑补偿事务 |
| 中间态可见 | 否 | 是(钱已冻结未入账) | 是(钱已真的动了) |
| 你要额外写 | 几乎不用 | Try/Confirm/Cancel 三套接口 + 相位状态表 + 幂等 / 空回滚 / 悬挂三个坑 | 每步的补偿逻辑 + 全链路状态追踪 |
| 协调者挂了 | 参与者阻塞,锁不敢放 | 参与者不阻塞,等重试即可 | 不阻塞,流程停在原地 |
| 适合 | 单库或少量分区、吞吐不高 | 延迟敏感、步骤少、需要「落定前可整体取消」 | 长流程、跨多团队服务、可接受几秒级最终一致 |
表 2 · 余额怎么存:一个可变字段,还是一串不可变事件
| 维度 | 直接存余额字段 | 事件溯源 |
|---|---|---|
| 查当前余额 | 一次主键读,最快 | 读内存状态或 CQRS 视图,也快,但多一条链路 |
| 查历史某刻 | 做不到(除非另建一套变更日志) | 快照 + 重放,天然可得 |
| 改错了怎么办 | 直接改那一行——也意味着谁都能悄悄改 | 只能追加一笔反向事件,痕迹抹不掉 |
| 存储成本 | 与账户数成正比 | 与交易笔数成正比,只增不减 |
| 吞吐上限 | 受随机写与行锁限制 | 顺序追加 + 内存状态,高一个量级 |
| 团队门槛 | 低,人人会 | 高:事件模式演进、快照、重放工具全要自建 |
还有两个量级选择常被追问。副本数:3 副本容忍 1 台故障、写入等 2 票;5 副本容忍 2 台但要等 3 票、尾延迟更高——同机房部署默认选 3,跨机房才考虑 5。分片数:分片越多并行度越高,但跨分片转账的比例也越高,而每一笔跨分片都要付 TC/C 的全部开销。所以分片键的原则是让尽可能多的转账落在组内——真实系统常按业务域(同一商户、同一地区)聚簇,而非纯随机哈希。
这一章的终局架构不是教学玩具,它几乎是当代分布式数据库的标准形状:数据按键分片,每片是一个共识复制组,跨片写走一个两阶段协议。TiKV、CockroachDB、YugabyteDB 都是这个骨架;区别只在于它们做成了通用数据库,而钱包做成了专用账本。
「不可变日志 + 确定性重放」也不只属于钱包:Kafka 把它做成了基础设施,MySQL 的 binlog、PostgreSQL 的 WAL 本质上是同一件事——先把「发生了什么」顺序写下来,状态永远是它的派生物。这也解释了为什么崩溃恢复、主从复制、CDC(变更数据捕获)三件看似无关的事,用的是同一条日志。
now()、随机数或外部接口,重放就会算出另一个结果——而重放正是整套架构的地基。非确定性输入必须在生成事件时固化成字面值。① 这一章的真正动作是把「余额」这个可变字段拆掉,换成不可变事件 + 确定性状态机 + 共识复制。
② 三条需求定全局:跨分区原子、100 万 TPS、可复现审计——第三条才是逼出事件溯源的那一条。
③ 2PC 的两条硬伤:锁的持有时长等于整个协议,且协调者一崩参与者就阻塞。
④ TC/C 把两阶段搬到应用层,每阶段立即提交;代价是自己处理幂等、空回滚、悬挂,靠一张与业务同事务提交的相位状态表兜底。
⑤ Saga 是「做完再往回撤」,TC/C 是「先预留再落定」;延迟敏感选 TC/C,长流程跨团队选 Saga。
⑥ 事件溯源四件套:命令(意图,可拒绝)、事件(事实,不可变且必须确定性)、状态(派生物)、状态机(串行消费 FIFO 命令)。CQRS 把读拆出去,代价是读侧最终一致。
⑦ 跑快靠顺序追加 + 内存状态 + 快照;跑稳靠 Raft 复制状态机;扩容靠分成多个 Raft 组,而跨组转账又绕回 TC/C。
⑧ 「分片 + 每片一个共识组 + 其上两阶段协议」正是 TiKV / CockroachDB 的骨架;日志派生状态的思路也贯穿 WAL、binlog、Kafka 与 CDC。
⑨ 诚实的代价:存储只增不减、事件模式要永久兼容、与删除权冲突、团队门槛高——没有 100 万 TPS 的约束,带审计表的关系数据库就够了。