专业书籍精读 · System Design Interview 卷二 · 第 9 章
System Design Interview Vol 2 · Ch 9 · Alex Xu & Sahn Lam · 2022
你手机里的照片自动备份、网站上的图片和视频、公司里那些永远删不掉的日志——它们大多住在一种叫对象存储的地方。这一章就是从零造一个这样的仓库:怎么装得下、怎么不丢、怎么还便宜。
这种仓库有一条听起来很蠢的规矩:东西一旦存进去,就不许原地改。哪怕只想改一个字,也得把整份重新存一遍。
可恰恰是这条「退让」,换来了后面所有的好处。因为不许改,就不用担心几份拷贝之间对不对得上;不用担心,就能随便多放几份、放到很远的地方、放在很便宜的盘上。它不是被迫这样,是主动砍掉一个能力去换另一堆能力。
我们熟悉的存法是像电脑里的文件夹一样层层套下去。东西少的时候很好用,可一旦是几十亿份,目录本身先撑不住:光是「这份东西叫什么、在哪儿」的登记簿就大到一台机器放不下。
更麻烦的是小东西特别费地方:仓库给每件货都要贴一张登记卡,一张照片可能只有几十 KB,那张卡却是固定成本。几十亿张卡加起来,比货还占地方。
① 账本和货架分家。「这份东西叫什么、谁的、什么时候存的」记在一本账本里;东西本身的字节堆在货架上。两者用一张取件单连起来。账本要频繁翻改、所以用擅长改的系统;货架只增不改、所以用最便宜的盘。
② 小件集包。不给每张小照片单独配一个箱子,而是把成百上千张顺手码进同一个大箱子,只在账本上记一句「在 7 号箱、从第几厘米开始、多长」。要取的时候直接伸手到那一段,不用把箱子拆开。
③ 抄三份,还是拆碎片配「补牌」。防丢最笨的办法是同一份抄三遍,占三倍地方。聪明的办法是:把一份东西拆成 8 块,再照着这 8 块算出 4 块补牌;12 块分放在不同机器上,随便坏掉哪 4 块,都能用剩下的推回原件——像记住了一道菜的配方,少一味也能反推出它是什么。只多占一半地方,扛得却比抄三份还多。
④ 定期体检。硬盘上的字节会自己悄悄变坏,没人报错。所以存的时候顺手记一个「指纹」,后台隔一阵就翻出来核对,对不上就用别处的好拷贝补回来。
拆碎片虽然省地方,但取东西时得同时找齐好几块拼回来,所以比直接拿一份现成的慢——热门的东西通常还是老老实实多抄几份。
对象存储是一场主动的减法:放弃「原地修改」,换来近乎无限的扩展、便宜的盘和极高的安全性。做法是把账本和货架拆成两套完全不同的东西,小件集包省登记卡,靠「拆碎片 + 补牌」用一半多的地方换掉三倍的拷贝,再用定期体检防住字节自己变坏。代价是取东西要拼装,所以慢一点。
想进到具体机制、记号和示意图? → 切到精读版
这一章表面是「造一个 S3」,实际讲的是一条更普适的架构手艺:把系统沿着「可变 / 不可变」这条缝劈成两半——海量、微小、天天被改的元数据走一套数据库,巨大、只写一次、再不修改的字节走另一套仓库,中间只留一张取件单。劈开之后两边各自能用最极端的手段优化:一边享受索引与事务,一边享受顺序追加、合并小对象、纠删码与冷存储。对象存储的能力不是加出来的,是「放弃原地修改」这一步减法换来的。
/ 只是名字中的一个普通字符。k 块,再算出 m 块校验块;任意丢不超过 m 块都能还原,空间开销 (k+m)/k 倍。卷二第 9 章。上承 Ch8「分布式邮箱服务」——那一章把附件甩给了「旁边的对象存储」,这一章就是把被甩过去的那个东西拆开看。它和 Ch4「分布式消息队列」一起构成卷二独有的「造轮子的内部构造」主线:不是用现成组件搭系统,而是自己实现那个组件。现实对应物是 S3、Google Cloud Storage、Azure Blob Storage、Ceph、MinIO,以及数据湖、备份系统、CDN 回源站的底座。
书里给的目标很朴素:撑住 100PB 数据、99.99% 的服务可用性、持久性高到「基本不会丢」,同时成本要压住。最后一条才是整章的发动机——前三条光靠「多抄几份」就能满足,只有把成本一起摆上桌,纠删码和小对象合并才成为必须。
把 100PB 换算成物理世界:按一块 4TB 机械盘算,100PB / 4TB = 25,000 块盘。而一块机械盘一秒只做得了约 100~150 次随机寻址——整个集群的随机 IOPS 预算是死的,约二三百万次/秒,再有钱也买不来更多。任何「一次读多拐一个弯」的设计,都是在花这笔预算。
书里按「约 40% 小对象(1MB 以下)、20% 中等、40% 大对象」的混合来估,平均对象落在百 MB 量级、总数十亿级;每对象约 1KB 元数据,元数据表就是 TB 量级,单机放不下、必须分片。
最要命的是小对象那 40%:若把每个对象当一个文件存进 Linux 文件系统,每个文件占一个 inode(约 256 字节)。几十亿个 inode 本身就是几百 GB 的纯开销;更糟的是读一个 10KB 缩略图要先花一次 IOPS 查 inode、再花一次读数据,把那笔死预算凭空翻倍。「海量小对象」不是调优题,是架构题。
块存储给你一块裸盘,文件存储给你一棵目录树,对象存储给你一个扁平的、按名字取整份的 HTTP 接口(细节见表 1)。差别不在「像不像文件夹」,在放弃了什么:它放弃了原地修改和真目录,于是一连串困难跟着消失——对象既然不会变,多份拷贝就永不需要协调;不需要协调,就能撒到不同机架、机房、介质上;只写一次,就能顺序追加、批量打包、事后压实。S3 能做到「往里扔多少都行」,代价早在接口设计那一刻就付过了。
这是全章的骨架。一次上传被劈成两步,落进两个完全不同的系统:
UUID 和它在文件里的位置。(bucket_id, object_name) → UUID,附带大小、时间、类型、权限。顺序不能反:永远先落数据、再落元数据。反过来一旦中间崩了,就留下一条指向不存在字节的元数据——用户看得见却取不出来,最糟的一种坏。按正确顺序崩,最坏只剩一块没人认领的孤儿数据,后台扫一遍就清掉。宁可留垃圾,不可留悬空指针。
既然 inode 是死成本,答案就是别让文件系统知道有那么多对象。数据节点上只维护少量「读写文件」,每个几 GB;新对象一律追加到当前那个文件尾部,写满就封存、转为只读,另开一个。
对象与位置的对应记在一张本地的 object_mapping 表里:UUID → (文件名, 偏移, 长度)。读时查一次映射,再一次带偏移的顺序读就把对象取出来——inode 开销摊薄到几乎为零,IOPS 从「两次」压回「一次」。
代价在删除上。文件已封存成只读,删一个对象只能在映射表里打个墓碑,字节还占着地方。真正回收靠压实:后台挑出死数据比例高的文件,把活着的对象搬到新文件、改映射、再整个删掉旧文件。这正是 LSM-tree 那套思路搬到了对象层——写入永远顺序,代价推迟到后台批量付。
防丢最直白的办法是同一份存三遍,空间开销 3×:100PB 的数据要买 300PB 的盘——这笔账在这个量级上是致命的。
纠删码换了个思路:把对象切成 8 个数据块,再算出 4 个校验块,12 块撒在 12 台机器上,任意丢不超过 4 块都能还原,空间开销只有 12/8 = 1.5×。直觉上像解方程:12 个方程解 8 个未知数,划掉任意 4 个仍够解。关键在于这不是「省钱但更险」,而是又省钱又更稳——三副本只能同时坏 2 份,8+4 能同时坏 4 块。公有云敢宣称 11 个 9,靠的正是它。
代价有三层:① 修复放大——丢一块要读齐 8 块才能重建,副本只需拷贝一份;② 读尾延迟——一次读要凑齐多块、受最慢那块拖累;③ CPU——编解码是实打实的计算。所以真实系统几乎都分层:热数据先用多副本,冷下来再由后台转成纠删码。
还有一层容易被忽略:再多的冗余也防不住位腐烂。磁盘悄悄翻转某个位而不报错时,系统根本不知道该信哪一份。所以每块入库时都算一个校验和随块保存,后台定期巡检重算比对,对不上就用别处的好块重建——持久性由此从「多放几份」升级成「可验证」。
元数据分两张表。桶表很小(一个用户通常几个到几十个桶),单机加只读副本就够。对象表是十亿行、TB 级,必须分片,而分片键直接决定哪个操作变贵:按 bucket_id 分,「列出桶内对象」只扫一片且天然有序,代价是大桶即热点——现实里一个桶装几亿对象很常见,这条路会先崩;按对象完整路径的哈希分,分布绝对均匀、单对象读写永远只打一片,代价是列出一个桶要扇出到所有分片再归并。
书里选了后者:单对象 GET 是压倒性高频,列举是低频的管理操作;真需要快,再单独维护一张按桶聚合的索引表。这个取舍值得记住:分片键永远在保护最高频的那条路径,而不是追求「哪条路径都不慢」。
顺手还解决两件事。版本控制:对象表加一列版本号,新版本是追加一行而不是覆盖,GET 默认取最新。删除:开了版本控制后,删除只是再追加一行删除标记,老版本仍在盘上——这既是误删能找回的原因,也是「删掉的东西还在计费」的原因。空间回收交给生命周期规则与压实。
大对象走分片上传:把一个 10GB 文件切片(S3 的下限是 5MB)并行传,失败只重传那片,最后一次 complete 把它们在元数据里拼成一个对象。没走到 complete 的片是彻底的垃圾,必须定期清——否则你会为一堆看不见的字节持续付钱。
表 1 · 三种存储:不是好坏之分,是放弃了什么之分
| 类型 | 能力 | 代价(诚实说) | 什么时候选它 |
|---|---|---|---|
| 块存储 | 随机读写任意位置,延迟最低(微秒~毫秒级) | 一般只挂给一台机器;容量受单卷上限约束;贵 | 数据库文件、需原地改写的负载 |
| 文件存储 | 有目录树、能多机共享,应用几乎不用改代码 | 目录树本身成为扩展瓶颈;几十亿文件时元数据服务先垮 | 团队共享盘、遗留应用上云 |
| 对象存储 (本章) | 容量近乎无限、单位成本最低、HTTP 可直接寻址、持久性最高 | 不能原地改;无真目录,重命名「目录」是 O(n) 次改名;首字节延迟几十~上百毫秒 | 图片视频、备份归档、数据湖——写一次读多次的一切 |
表 2 · 冗余方案:三副本 vs 纠删码
| 方案 | 好在哪 | 代价(诚实说) | 适用判据 |
|---|---|---|---|
| 三副本 | 实现简单;修复只是整份拷贝;读延迟最低;小对象友好 | 空间开销 3×,100PB 要买 300PB;容错上限只有 2 份 | 热数据、小对象、延迟敏感 |
纠删码 8+4(书里的选择) | 空间只 1.5×,容错反而更高(任意 4 块);大规模下省的是一整个数量级 | 修复放大:丢 1 块要读 8 块;读需凑齐多块、尾延迟差;吃 CPU | 温冷数据、大对象、成本主导 |
| 分层(真实系统) | 热用副本、冷转纠删码,两头好处都拿到 | 多一套后台转码流水线;转换期数据有两种形态,运维复杂度上升 | 规模够大、冷热差异明显时 |
表 3 · 对象元数据的分片键:保护哪条路径
| 分片键 | 什么变快 | 什么变贵(诚实说) | 适用判据 |
|---|---|---|---|
bucket_id | 列出桶内对象只扫一片,且天然按名字有序 | 大桶即热点:一个桶装几亿对象时这一片先垮,且无法再切 | 单桶规模可控的私有部署 |
| 完整路径哈希 (书里的选择) | 分布绝对均匀,单对象 GET/PUT 永远只打一片 | 列出一个桶要扇出到全部分片再归并;前缀查询同理 | 公有云形态:单对象访问高频 |
| 哈希分片 + 独立 listing 表 | 两条路径都快 | 多一份索引要维护,写变双写,两者只能最终一致 | 列举确实高频(数据湖扫描)时才值得 |
这一章的价值远超「会背 S3 的架构」。「可变的小元数据 + 不可变的大数据」这个劈法,是现代数据基础设施的通用形状:数据湖(Iceberg / Delta Lake)是元数据表 + 对象存储上的 Parquet 文件;Kafka 的分段日志是同一套路;上一章的邮箱把附件甩出去也是。面试里遇到任何「海量非结构化数据」的题,先问一句「这些字节改不改」,就已经答对一半。
8+4 的修复放大在真实规模下痛到需要专门设计新码。Huang 等 · Erasure Coding in Windows Azure Storage · USENIX ATC 2012 · 论文页a/b/c.jpg 里的 / 只是名字里的字符。因此「重命名一个目录」是把底下每个对象各改一次名,是 O(n) 而不是 O(1)——这正是早年大数据作业直接往 S3 写结果时慢得离谱的根因(提交阶段要大量改名)。① 对象存储的能力是一次减法换来的:放弃原地修改与真目录,才换到无限扩展、最低单位成本与最高持久性。
② 骨架是数据面 / 元数据面分离:不可变的大块字节一套系统,可变的小行一套数据库,中间只连一张 UUID 取件单。
③ 写入顺序不可颠倒:先数据后元数据。宁可留孤儿数据(GC 能清),不可留悬空指针(看得见取不出)。
④ 100PB ≈ 25,000 块 4TB 盘,集群随机 IOPS 是死预算——任何多拐一个弯的读都在花这笔钱。
⑤ 海量小对象是架构题:顺序追加进 GB 级大文件,用 UUID →(文件,偏移,长度) 定位,inode 开销与多余的那次 IOPS 同时消失。
⑥ 删除只打墓碑,空间靠后台压实回收——写入永远顺序,代价推迟到后台批量付。
⑦ 8+4 纠删码:1.5× 空间、容忍任意 4 块丢失,比三副本又便宜又更稳;代价是修复放大、读尾延迟与 CPU。
⑧ 冗余防不住位腐烂:每块存校验和、后台巡检重算,持久性才从「多放几份」升级成「可验证」。
⑨ 分片键按完整路径哈希,保护的是高频的单对象 GET,代价是「列出一个桶」要扇出归并。
⑩ 两条最贵的误读:11 个 9 不等于不用备份(最常见的丢数据原因是误删);对象存储不是文件系统(重命名「目录」是 O(n))。