专业书籍精读 · System Design Interview 卷二 · 第 4 章
System Design Interview Vol 2 · Ch 4 · Alex Xu & Sahn Lam · 2022
你在外卖 App 上点了「下单」。那一秒钟里,商家要收到单子、骑手系统要开始派单、库存要扣掉、优惠券要核销、风控要查一遍有没有薅羊毛、数据看板还要加上一笔。这一章讲的就是中间那根传话的管子——而且是教你亲手造一个。
这个以「快」出名的东西,把每一条消息老老实实写到硬盘上,不是攒在内存里。更怪的是,它比很多只写内存的方案还能扛。
不用这根管子行不行?下单服务挨个去喊:喊商家、喊骑手、喊库存、喊风控。麻烦有三层。
第一,下单服务得认识所有部门,新来一个部门就得改一次下单的代码。第二,风控那边一秒只处理得了五千件事,可大促零点一秒来五万单——喊也是白喊,还会把下单服务一起拖垮。第三,风控那台机器要是重启了,这几分钟的单子就永远没人查了,因为话喊出去就没了。
这根管子真正的样子,不是传声筒,是一本流水账。下单服务不喊任何人,只管往本子的最后一页添一行。谁想知道发生了什么,自己来翻本子,并且自己记住翻到第几行了。
三个麻烦一起解决。下单服务只认识本子,不认识任何部门;风控慢就慢慢翻,本子不会催它;风控重启回来,从自己记的那一行接着翻——账还在,没有谁的消息是「错过了就没了」。新来一个部门更简单,自己从第一行开始翻就是,谁也不惊动。
因为它只往最后一页写。硬盘慢在「翻来翻去找页」,不慢在「一直往后添」。一本只增不改的账本,恰好把硬盘最不擅长的事完全绕开了——这就是它敢用硬盘的全部秘密。
一本账本只有一个人在最后一页写字,再快也有上限。办法是开好几本,按订单号分:尾号 0 到 3 的记第一本、4 到 6 记第二本,几个人同时开工。代价也就在这儿:同一本里的先后顺序绝对可靠,跨本子之间谁先谁后,就说不准了。
消息队列不是传声筒,是一本只能往后添、谁读到哪儿自己记的流水账。它敢把消息写进硬盘,是因为只往末尾写;它能扩展,是因为一本拆成好几本——而拆开的那一刻,你手里就只剩「每本内部有序」这半句承诺了。
想看磁盘布局、副本同步与投递语义的具体机制? → 切到精读版
你以为消息队列的难点是「怎么把消息可靠地送到」,其实这一章从头到尾在解决一个存储问题:一旦决定把消息落成磁盘上一条只能追加、不能修改的日志,剩下的东西——分区、副本、消费位点、重放,甚至「恰好一次」——全是从这个决定里顺势长出来的。
卷二第 4 章,是全书第一章「造轮子」:前三章都在拼装已有组件,从这一章起开始自己造基础设施。它也是后面的地基——第 5 章监控告警、第 6 章广告点击聚合都默认这根管子已经存在。现实里对标 Apache Kafka,以及 Pulsar、RocketMQ 这一支。
先把场景摆具体。一个电商的「下单成功」事件,下游有七个部门要知道:商家、配送调度、库存、优惠券、风控、推荐模型、数仓。如果下单服务直接挨个同步调用,三件事会立刻咬住你:
一是耦合:下单服务里硬编码了七个下游地址,第八个部门上线就得改它、测它、发它一次。二是快慢不匹配:大促峰值下单 5 万 QPS,风控引擎满打满算只吃得下 5 千 QPS。三是故障传染:任何一个下游响应时间从 20 毫秒涨到 2 秒,下单服务那 200 个线程十几秒就被占满,于是「风控变慢」演变成「下不了单」。
那用传统消息中间件(RabbitMQ、ActiveMQ 这类)行不行?前三件解决了,还差两件:它们的语义是投递即删除,一条消息被消费掉就从队列里消失。这挡住了两个越来越常见的需求——同一条消息被多个互不相干的下游各读一遍(数仓和风控要读同一批订单,谁都不该把它「吃掉」),以及出 bug 之后把上周的数据重跑一遍。这一章要造的,正是一个把消息留着的队列。
最省事的做法是拿一张数据库表当队列:insert 写入、排序读一批、处理完 delete。每秒几百条完全能用,到几万条就塌,塌得有理有据:写要维护 B-tree 索引(随机 I/O),删会在页里留下空洞——偏偏队列表正是「疯狂插入 + 疯狂删除」这个最坏情形。
这一章的答案是追加日志:一个普通磁盘文件,只做一件事——在末尾追加。文件切成固定大小的分段(segment),只有最新那段可写,旧段一律只读、过期时整段删除(不做行级删除,所以没有空洞要清理)。
性价比全在「顺序」二字上。Kafka 官方文档引用过一组经典测算:在一组 7200 转 SATA 盘上,顺序写约 600 MB/s,随机写只有约 100 KB/s——差了三个数量级还多。换句话说磁盘不慢,随机访问才慢,而只往末尾长的日志恰好把随机访问完整绕开了。再叠上操作系统的页缓存(page cache),刚写下的消息往往还没落到盘片就被消费者取走了。
表 1 · 拿数据库当队列 vs 拿追加日志当队列
| 数据库表 | 追加日志(本章选择) | |
|---|---|---|
| 写入 | 插入 + 维护索引,随机 I/O | 末尾追加,顺序 I/O,可整批落盘 |
| 删除 | 行级删除留下空洞,需 vacuum | 整段删除,代价近似 rm 一个文件 |
| 重复消费 | 要自己建「谁读到哪」的表 | 每组记一个 offset,天然支持重放 |
| 适用 | 低频、需按任意字段查的任务表 | 不能改单条;高吞吐、多订阅方的事件流 |
一条日志的末尾只有一个写入点,吞吐天然有上限。分区就是把一个 topic 切成 N 条各自独立的日志,散在不同 broker 上各写各的——扩容从此是加分区、加 broker,而不是换更大的机器。
代价当场就付:offset 只在分区内单调,跨分区没有任何全局顺序,「有序」这个承诺就此降级成「同一分区内有序」。工程上的标准用法是拿业务主键当分区键——同一订单号的事件必然哈希到同一分区,于是「这一单是有序的」你还握着,「全站订单的绝对先后」你放弃了,而后者你本来也不需要。
消费侧是对称的规则:同组内,一个分区只能分给一个消费者。它同时给了你组内天然不重复消费,和一个硬天花板——消费者数超过分区数,多出来的纯闲着。所以分区数是你提前定下的并行度预算,经验法则是按单分区 10 MB/s 规划,要撑 1 GB/s 就得开 100 个分区上下。
这类系统的高吞吐,有一大半不来自存储结构,来自批量贯穿三段路径:生产者把消息攒在本地缓冲区,攒满一批或等够一小段时间(linger.ms)再发;broker 把这批原样落盘,不拆开、不重新编码;消费者也整批地拉。一次网络往返、一次系统调用、一次磁盘追加,就这样摊给了成百上千条消息。
这里藏着一个必须你自己拧的旋钮:linger.ms 从 0 调到 10,每条消息平白多等最多 10 毫秒,换来批次变大、吞吐可能翻几倍。低延迟与高吞吐在这里直接对立,没有两全。
「原样落盘」还顺手换来一个红利:既然磁盘上的字节和发给消费者的字节完全一致,broker 就能让内核把页缓存的数据直接送进网卡、不经过用户态——这就是零拷贝(zero-copy)。它不是一个独立技巧,而是「不解包」这个设计的副产品。
broker 主动推给消费者,还是消费者自己来拉?这一章选拉(pull),一句话的理由是:推模式逼着 broker 去关心它本不该关心的事——每个消费者的消化速度。速度交回消费者自己定,批量和重放就都是白送的(见下节表 2)。
每个分区有 1 个 leader 和若干 follower,读写都只走 leader;follower 只做一件事——不停地从 leader 拉取并追加同一条日志。优雅之处在于:复制复用的就是那条日志本身,不需要另一套同步协议。跟得上进度的那批 follower 组成 ISR,落后太多就被临时踢出去。生产者的 acks 决定「写入算成功」的标准:acks=0 发出去就算、acks=1 leader 写完就算(此刻宕机,没来得及复制的就没了)、acks=all 要 ISR 每一份都确认。
还有一个常被跳过、面试却爱问的机制:高水位(high watermark)——所有 ISR 都已拥有的那个最大 offset,消费者只能读到这里为止。意义是:消费者永远看不见「只有 leader 有、随时可能跟着 leader 一起消失」的那几条,于是故障切换后不会出现「我明明读到过、现在却没了」的幽灵消息。
两种状态性质完全不同。消费位点写得极其频繁——每个消费者每秒都可能提交一次。Kafka 早年把它存在 ZooKeeper 里,很快发现协调服务扛不住,于是搬进 broker 自己的一个内部 topic:用日志来存日志的进度,顺手复用了全套复制与持久化机制。而元数据——有哪些 topic、分区怎么分布、谁是 leader——写得少却必须强一致,还需要「选出一个 controller」的能力,这正是 ZooKeeper、etcd 的本职。
最后是再平衡:消费者加入、退出、崩溃或分区数变了,协调者就要重新分配「谁读哪个分区」。经典实现是 stop-the-world——全组停止消费,重分配,再继续。组大、分区多时可能卡住几秒到几十秒,这是消费者组模型最实在的一笔代价。
表 2 · 推模式 vs 拉模式
| 推(push) | 拉(pull,本章选择) | |
|---|---|---|
| 节奏谁定 | broker 定 —— 必须了解每个消费者的消化能力 | 消费者自己定 —— broker 不必关心 |
| 慢消费者 | 会被压垮,得额外做背压 | 自然退化成「读得慢」,不会崩 |
| 批量 / 重放 | broker 猜多大合适;重放难,它不记得谁读到哪 | 要多少拿多少;改一下位点就能重放 |
| 代价 / 典型 | 延迟低,消息一到就走。RabbitMQ push 模式 | 空轮询浪费,用长轮询补上。Kafka、Pulsar、RocketMQ |
表 3 · 三种投递语义:怎么实现、代价多少、什么场景选
| 至多一次 | 至少一次 | 恰好一次 | |
|---|---|---|---|
| 怎么实现 | acks=0,不重试;消费前先提交位点 | acks=all + 重试;处理完再提交位点 | 幂等编号 + 事务:把「写结果」和「提交位点」绑成一笔 |
| 会发生什么 | 网络一抖就丢,且你不会知道 | 会重复:提交位点前崩溃,这批再来一遍 | 不重不丢,但只在系统边界内成立 |
| 代价 | 几乎为零 | 下游必须自己做幂等 | 吞吐下降、实现复杂 |
| 选它的场景 | 日志、指标、埋点 | 绝大多数业务:订单、通知、同步 | 广告计费、金融流水 |
顺序 vs 扩展是另一处必须当场表态的地方。想要一个 topic 全局有序?那它只能有一个分区,吞吐就此封顶在单写入点的量级(几十 MB/s)。绝大多数场景该做的是把要求改小:按业务主键分区,只保证「同一实体的事件有序」。面试里被问「怎么保证消息顺序」,标准答案不是某个开关,而是先反问一句「需要谁和谁之间有序」。
保留期则是拿存储换后悔的余地:留 1 天,出 bug 只能重跑今天;留 7 天,一周内的错都救得回来,代价是磁盘乘以 7。若只关心「每个 key 的最新值」,还有日志压实(log compaction):同一 key 只留最后一条,日志就此变成一张能无限保存的快照表。
Apache Kafka 几乎逐条对应本章的设计。另外两条路也值得知道:Apache Pulsar 把 broker 和存储(Apache BookKeeper)拆开,扩容不必搬运数据;RocketMQ 补了事务消息和定时消息。而 AWS SQS 完全是另一支——真正的「消费即删除」队列,没有重放、没有分区顺序,换来的是你一点运维都不用管。选型的第一问永远是:你到底要不要「留着」?
面试里这一章的价值在于,它少有地能把「存储结构 → 扩展性 → 一致性语义」串成一条线。被问「Kafka 为什么快」,只答「顺序写加零拷贝」是在背答案;能说清「因为落盘格式和网络格式一致才能零拷贝」「因为分区内只有一个写入点才能保序」,才是真的理解了。
0.01%——一个反方向的实证:规模到了,可靠性是买来的,得先问价。Netflix Tech Blog「Kafka Inside Keystone Pipeline」, 2016 ↗① 全章的选择都倒推自一个存储决定——把消息落成只能追加的日志;分区、副本、位点、重放全是它的推论。
② 不用队列的代价是三样:耦合、快慢不匹配、故障传染(下游变慢 → 上游线程池打满 → 下不了单)。
③ 不拿数据库当队列:写是随机 I/O、删留空洞,而队列恰是「疯狂插入 + 疯狂删除」的最坏情形。
④ 敢写磁盘是因为只往末尾追加:顺序写约 600 MB/s vs 随机写约 100 KB/s——磁盘不慢,随机访问才慢。
⑤ 分区是扩展单位,也是顺序保证的边界;同组内一个分区只给一个消费者 → 分区数就是并行度天花板。
⑥ 吞吐靠批量攒出来,linger.ms 是延迟换吞吐的旋钮;零拷贝是「落盘格式 = 网络格式」的副产品。
⑦ 选拉不选推:消费者自己控速,批量与重放都是白送的。
⑧ 复制复用同一条日志;acks 决定持久性档位;高水位保证消费者读过的不会在故障切换后消失。
⑨ 位点写得频繁 → 存进内部 topic;元数据要强一致 → 交给共识组件。再平衡是 stop-the-world。
⑩ 投递语义按业务选:埋点用至多一次,绝大多数业务用至少一次 + 自己做幂等,只有计费和金融流水值得付恰好一次的代价。