Skip to content

设计一个对象存储系统

提出问题

对象存储已成为现代云基础设施的基石——AWS S3 日均处理超过 100 万亿个对象,国内阿里云 OSS、腾讯云 COS 也早已是企业标配。面试问"设计对象存储系统",本质是考察候选人对分布式存储的核心理解:如何在海量规模下,用廉价硬件实现高可用、高持久性、低延迟的存储?

一个真实的对比场景:你的 Java 后端服务每天产生 10TB 日志,之前用 NFS 挂载磁盘存储,结果 NFS 单点故障、inode 耗尽、ls 命令卡死。换成对象存储后,日志直接 PUT 到桶里,无需关心目录深度和磁盘容量。这就是对象存储的典型替代场景。

面试官真正想听的是:元数据与数据分离架构、分片策略、容错机制、一致性模型这几个关键决策。

分析问题

核心数据模型:桶 + 对象 + 扁平命名空间

对象存储的抽象模型极其简洁:

  • 桶(Bucket):命名空间容器,全局唯一。每个桶可存储无限对象。
  • 对象(Object):由 Key(键)、Data(二进制数据)、Metadata(元数据)三部分组成。
  • 扁平命名空间:不同于文件系统的树形目录,对象存储用 Key 的字符串前缀模拟目录(如 photos/2024/01/photo.jpg),底层仍是纯字符串映射。这避免了目录递归遍历的性能瓶颈——POSIX 的 rm -rf 递归删除 100 万个文件需要数分钟,对象存储的批量删除是 O(1) 级别。

S3 的 API 设计就是这个模型的最佳实践:PUT /{bucket}/{key}GET /{bucket}/{key}ListObjects(支持前缀和分隔符 / 模拟目录)。

bash
# 上传对象
curl -X PUT "http://object-store.example.com/my-bucket/logs/2024/01/app.log" \
  -H "Content-Type: text/plain" \
  --data-binary "@app.log"

# 列出对象(按前缀),模拟目录结构
curl "http://object-store.example.com/my-bucket?prefix=logs/2024/&delimiter=/"
# 返回: logs/2024/01/, logs/2024/02/, ...

元数据服务与数据分离

对象存储最核心的架构决策是元数据与数据分离。写入时,对象数据写到底层存储节点(Data Node),元数据(Key → 数据位置映射)写入元数据集群(Metadata Service)。

为什么分离?

  • 元数据量小:一个对象 1KB 的元数据 + 1TB 的数据,分开管理互不干扰。元数据集群可以用少量内存节点承载,数据节点按容量线性扩展。
  • 独立扩展:元数据集群可以用少量内存节点承载(如 TiDB 或 Raft 副本组),数据节点可以按容量线性扩展。
  • 灵活放置:元数据指向数据块的位置,可以根据策略(冷热分层、地域亲和性)自由选择存储节点。

写入流程时序图(文字版):

客户端                  Gateway               Metadata Service          Data Node(×3)
  │                       │                        │                       │
  │  PUT /bucket/key      │                        │                       │
  │──────────────────────►│                        │                       │
  │                       │  1. 分片 + 计算哈希      │                       │
  │                       │  2. 选 Data Node        │                       │
  │                       │────────────────────────►│                       │
  │                       │  3. 分配数据块位置       │                       │
  │                       │◄────────────────────────│                       │
  │                       │                        │                       │
  │                       │  4. 并行写入数据块       │                       │
  │                       │────────────────────────────────────────────────►│
  │                       │                        │                       │
  │                       │  5. 确认写入成功         │                       │
  │                       │◄────────────────────────────────────────────────│
  │                       │                        │                       │
  │                       │  6. 写入元数据映射       │                       │
  │                       │────────────────────────►│                       │
  │ 201 Created           │                        │                       │
  │◄──────────────────────│                        │                       │

读取流程:

客户端                  Gateway               Metadata Service          Data Node
  │                       │                        │                       │
  │  GET /bucket/key      │                        │                       │
  │──────────────────────►│                        │                       │
  │                       │  1. 查元数据             │                       │
  │                       │────────────────────────►│                       │
  │                       │  2. 返回数据块位置列表   │                       │
  │                       │◄────────────────────────│                       │
  │                       │                        │                       │
  │                       │  3. 并行读取数据块       │                       │
  │                       │────────────────────────────────────────────────►│
  │                       │  4. 返回数据块           │                       │
  │                       │◄────────────────────────────────────────────────│
  │                       │                        │                       │
  │  数据流 + 元数据       │                        │                       │
  │◄──────────────────────│                        │                       │

生产坑:元数据服务如果使用单 Raft 组,集群规模大了之后 Leader 会成为瓶颈(每秒几千 QPS 可以,百万 QPS 则扛不住)。S3 的元数据是分区存储的——DynamoDB 本身就是一个分布式的 KV 存储,按 Bucket+Key 哈希分区。自建方案如果预期超过 10 亿对象,建议用 TiDB 或 CockroachDB,别用单 Raft 组。

数据分片 vs 分片上传

两个容易混淆的概念:

概念发生位置目的分片粒度
数据分片(Sharding)服务端内部将大对象切块后分布存储到多节点,配合纠删码固定大小(如 4MB~64MB)
分片上传(Multipart Upload)客户端→服务端协议解决大文件网络传输失败需重传整个文件的问题客户端自定义(5MB~5GB)

分片上传的标准流程:

  1. 客户端发起 InitiateMultipartUpload,服务端返回一个 Upload ID。
  2. 客户端将文件切成多个分片,每个分片单独上传,获得 ETag。
  3. 客户端调用 CompleteMultipartUpload,服务端按分片顺序组装。
  4. 如果某分片失败,只需重传该分片。
python
# 模拟分片上传流程
def upload_large_file(bucket, key, file_path, part_size=5*1024*1024):
    # 1. 初始化
    upload_id = client.initiate_multipart_upload(Bucket=bucket, Key=key)
    parts = []
    
    # 2. 上传分片
    with open(file_path, 'rb') as f:
        part_number = 1
        while True:
            data = f.read(part_size)
            if not data:
                break
            etag = client.upload_part(
                Bucket=bucket, Key=key, 
                UploadId=upload_id, PartNumber=part_number,
                Body=data
            )
            parts.append({'PartNumber': part_number, 'ETag': etag})
            part_number += 1
    
    # 3. 完成
    client.complete_multipart_upload(
        Bucket=bucket, Key=key,
        UploadId=upload_id, MultipartUpload={'Parts': parts}
    )

生产实践:分片最小 5MB(S3 限制),但并发上传的分片数量要控制。实测经验:10 并发上传 100MB 分片时,单个 10Gbps 网卡能被撑满;100 并发则会触发 TCP 队头阻塞,反而更慢。推荐分片大小 16MB~64MB,并发数 4~8。上传完成后,服务端需要对分片做 MD5 校验,防止中间网络层面篡改或损坏。

纠删码 vs 副本:存储成本与持久性的权衡

对象存储的数据可靠性目标是 99.999999999%(11 个 9)。两个主流方案:

方案存储开销写入带宽恢复带宽典型配置持久性
三副本低(写 3 份)高(读 1 份即可)小规模、低延迟场景~99.9999%
纠删码 (EC)1.33~1.5×高(编码计算)低(需读 K 份重建)大规模、冷数据~99.999999999%

Reed-Solomon 纠删码原理简述:假设配置为 12+4,将原始数据切成 12 个数据块(D1~D12),通过范德蒙矩阵计算 4 个校验块(P1~P4)。任意丢失 4 个块,只要剩下至少 12 个块,就能通过高斯消元解出原始数据。数学上等价于解 12 元一次方程组。

生产坑

  • EC 恢复时要从 K 个节点读数据,如果网络带宽不够,恢复速度极慢。实测 12+4 配置下,恢复 1TB 数据在 10Gbps 网络上需要约 15 分钟,而三副本只需从 1 个节点读取即可恢复。
  • 热数据用 EC 会导致写入延迟增加——每写入一个对象都要做编码计算。Ceph 和 MinIO 的做法是:热数据写副本,后台异步转 EC

冷热分层策略:写入前 30 天用三副本(保证低延迟读写),30 天后自动转为 EC(12+4)。这个策略在阿里云 OSS 和 AWS S3 的智能分层(Intelligent-Tiering)中都有实现。

一致性模型

对象存储通常提供写后读最终一致性。做一个精确对比:

操作场景S3 行为自建方案
新对象 PUT 后立即 GETRead-after-Write 一致(S3 保证)需元数据写入确认后才返回
覆盖 PUT 后立即 GET最终一致性,可能读到旧版本需元数据版本号 + Quorum
删除后立即 GET最终一致性,可能仍读到需 Tombstone + 后台 GC
写入后立即 ListObjects最终一致性,新对象可能不出现二级索引更新延迟

面试追问:如果你要设计一个强一致的对象存储,怎么做?

回答思路:元数据层引入 Raft/Paxos 保证写入线性一致性,写入时确认所有副本都持久化再返回。代价是写入延迟从 5ms 飙升到 50ms+(取决于跨机房 RTT),且可用性下降(少数节点故障即不可写)。实际业务中 99% 的场景最终一致性够用。

数据校验与完整性

对象存储的另一个关键设计是端到端数据校验。数据可能在传输过程中损坏,也可能在磁盘上发生静默数据损坏(bit rot)。

python
# 端到端完整性校验
import hashlib

def put_object_with_checksum(bucket, key, data):
    # 客户端计算 MD5
    client_md5 = hashlib.md5(data).hexdigest()
    
    # 上传时携带 Content-MD5 头
    response = client.put_object(
        Bucket=bucket, Key=key,
        Body=data,
        ContentMD5=client_md5  # 服务端会校验
    )
    
    # 服务端返回 ETag(通常是 MD5)
    server_etag = response['ETag']
    
    # 客户端二次校验
    if client_md5 != server_etag.strip('"'):
        raise Exception("数据完整性校验失败,需重试")

生产实践:AWS S3 在 GET 时返回 ETagContent-MD5,但更可靠的做法是上传时计算 SHA256 并存储为自定义元数据,下载时重新计算比对。MinIO 的默认配置就是每 30 天全量扫描一次数据,用 SHA256 校验所有对象。

总结

设计对象存储的关键决策点可以用三个问题快速自检:

  1. 元数据怎么存? → 分布式 KV 或 NewSQL,元数据与数据分离是第一步。对象数超过 10 亿,别用单 Raft 组。
  2. 数据怎么放? → 分片 + 纠删码/副本,热数据用副本、冷数据转 EC 是业界标准做法。分片上传解决大文件网络传输问题。
  3. 一致性怎么保证? → 99% 场景最终一致性够用。强一致需要元数据层 Raft,代价是延迟翻 10 倍。

面试话术示例:"我参考 S3 的架构,采用元数据与数据分离设计。元数据层用 TiDB 保证 ACID 和扩展性,数据层用 12+4 纠删码降低存储成本。大文件通过分片上传(16MB 分片、8 并发)提升体验,一致性上采用 Read-after-Write 最终一致性,满足大多数业务场景。冷热数据自动分层,30 天前的数据自动转为 EC 存储,降低 50% 以上的存储成本。"

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。