专业书籍精读 · System Design Interview Vol 2 · 第 13 章
System Design Interview Vol 2 · Ch 13 · Alex Xu & Sahn Lam · 2022
你在手机上点一下「买入」,几毫秒后就收到「成交」——中间那台替你撮合的机器,就是证券交易所。这一章教你怎么造它。而它是全书最反常的一章:前面十二章都在教你把活摊给更多机器,这一章反过来——把最要命的那段活收回到一台机器的一个线程里。
想象一个农贸市场,墙上挂着两张单子:一张写着所有人愿意出的买价,一张写着所有人愿意收的卖价,都从最划算的往下排。有人喊「我 10 块买」,管理员就看卖单里有没有 10 块或更便宜的;有就当场配对,没有就把这张纸条按价格贴到墙上排队等。同样报价的,谁先来谁先成交——这面墙就叫「订单簿」,这条规矩叫「价格优先、时间优先」。
你可能以为交易所难在流量大。其实不是:书里按「每天十亿笔订单」估算,摊到开市的六个半小时,也就每秒四万多笔——比一个大厂的监控系统还轻。
真正难的是另外三件事:快到离谱(慢一点点,你的单子就被别人抢在前面);绝对不能插队(先到的必须先成交,这是法律问题不是体验问题);一笔都不能错,事后还得能一帧一帧倒带回放,因为监管随时会来问「那一秒到底发生了什么」。
如果让十个管理员同时改那面墙,就得抢着用、排着队,而且谁先改到很难说得准——顺序一乱,插队就发生了,事后也没法还原。所以交易所的办法很朴素:让一个人干,而且只让一个人干。
但在这个人之前加一道发号机:不管订单从几条线涌进来,都先在门口领一个号——1 号、2 号、3 号,写进一本流水账,然后严格按号送进去。于是两件好事一起来了:顺序被号码钉死,插队变得不可能;那本流水账原样重放一遍,就能把任何一刻的市场原封不动复原出来——换台机器重放,还得到一模一样的结果,所以备用机能随时顶上。
至于「一个人会不会太慢」——真不会。这活全在内存里做,就是翻翻两张单子、划掉几行;英国交易所 LMAX 公开说过,他们单线程每秒能处理六百万笔订单。机器的时间其实都耗在网络和操作系统上,不在撮合本身。代价也很实在:一只股票的那面墙只能待在一台机器上——想扩容,只能把不同股票分给不同机器,同一只股票扩不了。
交易所不靠「更多机器」取胜,靠一条不可更改的排队顺序:门口先发号,里面一个线程按号办事,号码本留着随时倒带。快、公平、可复盘,都是从这一条里长出来的。
想进到订单簿结构、定序器、延迟预算和真实交易所的数字? → 切到精读版
这是 Vol 2 的收官章,也是全书唯一一道「答案是别做分布式」的题:证券交易所的核心撮合必须单线程、纯内存、确定性——因为它要的不是吞吐,而是微秒级延迟 + 严格的价格时间优先 + 逐笔可重放。所有分布式手段(副本、扩展、恢复)都退到这个单线程之外,由一个定序器(sequencer)把并发输入压成一条不可变的流。
Vol 2 的最后一章,公认全书难度最高。它是前面两章的合流:Ch4 自造消息队列教会你「只能追加的日志」,Ch12 数字钱包教会你「事件溯源 + 复制状态机」,本章把这两样推到延迟的极端。现实对应物是 NYSE、Nasdaq、CME、LMAX,以及任何需要「确定性 + 可重放 + 微秒级」的系统——撮合、实时风控、游戏服务器的帧同步。
先把量级摆出来,因为它会颠覆你的直觉。书里的估算口径是:约 100 个标的、每天 10 亿笔订单;纽交所每天开市 6.5 小时 ≈ 23,400 秒,于是平均 ≈ 43,000 QPS,按峰值 5 倍算 ≈ 215,000 QPS。
这个数字放在本书里其实不高——Ch5 的监控系统写入量级是每秒千万级指标,Ch6 的广告点击聚合也在百万级。所以交易所的难点从来不是吞吐,而是另外三条:
不解决会怎样?2012 年 Facebook 上市,Nasdaq 的 IPO 撮合程序出现竞态,三万多笔订单卡在系统里两个多小时——后果见 §6。
订单簿是撮合引擎的全部状态:买卖两侧,每侧按价格排成档位,同一档位内的订单按到达时间排成先进先出队列。撮合规则只有一句:新单先跟对手侧最优档位配,配不掉的余量按价格贴进自己这侧排队。
工程上的诀窍是三个结构拼起来:一是按价格有序的档位表(取最优价),二是每个档位内的双向链表(维持 FIFO、支持中间摘除),三是 订单号 → 链表节点 的哈希表。有了它,下单、撤单、查最优价都是常数时间——撤单不必扫队列,顺着哈希表直接摘节点。这也是为什么撮合可以快到微秒级:它压根不是算法难题,是几十次内存访问。
直觉会说:43,000 QPS,那就多开几个线程并行撮合。这一章明确告诉你不行,理由不是性能而是正确性:
所以答案是:每个标的一个订单簿,一个订单簿一个线程,全程无锁、纯内存。性能够不够?把整个业务逻辑单线程化的 LMAX 公开称其单线程可处理 600 万笔订单/秒(见 §6),比这里需要的 215,000 峰值高一个多量级。扩展则完全靠按标的水平切分:100 个标的可以摊到多台机器,但同一个标的扩不了——这是这套设计付出的真实代价,也是它不适合「单一热点必须无限扩展」场景的原因。
撮合线程是单线程的,但接单不可能是——网关要做认证、风控、余额检查,必须多实例并行。那么并发进来的订单,谁排在前面?答案是在网关和撮合引擎之间插一个定序器:它给每一笔进来的事件分配全局单调递增的序号,写进一条只能追加的日志,撮合引擎严格按序号消费。
一个组件,三件事一起解决:①公平——先后由序号钉死,撮合线程无从「插队」;②事件溯源——这条流就是账本,撮合引擎的内存状态只是它的一个投影;③复制状态机——备用撮合引擎吃同一条流,必然得到同一个订单簿,主挂了直接顶上,不需要复制内存状态。这正是 Ch12 数字钱包那套思想在延迟维度上的复用。
因为撮合是确定性的,「重放」成了万能工具:崩溃后从最近快照开始重放增量事件,几秒内回到崩溃前状态;监管问某一刻的簿口,重放到那个序号即可;新版本撮合引擎上线前,拿昨天整天的真实流灌进去跑一遍,逐笔比对输出,任何行为漂移都会当场暴露。传统「存当前状态 + 改字段」的系统,这三件事都得各造一套机制。
撮合引擎每处理一笔就吐出状态变化,行情发布服务把它加工成三档对外广播:L1 只有最优买卖价与量、最近成交;L2 把前若干档位的聚合数量摊开;L3 是逐笔原始订单——连每张挂单的排队位置都给你。档位越深、带宽和处理成本越高,所以零售 App 一般只订 L1/L2,做市商和高频才买 L3。K 线图不是另建一套系统,而是把行情流按时间窗聚合出来的派生结果。
优化之前先量。把一笔订单的行程摊开,数量级差得非常离谱:
所以这一章的优化清单,几乎全在绕开操作系统,书里称之为机械同理心(mechanical sympathy)——写代码要顺着硬件的脾气:内核旁路(网卡数据直送用户态,省掉内核协议栈与一次拷贝)、CPU 绑核(线程钉死在固定核上,避免迁移导致缓存全失效)、忙等而非阻塞(用自旋换掉一次上下文切换的微秒级代价)、无锁环形缓冲(预分配定长数组循环使用,不分配不加锁;LMAX Disruptor 是其代表作)、避开垃圾回收(对象池 + 堆外内存,别让 GC 在撮合中途停顿)、缓存行填充(把相邻的热变量隔开,避免不同核互相踩同一条缓存行)。再往外一层就是就近托管:交易所把机柜租给券商,谁的线缆更短谁更快——所以交易所会给同机房所有客户剪成等长的线。
表 1 · 撮合引擎的三条路线:为什么本章选第一条
| 路线 | 怎么做 | 公平性 / 可重放 | 代价 | 适用 |
|---|---|---|---|---|
| 单线程确定性 | 每标的一簿一线程,纯内存、无锁,输入来自定序流 | 严格成立:顺序由序号钉死,重放逐笔一致 | 单标的扩不了;一个慢操作阻塞整条线 | 本章方案;交易所、实时风控 |
| 多线程加锁 | 多线程共享一个订单簿,靠锁保护 | 不成立:锁顺序随调度抖动,重放结果可能不同 | 锁竞争吃掉大部分延迟收益 | 几乎没有——这是本章明确反对的做法 |
| 按标的分片 | 不同标的路由到不同引擎实例,实例内仍单线程 | 单标的内严格成立;跨标的无全局顺序 | 跨标的的组合单(如配对交易)失去原子性 | 本章的唯一扩展手段 |
表 2 · 行情三档:订多深,是带宽与信息的直接交换
| 档位 | 内容 | 数据量 | 谁在用 |
|---|---|---|---|
| L1 | 最优买卖价与量、最近成交价 | 最小 | 零售 App、行情看板 |
| L2 | 前 N 个价格档位的聚合挂单量 | 中等 | 主动交易者、算法执行 |
| L3 | 逐笔原始订单,含队列内位置 | 最大,常需组播 | 做市商、高频策略 |
表 3 · 抠微秒的手段与它们各自的代价
| 手段 | 省下什么 | 代价 |
|---|---|---|
| 内核旁路 | 内核协议栈处理与一次内存拷贝 | 绑定特定网卡与驱动,可移植性和可观测性变差 |
| CPU 绑核 + 忙等 | 上下文切换与缓存失效,延迟抖动显著变小 | CPU 长期 100% 空转,电费与机器数按满载算 |
| 无锁环形缓冲 | 锁竞争与内存分配 | 容量定长,写满即背压;代码心智负担高 |
| 堆外内存 / 对象池 | GC 停顿(一次停顿就是几毫秒级事故) | 手工管理生命周期,内存泄漏与越界风险回归 |
| 就近托管 | 长途传输的毫秒 | 昂贵,且引出「这算不算公平」的监管争议 |
这套「定序 + 单线程 + 可重放」不止交易所在用:实时风控、游戏服务器的帧同步、任何需要「多方对同一事件序列达成一致且事后可查」的系统,骨架都一样。面试里它也是少见的反向考点——当题目出现「必须公平」「必须可审计」「延迟以微秒计」时,能说出「这里反而不该分布式,而要把核心收成单线程 + 前置定序器」,比再背一遍分库分表值钱得多。
大厂实证
~1 µs 级;同机房一次网络往返 ~500 µs。拆成多线程省下的那点 CPU 时间,在网络面前四舍五入等于零,却把确定性赔进去了。350 µs 延迟(俗称减速带),主张抹掉速度优势才是公平。这与本章「延迟越低越好」的默认立场正面冲突。① 需求量级:约 100 个标的、每天 10 亿笔订单、开市 6.5 小时 → 约 43,000 QPS,峰值约 215,000——吞吐不是难点。
② 真正的三条约束是:微秒级延迟、严格的价格-时间优先、逐笔可重放可审计。
③ 订单簿 = 有序价格档位 + 档内 FIFO 链表 + 订单号哈希表,下单 / 撤单 / 取最优价都是常数时间。
④ 撮合必须单线程、纯内存、确定性——多线程加锁会让成交顺序随调度抖动,同时毁掉公平性与可重放性。
⑤ 定序器一个组件解决三件事:钉死公平顺序、充当事件溯源日志、让备用引擎「同流即同态」。
⑥ 可重放是万能工具:崩溃恢复、监管审计、新版本上线前的逐笔回归,共用同一条流。
⑦ 扩展只能按标的分片;同一标的扩不了,跨标的也没有全局顺序——这是这套设计的真实代价。
⑧ 行情分 L1 / L2 / L3,是信息深度与带宽的直接交换;K 线是行情流的派生聚合。
⑨ 延迟几乎全在网络与内核(~500 µs 往返 vs ~1 µs 撮合),所以优化手段是内核旁路、绑核忙等、无锁环形缓冲、避 GC、就近托管。
⑩ 一句话带走:交易所买的不是机器数量,是一条谁也改不了的排队顺序。