Day 29 Hard Object Storage Erasure Coding Multipart Upload Durability

文件存储 — 用 1.5x 空间换 11 个 9 的持久性File & Object Storage: S3 Architecture, Erasure Coding, Multipart Upload, Durability

问题场景 + 需求约束

设计一个类 S3 的对象存储:服务 千亿级对象、100 PB 容量,单对象 1 字节到 5 TB,11 个 9 持久性(99.999999999%,即每年 1000 万个对象里坏不到 1 个)。这是网盘、备份、数据湖、图片/视频源站的地基——Dropbox、Netflix 的母带、你 App 的用户头像都躺在这种系统里。

高层架构

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, HDFSS3, 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。

扩展与优化

常见陷阱 + 面试问题

深入资源

深入思考

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 媒体处理管线的标准形态。