问题场景 + 需求约束
设计一个类 S3 的对象存储:服务 千亿级对象、100 PB 容量,单对象 1 字节到 5 TB,11 个 9 持久性(99.999999999%,即每年 1000 万个对象里坏不到 1 个)。这是网盘、备份、数据湖、图片/视频源站的地基——Dropbox、Netflix 的母带、你 App 的用户头像都躺在这种系统里。
- 读写比与对象分布:写一次读多次(WORM-ish),但对象大小是极端长尾——99% 是小文件(<1MB),99% 的字节在大文件里。两端要分别优化。
- 一致性:PUT 后立刻 GET 必须读到(read-after-write);overwrite 与 list 的一致性可放宽。
- 持久性 ≫ 可用性:数据丢了不可逆,停一会儿可恢复。设计上持久性是第一公民,11 个 9;可用性 4 个 9 即可。
- 吞吐:单对象 5 TB,10 Gbps 也要 1 小时——必须并行分片 + 断点续传,不能一个 TCP 流扛到底。
- 成本:100 PB 用 3 副本是 300 PB 裸盘,纠删码能压到 ~130 PB。每 PB 每月的电费机架费决定生死。
高层架构
graph TD
C["客户端
SDK / presigned URL 直传"]
LB["API 层
S3 协议 / Auth / 限流"]
subgraph CP["控制面 Control Plane"]
META["元数据服务
key → chunk 位置, 分片 KV"]
end
subgraph DP["数据面 Data Plane"]
PL["Placement / EC 编码器"]
S1["Storage Node 1"]
S2["Storage Node 2"]
S3["Storage Node N
裸盘 + CRC"]
end
BG["后台任务
scrub 校验 · 重建 · GC · 分层"]
C --> LB
LB --> META
LB --> PL
PL --> S1 & S2 & S3
META -.索引.-> DP
BG -.巡检/修复.-> DP
核心是控制面/数据面分离:元数据服务存「对象 key → 数据分片落在哪些盘」的索引(小、强一致、需要扩展到千亿条),数据面只管存不可变的字节块(大、追求吞吐和成本)。两者独立扩展是对象存储区别于文件系统的根本设计——没有目录树、没有 inode 链,扁平 keyspace 让水平扩展几乎无上限。后台任务(巡检、重建、GC)是持久性的真正来源,跟请求路径解耦。
关键技术点
1. 三种存储模型 — 为什么对象存储能无限扩
一句话 trade-off:对象存储牺牲了「原地修改」和「目录语义」,换来扁平 keyspace 的无限水平扩展和极低单位成本。
原理:Block(块,如 EBS/裸盘)给你定长扇区,最灵活但要自己管文件系统;File(文件,如 NFS/EFS)给你 POSIX 目录树和原地改写,但目录树是棵需要一致维护的树,元数据成为扩展瓶颈。Object(对象,如 S3)把整个文件当成不可变 blob,key 是一个扁平字符串(`a/b/c.jpg` 里的 `/` 只是字符不是目录),没有 rename、没有 append、没有 inode 链。这让元数据退化成一张巨大的 KV 表,可以按 key hash 任意分片。
| 维度 | Block 块 | File 文件 | Object 对象 |
| 访问单元 | 扇区/LBA | 路径 + offset | 整个对象 (key) |
| 修改 | 原地随机写 | 原地随机写 | 整对象覆盖(不可变) |
| 元数据 | 无(裸盘) | 目录树/inode | 扁平 KV |
| 扩展上限 | 单卷有限 | 受目录树瓶颈 | 近乎无限 |
| 典型 | EBS, 本地盘 | EFS, NFS, HDFS | S3, GCS, R2 |
真实案例:AWS S3 已存超过 100 万亿对象(2021 Pi Day 数据),靠的就是扁平 keyspace + 元数据分片;而需要 POSIX 语义的场景(如训练时直接 mmap)才上 FSx/Lustre。Dropbox 的 Magic Pocket 同样把文件切成不可变 block,用 KV(key→位置)做索引而非目录树。
2. 持久性引擎 — 复制 vs 纠删码
一句话 trade-off:3 副本简单、重建快、但 200% 空间放大;纠删码省到 ~30-50% 放大,代价是 CPU 编码、修复时的读放大、不适合小文件。
原理:要 11 个 9,单盘年故障率约 2-4% 远远不够。三副本(3-replication)跨 3 个 AZ 各放一份,任意 2 份挂了仍可读,简单粗暴。纠删码(Erasure Coding / Reed-Solomon)把对象切成 k 个数据块,算出 m 个校验块,共 k+m 份分散到不同故障域,任意 k 份就能重建。Backblaze Vault 用 17+3:17 数据 + 3 校验 = 20 份,丢任意 3 份不丢数据,存储放大只有 20/17≈1.18x,却也算到 11 个 9。
为什么不全用 EC?① 小文件不划算:切 17 份后每份太小,元数据和 IOPS 开销吃掉收益,所以热/小对象常用副本、冷/大对象用 EC。② 修复读放大:重建一个块要读 k 个其他块(17 次读 vs 副本的 1 次拷贝),故障高峰会放大网络。③ CPU:编解码耗算力,写延迟更高。
# Reed-Solomon 思想(伪代码,非可运行)
# 数据块当作多项式系数,校验块 = 在额外点上求值
data = split(obj, k=17) # 17 个数据分片
parity = rs_encode(data, m=3) # 范德蒙/柯西矩阵乘出 3 个校验
shards = data + parity # 20 份散到 20 个故障域
# 任意丢失 ≤3 份后重建:解 17 元线性方程组
def repair(surviving_shards): # 只需任意 17 份
return rs_decode(surviving_shards) # 矩阵求逆 × 幸存块
真实案例:Backblaze 开源了自研 Reed-Solomon 库,Vault 用 17+3 跨 20 个 pod。AWS S3 承诺 11 个 9,跨 ≥3 个 AZ 冗余并对所有对象持续做 CRC 巡检。Facebook f4 用 EC 存温数据(warm BLOBs),把老照片的存储放大从 3.6x(含跨机房副本)压到 ~2.1x。Dropbox Magic Pocket 内部也用 EC 把跨区冗余成本压下来。
3. 大对象上传 — Multipart + 客户端直传
一句话 trade-off:分片并行上传 + presigned URL 客户端直传,换来高吞吐与断点续传,代价是客户端复杂度和「孤儿分片」要 GC。
原理:5 TB 走单条 TCP 流,任何抖动都从头再来,且单流吞吐受 RTT × 窗口限制。Multipart upload 把对象切成 part(S3 规定 5MB–5GB/part,上限 1 万 part),每个 part 独立上传、可并行、可重传、带各自 ETag,最后一个 CompleteMultipartUpload 把 part 列表拼成对象。配合 presigned URL:服务端用密钥签一个有时效的 URL,客户端拿着它直接 PUT 到存储层,字节不经过你的应用服务器——省带宽、省一跳延迟。
# 服务端只发签名,不碰字节流
def init_upload(key, size):
upload_id = meta.create_multipart(key)
n = ceil(size / PART_SIZE) # 比如 64MB/part
urls = [presign("PUT", key, upload_id, i) # 每片一个签名 URL
for i in range(n)]
return upload_id, urls
# 客户端:并行 PUT 各 part,失败仅重传该 part,记录 {part_i: etag}
# 完成:POST complete(upload_id, [(i, etag)...]) → 元数据原子提交
陷阱:客户端上传一半跑路,part 会变成孤儿数据占着盘还在计费。S3 的解法是 lifecycle rule: abort incomplete multipart upload after N days 自动清理——一定要配,否则成本悄悄漏。
真实案例:AWS S3 的 multipart upload 与 presigned URL 是 SDK 默认大文件路径(>100MB 自动分片并发)。Netflix 上传母带、Dropbox 同步大文件、几乎所有「浏览器直传 S3」的 SaaS 都靠 presigned URL 把流量从自己服务器上卸掉。
4. 元数据强一致与热分区
一句话 trade-off:强一致让 read-after-write 变简单,但元数据要分片才能扛千亿 key,而分片又会在顺序 key 上制造热点。
原理:元数据是「key → 分片位置 + 版本 + ACL」的巨表,必须分片(按 key 的 hash)。2020 年前 S3 是最终一致(新对象 list 可能读不到),2020 年底 S3 上线强 read-after-write 一致性——PUT 返回成功后任何 GET/LIST 立即可见,且不加价不降性能。代价是元数据层要做到分片内强一致(通常是 Paxos/Raft 复制的 KV)。但按 key 前缀顺序分片会撞热点:若所有 key 是 `2026-06-20/...` 这种时间前缀,写入永远砸在同一分片。
面试高频:老 S3 性能指南建议给 key 加随机前缀(hash 散列)打散热点;2018 年后 S3 自动按前缀做自适应分区,每个前缀可达 3500 PUT / 5500 GET 每秒,但设计 key schema 时仍要避免单调递增前缀。这是「数据库分片热点」(Day 4)在存储元数据上的同一个坑。
真实案例:Google Colossus(GFS 继任者)把元数据从单 master 改成 BigTable 支撑的可分片元数据层,解决了 GFS single-master 的扩展天花板。S3 的强一致改造也是把元数据子系统重做成强一致 KV。
扩展与优化
- 分层存储(tiering):热数据 SSD/副本,温数据 HDD/EC,冷数据 SMR/磁带(S3 Glacier)。按访问频率自动下沉,成本可降一个数量级。Dropbox 专门为冷存储优化 Magic Pocket、上 SMR 盘。
- 版本控制 + 软删除:对象不可变天然适合多版本;删除只标记 tombstone,配合 GC 真正回收。这也是备份与防勒索的基础(versioning + MFA delete + 跨区复制)。
- 持续巡检(scrubbing):后台不断重算 CRC 比对,提前发现 bit rot 和坏盘,在丢到「不可重建」之前修复。这是 11 个 9 的真正保障,比请求路径还重要。
- 跨区复制(CRR):异步把对象复制到另一 region,用于 DR 和数据驻留合规。注意它是最终一致,RPO 不为 0。
常见陷阱 + 面试问题
- 把对象存储当文件系统用:以为 `ls` 一个「目录」很便宜——其实是 LIST 前缀扫描,百万 key 下很慢且贵。没有真正的 rename(是 copy+delete)。
- 用副本扛冷数据:冷数据 3 副本是烧钱,该上 EC;反过来给小文件上 EC 是自找麻烦。
- 忘了孤儿 multipart:不配 lifecycle abort,成本月月漏。
- 面试追问:① 11 个 9 到底怎么算出来的?(故障域独立性 + 修复速度 vs 故障率的概率模型)② 一个 5TB 对象怎么传?怎么断点续传?③ 强一致和最终一致在对象存储里各放弃了什么?④ 热点 key 怎么办?和数据库分片的热点是不是同一个问题?⑤ 删除一个对象后空间什么时候真的释放?(GC + tombstone)
深入资源
- 《Designing Data-Intensive Applications》(Kleppmann)— 复制、分区、一致性的底层原理,对象存储的元数据层正是这些概念的应用。
- Werner Vogels — "Building and operating a pretty big storage system called S3"(allthingsdistributed.com, 2023)— S3 持久性、巡检、规模的第一手视角。
- Dropbox 工程博客 — "Inside the Magic Pocket"(dropbox.tech)— EB 级自建对象存储的 EC、放置、迁移实战。
- Backblaze — "Reed-Solomon Erasure Coding" / "Vault Architecture"(backblaze.com/blog)— 17+3 纠删码与开源实现,把持久性数学讲透。
深入思考
11 个 9 的持久性到底是怎么"算"出来的?它最依赖哪个假设?
持久性 ≈ 把「在修复完成前,同一对象的冗余份额全部丢失」的概率压到极低。给定单盘年故障率(AFR)和修复时间(MTTR),可建马尔可夫/二项模型:k+m 编码下,必须在 MTTR 窗口内同时坏掉 ≥ m+1 个独立故障域才丢数据。所以两个杠杆是故障域独立性和修复速度——修得越快、故障域越独立,丢数据窗口越小。最脆弱的假设是「故障相互独立」:同批次坏盘、同机架断电、同 region 灾难是相关故障,会让漂亮的概率模型瞬间失效。这正是要跨 AZ / 跨故障域放置、并做持续巡检(缩短 MTTR)的原因。
为什么对象存储几乎都"不可变"(覆盖而非原地改)?这个限制反而带来了什么好处?
不可变让一切变简单且可并发:① 缓存/CDN 可以无脑长缓存,因为同一 key 内容不变(变就换 version id);② 复制和 EC 不用处理「改一半」的中间态,写要么全成要么不存在;③ 没有原地写就没有读写锁竞争,多副本天然一致;④ 天生支持版本控制和快照。代价是「改一个字节也要重写整个对象」,所以对象存储不适合频繁小改的 workload(那是 block/file 或数据库的活)。这是用功能受限换取扩展性、一致性和成本的经典 trade-off。
S3 在 2020 年从最终一致升级到强 read-after-write 一致,且"不加价不降性能"——这在工程上意味着重做了什么?
最终一致往往源于「元数据走缓存/异步索引」。要做到强一致,PUT 成功的那一刻,元数据的权威记录就必须已落到可被后续读命中的位置——意味着元数据子系统本身要是强一致复制的分片 KV(Paxos/Raft 类),并且读路径直接命中权威分片而非可能滞后的缓存。"不降性能"说明他们没有用「读时加锁/读多数派」的笨办法,而是把一致性下沉到分区内的复制协议,让常规读仍是单跳。本质上是把一致性成本从「每次请求」转移到「元数据架构」一次性付清。
给 100 PB 冷数据选 3 副本还是 17+3 EC?算一下成本差,再说什么时候反而该用副本。
3 副本:100 PB 逻辑 → 300 PB 裸盘。17+3 EC:放大 20/17≈1.18,→ 约 118 PB 裸盘。差 ~180 PB,按冷存储每 PB 每月数千美元算,一年省的是千万美元量级,冷数据用 EC 几乎没有悬念。什么时候仍选副本?① 小文件多:切 17 份后每份只有几 KB,元数据条目暴涨、随机读 IOPS 被打爆,EC 的空间收益被运维成本吃掉。② 读延迟敏感且常读:副本可就近读单份,EC 正常读虽只读数据片不需解码、但任一片缺失就要拉 k 片重建,尾延迟更差。所以现实是分层:热/小 → 副本,冷/大 → EC。
客户端用 presigned URL 直传到存储层,绕过了你的应用服务器——你因此失去了什么控制能力?怎么补?
失去的是请求路径上的拦截点:没法在写入瞬间做病毒扫描、内容审核、配额强校验、改写/转码、精细计费。补法分两类:① 签名时前置约束:presigned URL 里嵌入条件(content-length-range 限大小、content-type 限类型、key 前缀限路径),签名本身就是一次授权决策。② 事件驱动后置处理:上传完成触发事件(S3 Event → Lambda/队列),异步做扫描、转码、建索引、对账,发现违规再隔离/删除。本质是把「同步守门」改成「授权 + 异步治理」,这也是 serverless 媒体处理管线的标准形态。