专业书籍精读 · System Design Interview 卷一 · 第 15 章
System Design Interview Vol 1 · Ch 15 · Alex Xu · 2020
你在电脑上改了一个文件,手机上一打开就是新的。这一章问:把这件事做给几千万人用,背后要建些什么?答案有点反直觉——存文件反而是最容易的一步,难的是让你那五台设备对「现在到底哪一版才算数」始终说法一致。
你在一份两百页的文档里改了一个字,网盘并没有把这两百页重传一遍。它只补了改动落在的那一小段——在它眼里,你的文件早就被剪成了一叠小卡片,你动了哪张,它就补哪张。
早年的做法是「整份重传」。文件小的时候没人在意,可一旦你天天改一个几百兆的设计稿、还开着手机热点,代价立刻变得荒唐:改一个字,传半小时。更麻烦的是设备从不同时在线——笔记本改完就合上,平板三天后才开机,中间两边可能各改过一次。系统必须替你把这笔糊涂账理清楚。
① 把文件剪成小卡片。上传前先切成大小差不多的小块,每块单独压缩、加密、存进仓库。以后改一处,只补那一张卡片。
② 货和账分开放。网盘其实是两套系统:一套放「货」(那些小块),一套记「账」(谁有哪些文件、第几版、由哪些块拼成)。你看到的目录、文件名、历史版本全在账本里。货那半边早就被云仓库解决了,剩下的难题几乎全在账上。
③ 谁先记上账,谁就算数。两台设备离线各改一次,最后同时上线——账本只认先到的那一版;后到的不会被悄悄盖掉,而是另存一份「冲突副本」摆在你面前,让你自己决定留哪个。
所有取舍最后都指向同一个问题:卡片切多大。切得太碎,账本上的条目会暴涨,光记账就把系统压垮;切得太大,改一个字又要重传一大坨。业界的答案落在几兆这个量级——大到账记得过来,小到重传不心疼。
代价很诚实:省下来的是带宽,赔进去的是账本——文件一多,慢的往往不是传输,而是「算清楚这次到底该传哪几块」。
网盘不是存储系统,是同步系统:文件切成块丢给云仓库,真功夫全在那本记着「谁有什么、第几版、由哪些块拼成」的账上。两边打架时,它选择让你看见冲突,而不是替你做决定。
想进到具体机制、记号和示意图? → 切到精读版
你以为设计网盘是设计一个存储系统,其实存储那一半早被对象存储买断了——这一章真正在设计的是一本账:把文件切成不可变的块交给云存储,用一套元数据(metadata)记录「谁有哪些文件、第几版、由哪些块拼成」,再用增量同步(delta sync)压住带宽、用通知服务把多台设备拉回同一版本。全章的难点、一致性要求与成本,几乎全压在元数据这一面。
4 MB。本章是 Vol 1 案例部分的收官之作(Ch16 是收尾励志章、无实质内容),也是全书唯一一道客户端占一半戏份的题:前面几章的主角都是服务端集群,这里有相当一部分设计发生在你电脑上那个同步客户端里。它把 Ch6 键值存储的复制思路、Ch14 YouTube 的对象存储与分片上传都接了过来,对应现实里 Dropbox / Google Drive / OneDrive / iCloud Drive 这一类个人云存储。
先把规模摆出来(书里的口径):5000 万注册用户、1000 万日活,每人 10 GB 免费空间,人均每天上传 2 个文件、平均 500 KB,读写比 1:1。算下来总存储 5000 万 × 10 GB = 500 PB;上传接口 QPS 只有 1000 万 × 2 ÷ 86400 ≈ 240,峰值按两倍算也就 480。
这组数字本身就是题眼:QPS 低得离谱,存储量高得离谱。480 QPS 一台像样的机器就能扛住,可 500 PB 数据、上百亿个块的账,以及「一份文件在五台设备上都得是最新的」这件事,才是真正的工程量。不解决会怎样?账单和体验会同时崩:整份重传让一个几百兆的文件每改一次就吃掉几百兆上行带宽;没有版本与冲突处理,两台设备同时编辑就会静默丢数据——而本章把「绝不丢数据」列为第一条非功能需求,一次静默覆盖就足以让用户永久离开。
起点是一台机器:Web 服务器收文件、写进本地目录、把文件名和大小记进 MySQL。它一定会撞墙——单机的盘装不下 500 PB。于是文件本体交给 Amazon S3 一类对象存储(靠跨区域复制换持久性与可用性),自家机器只留账本。
这一刀劈下去,系统就长成了两条独立的路:数据面(客户端 → 块服务器 → 对象存储,走的是字节,量大、可异步、可重试)与元数据面(客户端 → API 服务器 → 元数据缓存 / 元数据库,走的是账,量小但要求强一致)。再补三个部件就是本章的完整骨架:通知服务(文件变了通知其它设备)、离线备份队列(设备不在线时把变更排着队,上线再消费)、冷存储(久无人问津的块挪去便宜档位)。
这个「劈两半」不是面试书的发明,而是大规模存储的通行结构:Google 的 Colossus 就是把元数据放进 BigTable、让数据在客户端与磁盘服务器之间直连(见 §6)。
上传前,客户端(或块服务器)把文件切成上限 4 MB 的块,每块单独压缩(按文件类型挑算法)、加密,再送进对象存储。此后改动只重传受影响的那几块——这就是增量同步。
给个量级感:一个 300 MB 的工程文件,你改掉其中一小段。全量重传要走 300 MB 上行;块级只需重传 1 个块,最多 4 MB,差了约两个数量级。在人均上行带宽只有几兆的年代,这是「能不能用」与「不能用」的区别。
那为什么不切得更小?因为每个块在元数据库里都是一行,还带一个哈希。若切成 4 KB,500 PB 就意味着上百万亿行块记录——省下的带宽全赔在元数据的存储、索引与查询上。切得太大又退回全量重传。4 MB 是这两条曲线的交点,不是什么魔法数字:它是「账记得过来」与「重传不心疼」之间的那个折中。
还有一条容易被略过、却支撑起整章的性质:块一旦写入就不再修改。改文件等于写新块 + 在元数据里换指针。不可变让去重、缓存、跨区域复制都变得简单(内容永不变,缓存就永不失效),代价是旧块会堆积,要靠版本策略和垃圾回收慢慢收。
元数据这一侧的表大致是:user(用户)、device(设备,用来推通知)、namespace(用户的根目录)、file(文件)、file_version(版本)、block(块,记录它属于哪个版本的第几块)。一个文件的内容 = 按顺序拼起来的一串块 id,这是整本账的核心一行。
上传不是一个请求,而是两条并行的路:先 POST 一条文件元数据、状态标成 pending(待确认),同时把变更块推给块服务器;块全部落进对象存储后回调 API 服务器,元数据状态才改成 uploaded(已完成),再交给通知服务广播出去。
为什么非要 pending?因为两条路是异步的:没有它,系统就分不清「正在上传」和「上传失败」,别的设备还可能读到一条指向尚不存在的块的记录、下载时直接 404。pending 是把两条异步的路重新缝在一起的那一针。
元数据这一侧还必须强一致:同一个文件在五台设备上显示的必须是同一版。所以缓存要在写入时同步失效、元数据库选带 ACID 的关系型。书里点破了一个很容易踩的坑:主从复制的异步滞后本身就会破坏强一致——从库返回旧版本,用户就会看见「文件回退了」,而在同步语义里,回退和丢数据是一回事。
需求很简单:设备 A 改了文件,其它在线设备要尽快知道。书的选择是长轮询,理由两条,都很朴素:一是这里的通信不是双向的——服务端要告诉客户端「变了」,客户端并不需要通过这条通道频繁回话;二是通知本身很稀疏——按前面的量级,一个用户一天也就两三次文件变更,不是聊天那种每秒好几条。WebSocket 的双向实时能力在这里用不上,却要付出连接管理与协议升级的复杂度。
把它和 Ch12 聊天系统对照着看,是全书最值得记住的一次对比:同样是「服务端主动推」,负载性质不同,答案就完全不同——聊天必须 WebSocket,因为消息双向且高频;网盘用长轮询就够。面试里能主动做出这个对照,比背下任何一个结论都值钱。
代价也要诚实说:长轮询同样要维持海量挂起连接,一个通知节点挂掉,挂在上面的客户端会同时重连,形成一次自制的惊群;离线设备错过的变更,则靠离线备份队列在它上线时补齐。
500 PB 的账单会逼出三个动作:① 块级去重——对块内容取哈希,内容相同只存一份,一个热门安装包被一万人上传也只占一份;② 限制版本数——只留最近 N 个版本,别让「无限历史」变成无限账单;③ 冷热分层——几个月无人访问的块挪去 Glacier 这类冷档,单价低一个数量级,代价是取回慢、还按取回量另收费。三招都不动功能,只动成本曲线。
表 1 · 同步粒度:全量 vs 块级增量
| 全量同步 | 块级增量同步 | |
|---|---|---|
| 改一小段的上行量 | 整个文件(300 MB) | 一个块(≤ 4 MB) |
| 元数据体量 | 每个文件一行 | 每个块一行——可达百亿量级 |
| 客户端复杂度 | 几乎没有 | 要切块、算哈希、比对差异、断点续传 |
| 去重能力 | 只能整文件去重 | 跨文件、跨用户按块去重 |
| 适用 | 小文件、原型阶段 | 大文件反复编辑——网盘的主场景 |
表 2 · 变更通知通道怎么选
| 长轮询(本章选择) | WebSocket | 客户端定时轮询 | |
|---|---|---|---|
| 适合的负载 | 稀疏、单向的服务端通知 | 高频、双向(聊天、协同光标) | 可容忍分钟级延迟 |
| 延迟 | 秒级 | 亚秒级 | 取决于轮询间隔 |
| 代价 | 海量挂起连接;节点故障引发重连风暴 | 连接管理与协议复杂度更高 | 空转请求浪费,间隔越短越浪费 |
| 本章判据 | 通信不双向 + 通知不频繁 → 选它 | Ch12 聊天系统才该选 | 规模一大就不划算 |
表 3 · 多端离线编辑撞车了怎么办
| 策略 | 做法 | 诚实代价 |
|---|---|---|
| 先到先得 + 冲突副本 (本章选择) | 先被处理的版本成为当前版本;后到的不覆盖,另存一份冲突副本交给用户合并 | 把决策推给了人;文件夹里会多出「冲突副本」这种噪声 |
| 后写覆盖 | 直接以最后一次写入为准 | 静默丢数据——网盘绝不能接受 |
| 自动合并(OT / CRDT) | 按文档结构合并两边的编辑 | 只对有结构的文档成立;通用文件是不透明字节,系统不知道语义 |
第三行正是本章为何不自动合并的原因:网盘面对的是任意二进制文件,系统无从判断两处改动能否共存。能自动收敛的,只有把编辑建模成有语义操作的那条技术路线。
还有一处书在收尾时留下的开放权衡:切块该放客户端还是服务端。放客户端(直传对象存储)省掉一跳、更快也更省服务端带宽,代价是切块 / 压缩 / 加密要在每个平台各实现一遍,且客户端可被逆向、加密逻辑会暴露;放服务端则统一可控,但所有字节都得先过你的机器。
这一题的价值在于,它是全书唯一一道必须回答「客户端该做多少事」的题——当纯服务端题来答就答偏了。而它建立的那套心智——数据面 / 元数据面分离、内容切成不可变块、状态机式的上传流程、按负载性质挑通知通道——是所有同步类产品的通用骨架:备份工具、代码托管、照片流、离线优先的移动应用,结构都长得很像。
11 个 9:「我们总是在写入被确认成功之前把数据冗余地存到多个可用区」,并定期校验校验和、发现不匹配就用冗余自动修复——这正是本章敢把文件本体外包给对象存储的底气。Google Cloud Blog「11 nines of durability」, 2021-01 ↗4 MB 就出自这一系谱。Dropbox 工程博客《Inside the Magic Pocket》, 2016(本次未能验证链接可达,故只留文字出处)4 MB 是折中点而非最优解。① 一句话:网盘不是存储系统而是同步系统;存储可以外包给对象存储,元数据与同步语义外包不掉。
② 规模的题眼:500 PB 存储 vs 峰值仅 480 QPS——难的从来不是并发,是数据量与多端一致。
③ 骨架是一刀两断:数据面(块服务器 → 对象存储,量大可异步)与元数据面(API → 缓存 / 库,量小须强一致)。
④ 文件切成上限 4 MB 的不可变块,改动只重传受影响的块;块大小是「带宽 vs 元数据行数」的折中。
⑤ 上传是两条并行的路 + 一个 pending → uploaded 状态机;pending 是异步流水线的事务边界。
⑥ 通知选长轮询,判据是「通信不双向 + 通知不频繁」;聊天系统换成 WebSocket,因为负载性质变了。
⑦ 冲突处理是先到先得 + 冲突副本:宁可把决策还给用户,也绝不静默覆盖;自动合并只在结构化文档上成立。
⑧ 省空间三招:块级去重、限制版本数、冷热分层;三招都只动成本曲线,不动功能。