专业书籍精读 · System Design Interview 卷二 · 第 8 章
System Design Interview Vol 2 · Ch 8 · Alex Xu & Sahn Lam · 2022
你每天打开的 Gmail、QQ 邮箱、公司邮箱,背后要同时给十亿个人各管一个信箱:收得下、找得到、换台手机还得一模一样。这一章就是把「造一个这样的邮箱服务」从头到尾走一遍。
邮件的收发规矩是 1980 年代定下来的,四十年几乎没改过,照着做就行。可这一章依然是全书最重的几章之一——因为难的从来不是「怎么把信送过去」,是「送到之后往哪儿放、以后怎么找回来」。邮箱本质上不是通信系统,是个超大仓库。
早年的邮件服务器很朴素:一台机器,每个人的信就堆在这台机器上的一个文件夹里。人一多就全线崩塌——一天新进来的信就能把一台机器塞满;机器一挂,这批人集体失联;而且邮件几乎不许删(法务、审计都要求留着),只进不出。
还有件反直觉的事:真正压垮系统的往往不是「收信」,而是你随手点的那些小动作。标个已读、加个标签、拖进归档——每一下都是一次写入。你一天读 40 封,就至少有 40 次这种写。
① 一人一格。邮箱有个别的系统羡慕不来的好处:没人会去查别人的信箱。所有操作天然只关一个人的事。于是把仓库按人切成一格一格,任何一次读写都只碰一格——那些「要协调好几台机器才能办成一件事」的麻烦,直接不存在了。
② 信和包裹分开放。正文很轻(几行字),附件很重(几百 KB 到几十 MB)。所以附件送进旁边的大仓库,信箱格子里只留一张取件单。这样翻收件箱的时候,不会被一堆大包裹拖慢。
③ 每人一本自己的目录。搜邮件和上网搜东西完全是两回事:搜网页是十亿人查同一本大目录;搜邮件是十亿本小目录,每本只服务一个人。你那本薄得很,所以能做到「一秒前刚到的信,现在就搜得着」。
④ 门口收发员只管签收。外面的信送上门,门口不能让人家在门外干等——先签个字收下,扔进传送带;查毒、拦垃圾、分拣归档这些慢活,在传送带后头慢慢干。
一封信实际上被写到了三个地方:信箱、大仓库、目录本。所以删一封信要删三处,而这三处在短时间内可能对不上——你删了,搜索里可能还残留一会儿。
邮箱服务的难点不在「送信」,在「存和找」。它最大的本钱是没人查别人的信箱:按人把仓库切成一格一格,重活就都变成了一格之内的事;重附件挪去旁边的大仓库,目录本每人一册。代价是一封信散在三处,删改时短时间内会对不齐。
想进到具体机制、记号和示意图? → 切到精读版
这一章表面是「造一个 Gmail」,实际上是整套 Xu 系列里分区键(partition key)最干净的一道题:邮件协议那一层四十年前就冻结了,工程量全压在十亿个互不相干的私人信箱上——而正因为任何查询都只落在一个人身上,分布式里最贵的那几样(跨分区事务、全局排序、全局索引)在这里可以整套不买。这一章真正教你的,是认出并用尽这份天赐的分区键。
卷二第 8 章。上承 Ch7「酒店预订」(有限库存的并发争抢),下启 Ch9「对象存储」。它是卷二那条「造轮子的内部构造」主线的前半段:本章把「海量小对象的元数据」摊开讲,下一章再把承接附件的那个存储层本身拆开。现实对应物是 Gmail、Outlook、企业邮件网关,以及任何「每个用户一个私有数据空间」的系统——网盘、笔记、私信收件箱都是同一形状。
先按书里那组假设把账算出来:10 亿用户,人均每天发 10 封、收 40 封,正文平均 50KB,20% 的信带附件、附件平均 500KB。
10^9 × 10 / 86400 ≈ 11.6 万封/秒;收信:10^9 × 40 / 86400 ≈ 46 万封/秒。10^9 × 40 × 50KB ≈ 2PB,附件 10^9 × 40 × 20% × 500KB ≈ 4PB——合计约 6PB/天,而且几乎只增不减。传统邮件服务器的做法是一台机器上一个 Maildir 目录,每个人的信当文件堆着。这套东西在 6PB/天面前三处同时崩:存不下(单机容量)、搜不动(全文搜索要扫文件)、挂一台掉一批人(没有副本)。
更隐蔽的一条是写放大在状态位上:邮件正文写一次就不动了,但「已读 / 未读 / 标签 / 归档」每天被改无数次。按上面的假设,光是「读完标已读」这一个动作,全局就是约 46 万次/秒的小更新——跟收信本身同一个量级。邮箱不是一个读系统,是一个读写都很重的系统,这直接决定了它不能用只读优化的存储。
对外你必须讲 SMTP:发信时查对方域名的 MX 记录找到目标服务器,建连接把信推过去。这一层你改不了,也控制不了对端——对方服务器可能宕着、可能限速、可能把你判成垃圾直接退回。
由此推出第一条架构硬约束:出站绝不能同步等。用户点「发送」得立刻返回,投递本身是个可能要重试几小时甚至几天的后台任务。所以出站必须是队列 + 指数退避重试 + 死信/退信。
对内则是另一套:POP3 是单设备时代的遗产(下载即删),IMAP 让服务端成为唯一真相、多设备各自同步,而今天网页版和手机 App 实际走的是自家的 HTTPS 接口 + WebSocket 推送——只在跟第三方客户端(Outlook、Apple Mail)打交道时才需要真的实现 IMAP。
书里先把邮件元数据的性格列清楚,选型是从性格里推出来的:
最后一条是整章的枢纽。它意味着「一致性边界」可以正好画在一个用户身上:把行键(row key)取成 user_id,一个用户就是一行,那么所有需要原子性的操作——收一封信、标已读、批量归档——全部落在同一行内。绝大多数宽列存储都提供单行原子写,于是分布式事务这项最贵的开销,一次都不用买。Google 的 Megastore 论文(CIDR 2011)正是拿邮箱账户当「实体组」的范例:组内保证 ACID,组与组之间不保证——邮箱天然长成这个形状。
列键则再吃一次免费的午餐。把它设计成 文件夹:时间戳:邮件ID 这样的复合键,宽列存储本就按列键排序,于是「取收件箱最近 20 封」退化成一次反向范围扫描:不需要二级索引,不需要 ORDER BY,不需要 offset 分页。
正文通常几 KB 到几十 KB,附件则是几百 KB 起、上限 25MB(Gmail 的公开限制)。若把附件字节塞进元数据行,收件箱列表这个最高频的操作就得连带拖动几百 MB 的无用数据。所以书里的做法是把附件放进外部对象存储,元数据里只留一个键。
这带来一个漂亮的顺手收益:按内容哈希去重。一封带 10MB 附件的信群发给 100 人,对象存储里只存一份,元数据里 100 张「取件单」指过去——省掉 99% 空间。代价是删除从一次写变成了引用计数与垃圾回收问题:最后一个引用消失前,那份字节不能动。
大附件上传还得是分片上传 + 断点续传:25MB 在弱网下一次性 PUT 的失败率高到不可接受,切成 5MB 一片重试才有意义。
这是全章最容易被想歪的地方。邮件搜索和网页搜索有三条根本差别:
把这三条合起来,索引的分片键就只剩一个选择:还是 user_id。一次搜索只打一个分片,不需要把查询扇出到上千个分片再归并——这正是邮件搜索能做到几十毫秒级而成本又不爆炸的原因。
写路径因此被搜索反向塑形:一封信进来,要同时写元数据、写对象存储、写索引。这三者不在同一个事务里,所以书里的处理是异步写 + 失败重试 + 定期比对补偿,接受一个秒级的不一致窗口。把搜索当成「以后再加的功能」,是这道题最常见的翻车点——它决定的是写路径的形状,不是读路径的装饰。
表 1 · 元数据存哪:三条路线,书里选了第三条
| 方案 | 好在哪 | 代价(诚实说) | 适用判据 |
|---|---|---|---|
| 关系型数据库 | 事务、二级索引、SQL 全都现成;小规模邮箱(百万级用户)完全够用 | PB 级下分库分表成为主要工作量;全文搜索要么外挂、要么拖垮主库;行宽不适合几十万列的信箱 | 用户数 ≤ 千万、单用户邮件量可控时,别过度设计 |
| 纯对象存储 | 容量与成本最优,天然适合只写一次的正文和附件 | 改一个已读位要重写整个对象;没有范围扫描、没有按时间取最近 N 封 | 只作为附件层与冷归档层,不承担元数据 |
| 宽列 NoSQL (书里的选择) | 行键 = user_id 直接分区;列键自带排序;单行原子写覆盖全部事务需求;水平扩到 PB 级 | 没有跨用户查询能力(本题恰好不需要);运维门槛高;胖行需要自己二次切分 | 十亿用户量级、访问天然按人隔离 |
表 2 · 搜索:买现成的,还是长在存储里
| 方案 | 好在哪 | 代价(诚实说) | 适用判据 |
|---|---|---|---|
| Elasticsearch 一类 | 开箱即用:分词、相关性、聚合、高亮全有;生态成熟,几周能上线 | 额外一套与原文同量级的存储与内存开销;十亿分片的运维极重;重建索引是以天计的工程 | 用户规模中等、团队要快速交付;或搜索功能要求丰富 |
| 与邮件存储共用一套 (LSM 上原生建索引) | 写入顺带建索引,无第二套系统;实时性最好;存储与索引共享分片与副本,不会分裂 | 要自己实现分词、合并、压缩;功能远不如 ES 丰富;只有到了极大规模才划算 | 十亿用户、成本敏感、且团队有存储引擎能力 |
表 3 · 客户端怎么取信:三种同步模型
| 协议 | 模型 | 代价(诚实说) | 今天的位置 |
|---|---|---|---|
| POP3 | 下载到本地,服务端删除 | 多设备下完全不成立:手机收了,电脑就看不见 | 只为极老的客户端保留 |
| IMAP | 服务端为真相,客户端同步文件夹与标志位 | 协议状态多、实现复杂;大信箱首次同步代价很高 | 对接 Outlook / Apple Mail 的必需品 |
| HTTPS + WebSocket | 自家接口,按需分页拉取 + 新信实时推送 | 只对自家客户端有效,第三方客户端接不进来 | 网页版与自家 App 的主路径 |
这道题在面试里的独特价值是:它是唯一一道分区键已经写在题面上的题。考官真正在看的,不是你能不能背出 SMTP 的握手,而是你有没有意识到「没人查别人的信箱」这句话一次性买断了跨分区事务、全局排序和搜索扇出——并把这份红利用到底。答不出这层,整道题就退化成一堆框图。
① 邮箱不是通信系统,是存储与检索系统:协议四十年没变,工程量全在「存哪、怎么找」。
② 量级:10 亿用户下发信约 11.6 万/秒、收信约 46 万/秒,每天新增约 6PB,且只增不减。
③ 真正的写压力在已读位与标签,与收信同量级——它决定了不能用只读优化的存储。
④ 本章的枢纽:没人查别人的信箱。一致性边界正好画在一个用户上,行键取 user_id,跨分区事务整套不用买。
⑤ 列键设成 文件夹:时间戳:邮件ID,「取最近 20 封」退化成一次范围扫描——排序成了存储布局的副产品。
⑥ 正文与附件分家:附件进对象存储、按内容哈希去重,元数据只留键;代价是删除变成引用计数与 GC。
⑦ 邮件搜索 ≠ 网页搜索:十亿个小索引各服务一人、按时间倒序、必须秒级可搜;分片键还是 user_id,一次搜索只打一个分片。
⑧ 一封信落三处(元数据 / 附件 / 索引),三者不在一个事务里,只保证最终一致——搜索是写路径的形状,不是读路径的装饰。
⑨ 选型:宽列 NoSQL 胜关系型与纯对象存储;搜索在「买 Elasticsearch」与「长在 LSM 上」之间按规模取舍。
⑩ 两条血的教训:副本不是备份(2011 Gmail 靠磁带恢复);反垃圾是主路径不是附件(Gmail 每天拦超 1 亿封钓鱼)。