10.8 对象存储系统设计
一句话
对象存储把 “元数据” 与 “数据” 彻底分开:元数据进分布式索引,数据切成块进大容量存储,再用 桶 + 扁平 key 做路由——换来近乎无限的扩展,代价是 没有原地修改、没有目录语义、list 很贵。
三层架构
| 层 | 职责 | 关键设计 |
|---|---|---|
| 接入层 | S3/HTTP API、鉴权、限流、分片上传 | 无状态,水平扩展 |
| 元数据层 | 桶、对象名、大小、ETag、版本、权限 | 按 bucket + hash(key) 分片,KV/索引存储 |
| 数据层 | 实际字节(chunk 4–64MB) | 纠删码或多副本,按 chunk 分散 |
| 后台 | GC、生命周期、副本修复、跨区复制 | 异步,不能阻塞前台 PUT |
一次 PUT 的路径
1. 客户端 → 接入层:PUT /bucket/key(带 Content-MD5)
2. 接入层校验权限与配额,算 key hash → 定位元数据分片
3. 大对象先 multipart:每片单独上传、校验 ETag
4. 数据写 chunk(EC 编码或 3 副本),落盘后返回成功
5. 元数据最后提交(决定 “对象是否可见”)
顺序很关键:先写数据、后提交元数据。反过来的话,元数据可见而数据未落盘 = 读到空洞。
一致性:读写语义要写进契约
| 操作 | 常见保证 | 说明 |
|---|---|---|
| 新建后读 | 强一致(read-after-write) | 元数据提交即可见 |
| 覆盖写后读 | 强一致(现代实现) | 新版本提交后旧版本不再可见 |
| 删除后读 | 强一致 | 返回 404 |
| list | 可能最终一致 | 分页遍历期间的新增可能不出现 |
| 跨区域复制 | 最终一致(秒–分钟) | RPO 由复制延迟决定 |
S3 在 2020 年底把 “覆盖写/删除” 也做到了强一致;自建系统若用异步元数据,务必在文档里写清 “哪个操作不是强一致”。
纠删码 vs 三副本
| 方案 | 空间开销 | 修复代价 | 适合 |
|---|---|---|---|
| 3 副本 | 3× | 低(拷一份) | 热数据、低延迟 |
| EC 4+2 | 1.5× | 中(读 4 块重建) | 温/冷数据、大对象 |
| EC 8+3 | 1.375× | 高(CPU 与网络) | 归档 |
EC 省空间但放大读放大与重建时间:修一块要读 k 块。所以常见组合是 “热数据三副本、冷数据 EC”。
小文件与 list:两个绕不开的坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 大量小对象 | 元数据 QPS 打满、list 超时 | 打包成大对象(tar/parquet)+ 索引表 |
| 按前缀 list 扫桶 | 一次 list 几百万条,慢且贵 | 用外部索引(数据库/清单文件) |
| 覆盖写频繁 | 每改一次重传整个对象 | 拆成多对象或改用数据库 |
落到数字:容量与请求的换算
| 项 | 量级 | 说明 |
|---|---|---|
| 单对象上限 | 5TB(S3 语义,常规实现) | 超过必须 multipart |
| multipart 分片 | 5MB–5GB/片,最多 1 万片 | 分片大小决定重传成本 |
| EC 4+2 空间开销 | 1.5× | 对比 3 副本的 3× |
| 重建 1 个 64MB 块 | 读 4 块 + 编码 CPU | 大集群重建风暴的根源 |
| list 单页 | 1000 个 key(常见) | 遍历 100 万对象 = 1000 次请求 |
aws s3api head-object --bucket b --key k --query '{len:ContentLength,etag:ETag}' # 校验大小与 ETag
curl -sI https://cdn.example.com/k.jpg | grep -iE "etag|age|x-cache" # 边缘是否命中
反模式
| 反模式 | 后果 |
|---|---|
| 把对象存储当文件系统(目录/rename) | 语义缺失,只能模拟,性能极差 |
| 高频覆盖同一对象 | 每次全量重传 + 版本堆积 |
| 用 list 做业务查询 | 分页 + 最终一致,结果不可靠 |
| 只用 3 副本存归档 | 存储成本 2–3 倍 |
| 元数据与数据同库 | 扩展性被单库锁死 |
| 用对象存储做“事务型更新” | 无多对象事务,跨对象一致性靠自己保证 |
三条铁律
🔑 数据先落盘、元数据后提交;顺序反了就会读到 “可见但不存在” 的对象。
🔑 对象不可变:要改就写新版本,旧版本交给生命周期清理。
🔑 list 不是查询;业务检索要建自己的索引。
自检
Q1 为什么对象存储的 PUT 要 “先写数据再提交元数据”?
▸ 答案:因为元数据提交决定 “对象可见”。若先提交元数据再写数据,中间窗口里客户端能 GET 到这个对象,但数据还没落盘 → 读到空洞或报错。反过来先写数据、后提交元数据,最坏情况是 “数据写了但对象不可见”(由 GC 清理),不会产生错误读。
Q2 一个桶里 5 亿个小文件,最先崩的是哪一层?
▸ 答案:元数据层(索引 QPS 与前缀 list)。对象存储的容量层可以无限加盘,但元数据的分片、索引与 list 能力是瓶颈。解法是合并小对象(打包)并把 “按属性查询” 移到外部索引,而不是继续加存储节点。
Q3 EC 4+2 相比 3 副本省多少空间?代价是什么?
▸ 答案:空间从 3× 降到 1.5×(省 50%),代价是:① 读一个对象要读 4 块(读放大);② 重建一块要读 4 块并算编码(CPU + 网络);③ 小对象场景下 EC 的元数据开销占比更高。所以 EC 常用于大对象与冷数据,热数据仍用副本。
Q4 为什么 “覆盖写” 在对象存储里代价高?
▸ 答案:因为对象不可变:覆盖 = 写一个全新对象 + 提交新版本 + 旧版本等待 GC。频繁覆盖会产生大量写放大与版本垃圾。正确做法是 “追加小对象” 或 “把可变部分放到数据库/KV”,对象只存不可变的大块。
Q5 跨区域复制的 RPO 由什么决定?
▸ 答案:由复制方式与延迟决定:异步复制(常见)的 RPO ≈ 复制延迟(秒到分钟);同步复制可以做到 RPO=0,但写入延迟增加一个跨区 RTT。选择依据是业务能容忍丢多少数据(见 36.1 RTO / RPO)。
Q6 为什么 “重建 1 个块要读 4 块” 会在大集群里变成事故?
▸ 答案:因为重建是读放大:1 块坏 → 读 4 块 → 占用 4 倍网络与磁盘带宽。集群越大、单盘越大,同时坏盘的概率越高,重建流量会与前台流量抢带宽,导致 “修数据把业务拖慢”。对策:限速重建、优先重建热数据、监控重建队列。