土法炼钢兴趣小组的算法知识备份

【存储工程】对象存储模型:从文件到对象的范式转变

文章导航

分类入口
storage
标签入口
#object-storage#s3#eventual-consistency#cap-theorem#flat-namespace#metadata

目录

一个运行了十年的 NAS 集群,目录层级深达 15 层,ls 一次耗时 30 秒,元数据服务器的内存已经扛不住 50 亿个文件的 inode 表。一套分布式文件系统,跨三个数据中心同步目录树,rename 操作需要对父目录加锁,锁冲突导致吞吐量骤降。一个日志归档系统,每天写入 200TB 数据,文件系统的日志(journal)机制成了写入瓶颈,fsck 一次要跑十几个小时。

这些场景有一个共同的根源:传统文件系统的设计假设——层次化目录树、POSIX 语义、强一致性的元数据操作——在数据规模达到 PB 级别时变成了工程负担。对象存储(Object Storage)正是为了摆脱这些假设而诞生的存储范式。它不是文件系统的升级版,而是一种完全不同的设计取舍:放弃目录树,放弃就地修改,放弃 POSIX 语义,换来的是近乎无限的水平扩展能力和简单到只剩 HTTP 动词的访问接口。

本文从文件系统的扩展瓶颈出发,拆解对象存储的数据模型、一致性选择、内部架构和访问模式,分析 S3 从最终一致性(Eventual Consistency)到强一致性(Strong Consistency)的演进历程,最后对比对象存储、文件存储和块存储三种范式的选型依据。代码和命令基于 AWS S3 API(2006 年发布,至今仍是事实标准)、MinIO(兼容 S3 API 的开源实现)以及相关的公开设计文档。


一、为什么云时代选择了对象存储

文件系统的扩展瓶颈

传统文件系统(无论是本地的 ext4、XFS,还是分布式的 HDFS、CephFS)都基于一个核心抽象:层次化的目录树(Hierarchical Directory Tree)。这棵树在规模小的时候工作得很好,但随着数据量增长,它的三个根本性约束开始暴露。

约束一:元数据集中化

每个文件都有一个 inode,每个目录也有一个 inode,目录项(dentry)记录了名称到 inode 的映射。整棵目录树的遍历、查找、重命名都依赖这些元数据结构。在单机文件系统中,inode 表存储在磁盘的固定区域;在分布式文件系统中,元数据通常集中在一个或少数几个元数据服务器(Metadata Server, MDS)上。

当文件数量达到数十亿级别,元数据本身就成了瓶颈。HDFS 的 NameNode 把全部元数据存在 JVM 堆内存中,每个文件大约消耗 150 字节元数据,10 亿个文件就需要约 150GB 内存。CephFS 的 MDS 虽然支持动态子树分区(Dynamic Subtree Partitioning),但目录树的拓扑结构决定了分区粒度受限于子树的大小分布。

约束二:POSIX 语义的代价

POSIX 文件系统语义要求一系列强保证:

这些语义在单机上通过内核锁(VFS 层的 i_mutexd_lock)实现,延迟在微秒级。但在分布式环境中,每一条保证都意味着跨节点的协调开销。rename 需要分布式锁或两阶段提交(2PC),stat 需要从元数据主节点获取最新值,readdir 的一致性快照需要在目录上加读锁。这些协调操作的延迟从微秒跳到毫秒甚至更高,吞吐量也因此大幅下降。

约束三:目录树的局部性假设

文件系统的目录树暗含了一个假设:逻辑上相关的文件在物理上也应该靠近。很多文件系统的磁盘布局算法(例如 ext4 的 block group、XFS 的 allocation group)都利用了这个假设来优化磁盘寻道。但在云环境中,数据的组织逻辑是多维度的——按用户、按时间、按业务线、按地域——硬要把这些维度塞进一棵树,结果就是要么层级太深(影响路径解析性能),要么层级太浅(单目录文件数爆炸)。

对象存储的设计选择

对象存储的核心思路是:既然目录树是瓶颈的根源,那就彻底去掉目录树。具体的设计选择包括:

  1. 扁平命名空间(Flat Namespace):没有目录层级,所有对象通过唯一的键(Key)标识,键可以包含 / 字符,但 / 只是键的一部分,不代表目录结构。
  2. 不可变写入(Immutable Write):对象一旦写入,不支持就地修改(in-place update),只能整体覆盖(overwrite)。这消除了并发写同一文件的复杂性。
  3. HTTP 接口:用 RESTful API 代替 POSIX 系统调用,PUT/GET/DELETE/HEAD 四个动词覆盖所有核心操作。
  4. 元数据与数据分离:对象的自定义元数据(用户定义的键值对)和数据本体分开存储和索引,元数据可以独立扩展。
  5. 最终一致性(可选):放松一致性要求,允许在一定时间窗口内读到旧数据,换取更高的可用性和分区容忍度。

这些选择不是偶然的,每一条都直接对应文件系统的一个瓶颈:扁平命名空间消除了目录树的元数据热点;不可变写入消除了分布式锁;HTTP 接口消除了 POSIX 语义的协调开销;元数据分离让索引可以独立扩展;最终一致性把 CAP 定理的代价从可用性转嫁到了一致性。


二、文件系统 vs 对象存储的本质差异

命名空间:层次 vs 扁平

文件系统的命名空间是一棵有根树(Rooted Tree)。每个文件的定位需要从根目录出发,逐级解析每一层目录的 dentry,最终找到目标文件的 inode。路径 /data/logs/2024/01/access.log 的解析过程涉及 5 次目录查找。

对象存储的命名空间是一个扁平的键值映射(Key-Value Map)。对象的定位只需要一次哈希或索引查找。键 data/logs/2024/01/access.log 是一个完整的字符串,其中的 / 没有任何特殊含义。

文件系统的命名空间(层次化目录树)

          /(根目录)
          |
     ┌────┴────┐
     |         |
   data      config
     |
   logs
     |
   2024
     |
    01
     |
 access.log


对象存储的命名空间(扁平键值映射)

┌──────────────────────────────────────────┬──────────────┐
│ Key                                      │ Value        │
├──────────────────────────────────────────┼──────────────┤
│ data/logs/2024/01/access.log             │ <blob>       │
│ data/logs/2024/01/error.log              │ <blob>       │
│ data/logs/2024/02/access.log             │ <blob>       │
│ config/app.yaml                          │ <blob>       │
│ images/banner.png                        │ <blob>       │
└──────────────────────────────────────────┴──────────────┘

不过,对象存储通常提供”前缀列举”(List by Prefix)和”分隔符”(Delimiter)机制来模拟目录浏览的体验。例如,S3 的 ListObjectsV2 请求可以指定 prefix=data/logs/2024/delimiter=/,返回的结果中会包含”公共前缀”(Common Prefixes)data/logs/2024/01/data/logs/2024/02/,看起来像是两个子目录。但这只是 API 层面的模拟,底层不存在目录的概念。

访问语义:POSIX vs HTTP

POSIX 文件系统 API 提供的是一套面向字节流的、有状态的接口:

// POSIX 文件操作:有状态,基于文件描述符
int fd = open("/data/logs/access.log", O_RDWR);
lseek(fd, 1024, SEEK_SET);       // 移动读写位置到偏移 1024
read(fd, buf, 4096);             // 从当前位置读 4096 字节
write(fd, new_data, 256);        // 从当前位置写 256 字节(就地修改)
flock(fd, LOCK_EX);              // 加排他锁
close(fd);                       // 关闭文件描述符

对象存储 API 提供的是一套面向对象的、无状态的接口:

# S3 对象操作:无状态,基于 HTTP 动词
# 上传对象(整体写入,不是追加)
aws s3api put-object \
  --bucket my-bucket \
  --key "data/logs/access.log" \
  --body ./access.log

# 下载对象(整体读取,或指定 Range)
aws s3api get-object \
  --bucket my-bucket \
  --key "data/logs/access.log" \
  --range "bytes=1024-5119" \
  output.bin

# 删除对象
aws s3api delete-object \
  --bucket my-bucket \
  --key "data/logs/access.log"

两者的根本区别在于:

维度 POSIX 文件系统 对象存储
操作粒度 字节级别(可读写文件的任意偏移) 对象级别(整体读写,部分读取用 Range)
状态管理 有状态(文件描述符、读写位置) 无状态(每次请求独立)
并发控制 内核级锁(flock、fcntl) 无锁,最后写入胜出(Last-Writer-Wins)
修改方式 就地修改(in-place update) 整体覆盖(overwrite)或版本化
传输协议 系统调用(内核态接口) HTTP/HTTPS(应用态协议)
延迟量级 微秒(本地)/ 毫秒(分布式) 毫秒到百毫秒级(网络 + HTTP 开销)
小文件效率 高(系统调用开销小) 低(HTTP 请求开销大)
大文件效率 一般(受限于单节点带宽) 高(支持多分片并行上传)

元数据可扩展性

文件系统的元数据是固定结构的。一个 inode 包含的字段在文件系统格式设计时就确定了:文件大小、权限、时间戳、数据块指针。虽然可以通过扩展属性(Extended Attributes, xattr)附加自定义键值对,但 xattr 有严格的大小限制——ext4 限制单个 xattr 值不超过 4KB(受限于单个磁盘块大小),所有 xattr 总大小受 inode 尺寸限制。

对象存储的元数据分为两类:

# 上传对象时附加用户自定义元数据
aws s3api put-object \
  --bucket my-bucket \
  --key "images/photo-001.jpg" \
  --body ./photo.jpg \
  --content-type "image/jpeg" \
  --metadata '{"camera":"Canon-R5","location":"Beijing","shoot-date":"2024-06-15"}'

# 查看对象元数据(不下载数据)
aws s3api head-object \
  --bucket my-bucket \
  --key "images/photo-001.jpg"

head-object 的返回示例:

{
    "ContentType": "image/jpeg",
    "ContentLength": 8294400,
    "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
    "LastModified": "2024-06-15T10:30:00Z",
    "Metadata": {
        "camera": "Canon-R5",
        "location": "Beijing",
        "shoot-date": "2024-06-15"
    },
    "StorageClass": "STANDARD"
}

用户元数据虽然大小有限,但它的真正价值在于:可以在不读取对象数据的前提下,通过元数据实现分类、检索和策略管理。很多对象存储系统允许通过标签(Tagging)进一步扩展对象的描述信息,标签和元数据的区别是标签支持独立的读写权限控制和生命周期策略绑定。


三、对象存储的数据模型

核心概念

对象存储的数据模型由四个核心概念组成:桶(Bucket)、对象(Object)、版本(Version)和元数据(Metadata)。

对象存储数据模型

┌─────────────────────────────────────────────────────────────┐
│                        账户(Account)                       │
│                                                             │
│  ┌──────────────────────┐    ┌──────────────────────┐      │
│  │   Bucket: images     │    │   Bucket: logs       │      │
│  │                      │    │                      │      │
│  │  ┌────────────────┐  │    │  ┌────────────────┐  │      │
│  │  │ Object         │  │    │  │ Object         │  │      │
│  │  │ Key: a/b.jpg   │  │    │  │ Key: app.log   │  │      │
│  │  │ ┌────────────┐ │  │    │  │ ┌────────────┐ │  │      │
│  │  │ │ Version v3 │ │  │    │  │ │ Data Blob  │ │  │      │
│  │  │ │ Version v2 │ │  │    │  │ │            │ │  │      │
│  │  │ │ Version v1 │ │  │    │  │ └────────────┘ │  │      │
│  │  │ └────────────┘ │  │    │  │ Metadata:      │  │      │
│  │  │ Metadata:       │  │    │  │  Content-Type  │  │      │
│  │  │  Content-Type   │  │    │  │  x-amz-meta-*  │  │      │
│  │  │  x-amz-meta-*   │  │    │  │  Tags          │  │      │
│  │  │  Tags           │  │    │  └────────────────┘  │      │
│  │  └────────────────┘  │    │                      │      │
│  └──────────────────────┘    └──────────────────────┘      │
└─────────────────────────────────────────────────────────────┘

桶(Bucket)

桶是对象的容器,也是访问控制和计费的基本单位。每个桶有一个全局唯一的名称(在 AWS S3 中,桶名称在全球所有区域中唯一)。桶不支持嵌套——不能在一个桶里创建另一个桶。桶的主要属性包括:

对象(Object)

对象是对象存储中数据的基本单位。一个对象由三部分组成:

对象的寻址格式为 s3://bucket-name/object-key,通过 HTTP 访问时 URL 为 https://bucket-name.s3.region.amazonaws.com/object-key(虚拟主机风格)或 https://s3.region.amazonaws.com/bucket-name/object-key(路径风格,已逐步弃用)。

版本(Version)

当桶启用版本控制后,每次对同一个键执行 PUT 操作,不会覆盖旧数据,而是创建一个新版本。每个版本有一个系统生成的版本 ID(Version ID)。删除操作也不会真正删除数据,而是插入一个删除标记(Delete Marker)。这意味着:

# 启用桶的版本控制
aws s3api put-bucket-versioning \
  --bucket my-bucket \
  --versioning-configuration Status=Enabled

# 上传同一个键的多个版本
echo "version 1" | aws s3 cp - s3://my-bucket/doc.txt
echo "version 2" | aws s3 cp - s3://my-bucket/doc.txt
echo "version 3" | aws s3 cp - s3://my-bucket/doc.txt

# 列出所有版本
aws s3api list-object-versions \
  --bucket my-bucket \
  --prefix "doc.txt"

返回结果(简化):

{
    "Versions": [
        {
            "Key": "doc.txt",
            "VersionId": "v3-abc123",
            "IsLatest": true,
            "LastModified": "2024-06-15T12:00:00Z",
            "Size": 10
        },
        {
            "Key": "doc.txt",
            "VersionId": "v2-def456",
            "IsLatest": false,
            "LastModified": "2024-06-15T11:00:00Z",
            "Size": 10
        },
        {
            "Key": "doc.txt",
            "VersionId": "v1-ghi789",
            "IsLatest": false,
            "LastModified": "2024-06-15T10:00:00Z",
            "Size": 10
        }
    ]
}

分片上传

对于大文件,对象存储提供了分片上传(Multipart Upload)机制。工作流程分三步:

  1. 发起分片上传(CreateMultipartUpload):获取一个上传 ID(Upload ID)。
  2. 上传分片(UploadPart):将文件切分为多个分片,每个分片独立上传。各分片可以并行上传,失败的分片可以单独重试。S3 中每个分片大小范围为 5MB 到 5GB,最多支持 10000 个分片。
  3. 完成分片上传(CompleteMultipartUpload):提交所有分片的编号和 ETag 列表,系统将分片组装为一个完整对象。
import boto3

s3 = boto3.client('s3')
bucket = 'my-bucket'
key = 'large-dataset/data.parquet'
file_path = '/data/large_file.parquet'
part_size = 100 * 1024 * 1024  # 100MB 每个分片

# 第一步:发起分片上传
response = s3.create_multipart_upload(Bucket=bucket, Key=key)
upload_id = response['UploadId']

parts = []
part_number = 1

try:
    with open(file_path, 'rb') as f:
        while True:
            data = f.read(part_size)
            if not data:
                break
            # 第二步:上传每个分片
            part_response = s3.upload_part(
                Bucket=bucket,
                Key=key,
                PartNumber=part_number,
                UploadId=upload_id,
                Body=data
            )
            parts.append({
                'PartNumber': part_number,
                'ETag': part_response['ETag']
            })
            part_number += 1

    # 第三步:完成分片上传
    s3.complete_multipart_upload(
        Bucket=bucket,
        Key=key,
        UploadId=upload_id,
        MultipartUpload={'Parts': parts}
    )
except Exception as e:
    # 上传失败时中止,释放已上传的分片占用的存储
    s3.abort_multipart_upload(
        Bucket=bucket, Key=key, UploadId=upload_id
    )
    raise e

分片上传的设计直接解决了大文件上传的三个工程问题:网络中断后不需要从头重传(只重传失败的分片)、多线程并行上传提高吞吐量、避免单次 HTTP 请求超时。


四、对象存储的 CAP 权衡

CAP 定理回顾

CAP 定理(CAP Theorem)由 Eric Brewer 在 2000 年提出,2002 年由 Seth Gilbert 和 Nancy Lynch 形式化证明。它指出,在一个分布式数据存储系统中,以下三个属性最多只能同时满足两个:

在实际的分布式系统中,网络分区不是一个可以选择避免的事情——它一定会发生。因此真正的选择是在分区发生时,是优先保证一致性(牺牲可用性,即拒绝服务直到分区恢复)还是优先保证可用性(牺牲一致性,即允许返回旧数据)。

对象存储的一致性选择

早期的对象存储系统普遍选择了”AP”方向——优先保证可用性和分区容忍性,接受最终一致性。这个选择基于以下工程判断:

  1. 写入模式:对象存储的典型写入模式是”写一次、读多次”(Write-Once-Read-Many, WORM)。大多数对象写入后不会被修改,读写冲突的概率很低。
  2. 跨区域复制:云环境中数据通常需要跨区域(Region)复制以实现灾备。跨区域的网络延迟在几十到几百毫秒,如果要求同步复制(强一致性),写入延迟将不可接受。
  3. 规模要求:PB 级存储、万亿级对象的规模下,维护全局强一致性的协调开销(如 Paxos/Raft 共识协议)难以承受。

最终一致性的具体表现

在最终一致性模型下,以下行为是”合法”的:

时间线 ───────────────────────────────────────────────────>

客户端 A:   PUT key=X, value=V1        (写入成功,返回 200)
                    |
客户端 B:               GET key=X      (返回 404,尚未看到 V1)
                              |
客户端 B:                         GET key=X  (返回 V1,数据最终可见)

S3 在 2020 年 12 月之前的一致性模型具体为:

这种不一致的时间窗口通常在秒级到分钟级,但没有上界保证。在极端情况下(网络分区、副本同步延迟),不一致窗口可能更长。

最终一致性带来的工程问题

最终一致性不是一个抽象的理论问题,它在实际系统中造成过真实的故障。以下是几个典型场景:

场景一:先写后读不一致

一个数据处理流水线先将计算结果写入 S3,然后通知下游服务读取。下游服务收到通知后立即发起 GET 请求,但读到的是旧版本数据(覆盖 PUT 场景),导致处理结果错误。

场景二:列举遗漏

一个归档任务先往 S3 写入一批文件,然后调用 LIST 获取文件列表以验证完整性。由于 LIST 的最终一致性,某些刚写入的文件可能不出现在列表中,导致归档任务误判为写入失败并重新触发。

场景三:删后可见

一个合规系统在收到数据删除请求后调用 DELETE,然后通过 GET 验证删除结果。由于最终一致性,GET 可能仍然返回已删除对象的数据,导致合规系统的审计日志记录不一致。

这些问题的工程绕行方案包括:在键中嵌入版本号或时间戳避免覆盖写(始终写新键)、在写入后增加延迟等待、使用额外的元数据数据库(如 DynamoDB)来跟踪对象状态。但这些方案都增加了系统复杂度,而且”等多久才够”没有确定性的答案。


五、S3 的一致性升级

2020 年 12 月:从最终一致性到强一致性

2020 年 12 月,AWS 宣布 S3 对所有 GET、PUT、LIST 操作提供强读后写一致性(Strong Read-after-Write Consistency),覆盖所有现有和新创建的 S3 桶,无需任何配置更改,无额外费用,无性能影响。

这意味着:

实现原理

AWS 首席科学家 Werner Vogels 在公告中提到,这个一致性升级不是通过简单地把副本同步从异步改为同步来实现的(那样会严重影响延迟和可用性),而是通过重新设计 S3 的内部元数据子系统来实现的。

根据公开信息,S3 的内部架构大致包含以下层次:

  1. 前端层(Front End):处理 HTTP 请求,进行认证、授权和请求路由。
  2. 元数据层(Metadata Subsystem):维护对象的索引信息,包括键到存储位置的映射、版本信息等。
  3. 存储层(Storage Subsystem):实际存储对象数据的分布式存储系统。

一致性升级的核心变化在元数据层。根据 AWS 在 re:Invent 2021 的演讲(Andy Warfield, “Dive Deep on Amazon S3”),S3 引入了一种称为”目击者”(Witness)的机制:每次写操作在元数据层完成后,会在一个全局有序的日志中注册一个”见证令牌”(Witness Token)。后续的读操作在返回结果之前,会检查自己读到的元数据版本是否已经包含了所有已确认的写操作。如果没有,读操作会等待元数据同步到最新状态后再返回。

这个设计的关键在于:它不要求写操作等待所有副本同步完成(那样会增加写入延迟),而是在读操作侧增加了一个轻量级的检查点。写入延迟基本不变,读取延迟在元数据已经同步的常见情况下也基本不变,只有在读取发生在写入之后极短时间内的边界情况下,读取会有微小的额外等待。

一致性升级的影响

这次升级的工程意义在于:之前所有为了绕过最终一致性而设计的应用层 workaround 都可以移除了。具体包括:

值得注意的是,这次升级只覆盖了单区域(Single Region)内的一致性。跨区域复制(Cross-Region Replication, CRR)仍然是异步的,跨区域的读取不保证强一致性。这是一个合理的工程取舍:跨区域的网络延迟决定了同步复制的代价过高。


六、对象存储的内部架构

整体架构分层

一个典型的对象存储系统可以分为三个主要层次:元数据层、数据层和索引层。以下以 MinIO 和公开的 S3 架构信息为参考,描述各层的职责和设计要点。

对象存储内部架构

┌─────────────────────────────────────────────────────────────────┐
│                        客户端请求(HTTP)                        │
└───────────────────────────┬─────────────────────────────────────┘
                            |
                            v
┌─────────────────────────────────────────────────────────────────┐
│                       API 网关 / 负载均衡                        │
│         (请求路由、认证鉴权、限流、日志记录)                    │
└───────────────────────────┬─────────────────────────────────────┘
                            |
              ┌─────────────┼─────────────┐
              |             |             |
              v             v             v
┌──────────────────┐ ┌────────────┐ ┌──────────────────┐
│    元数据层       │ │  索引层    │ │     数据层        │
│ (Metadata)      │ │ (Index)  │ │   (Data)        │
│                  │ │            │ │                  │
│ - 桶信息          │ │ - 键前缀   │ │ - 对象数据分片    │
│ - 对象键值映射    │ │   索引     │ │ - 纠删码编码      │
│ - 版本记录        │ │ - 列举加速 │ │ - 副本管理        │
│ - ACL/策略       │ │ - 范围查询 │ │ - 存储层级管理    │
│ - 生命周期状态    │ │            │ │   (热/温/冷/归档)│
│                  │ │            │ │                  │
│ 实现:分布式      │ │ 实现:     │ │ 实现:分布式      │
│ KV 存储或关系     │ │ B-Tree /   │ │ 块存储 + 纠删码   │
│ 数据库           │ │ LSM-Tree   │ │ 或副本复制        │
└──────────────────┘ └────────────┘ └──────────────────┘

元数据层

元数据层负责管理所有关于”对象在哪里、是什么状态”的信息。核心数据结构是一个从 (bucket, key, version) 到存储位置和属性的映射。

元数据层的设计挑战在于:

常见的元数据存储方案包括:

数据层

数据层负责实际存储对象的二进制数据。核心的设计决策是数据冗余策略:副本复制(Replication)还是纠删码(Erasure Coding)。

副本复制:简单地将数据复制到多个节点。三副本(3-way Replication)是最常见的配置,存储开销为 3 倍。优点是实现简单、读取性能高(可以从任意副本读取),缺点是存储效率低。

纠删码(Erasure Coding, EC):将对象切分为 k 个数据分片(Data Shard),通过编码算法生成 m 个校验分片(Parity Shard),总共 k+m 个分片分布在不同节点上。只要任意 k 个分片可用,就能恢复原始数据。存储开销为 (k+m)/k 倍。

以 MinIO 默认的 EC:4+4 配置为例:

纠删码分片示意(EC 4+4)

原始对象数据
┌────────────────────────────────────────────────────┐
│                   Object Data                       │
└────────────────────────────────────────────────────┘
              |  切分为 4 个数据分片
              v
┌──────────┬──────────┬──────────┬──────────┐
│ Data 0   │ Data 1   │ Data 2   │ Data 3   │
└──────────┴──────────┴──────────┴──────────┘
              |  编码生成 4 个校验分片
              v
┌──────────┬──────────┬──────────┬──────────┐
│ Parity 0 │ Parity 1 │ Parity 2 │ Parity 3 │
└──────────┴──────────┴──────────┴──────────┘

分布到 8 个节点(或磁盘):
  Node 0: Data 0      Node 4: Parity 0
  Node 1: Data 1      Node 5: Parity 1
  Node 2: Data 2      Node 6: Parity 2
  Node 3: Data 3      Node 7: Parity 3

容忍任意 4 个节点故障,存储开销 = 8/4 = 2 倍
(对比三副本的 3 倍开销,EC 4+4 在相同容错能力下更节省空间)

纠删码的权衡在于:读取时如果有分片不可用,需要从其他分片做计算恢复,CPU 开销更高,延迟也可能更大。写入时需要计算校验分片,也有额外的 CPU 消耗。因此,纠删码更适合冷数据和大文件,副本复制更适合热数据和低延迟场景。

索引层

索引层为 LIST 操作(前缀列举)提供高效的查询支持。对象存储的 LIST 操作语义是:给定一个前缀和分隔符,返回该前缀下的所有对象键(按字典序排列),并将具有公共前缀的键折叠为”目录”。

实现这个语义需要一个支持范围查询(Range Query)和前缀匹配的有序索引。常见的实现方式包括:

S3 的 LIST 操作有一个重要的性能特性:单次请求最多返回 1000 个键(可通过 max-keys 参数设置),超过这个数量需要分页(使用 continuation-token)。这个限制是有意设计的——它防止单次 LIST 请求扫描过多的索引条目,避免长时间占用服务器资源。对于包含大量对象的桶,完整的 LIST 操作可能需要数千次分页请求,延迟从秒级到分钟级不等。

如果应用需要频繁查询对象列表,建议使用 S3 清单(S3 Inventory)功能,它以异步批处理的方式生成桶的完整对象列表,比逐页 LIST 高效得多。


七、对象存储的访问模式

RESTful API

对象存储的 API 设计遵循 REST 风格,资源(桶和对象)通过 URL 标识,操作通过 HTTP 动词表达。以 S3 API 为例,核心操作映射如下:

操作 HTTP 方法 URL 示例 说明
创建桶 PUT PUT /bucket-name 创建一个新桶
删除桶 DELETE DELETE /bucket-name 删除空桶
列举桶内对象 GET GET /bucket-name?list-type=2 列举对象,支持前缀和分隔符
上传对象 PUT PUT /bucket-name/key 上传对象数据和元数据
下载对象 GET GET /bucket-name/key 下载对象数据
获取元数据 HEAD HEAD /bucket-name/key 只返回元数据,不返回数据
删除对象 DELETE DELETE /bucket-name/key 删除对象(或创建删除标记)
批量删除 POST POST /bucket-name?delete 一次请求删除多个对象
复制对象 PUT PUT /dest-key(带 x-amz-copy-source 服务端复制,不经过客户端

每个请求和响应都使用标准的 HTTP 头部传递元信息:

PUT /my-bucket/reports/q3-2024.pdf HTTP/1.1
Host: s3.us-east-1.amazonaws.com
Content-Type: application/pdf
Content-Length: 2048576
x-amz-meta-department: finance
x-amz-meta-quarter: Q3-2024
x-amz-storage-class: STANDARD_IA
x-amz-date: 20240615T100000Z
Authorization: AWS4-HMAC-SHA256 Credential=AKIA.../20240615/us-east-1/s3/aws4_request, ...

<binary data>

签名认证(Signature V4)

S3 API 使用 AWS Signature Version 4(SigV4)进行请求认证。这是一种基于 HMAC-SHA256 的签名方案,不传输密钥本身,而是用密钥对请求内容进行签名。签名过程分四步:

SigV4 签名计算过程

第一步:构造规范请求(Canonical Request)
┌─────────────────────────────────────────────────┐
│ PUT                                             │
│ /my-bucket/reports/q3-2024.pdf                  │
│ (查询参数,按字典序排列)                       │
│ host:s3.us-east-1.amazonaws.com                 │
│ x-amz-date:20240615T100000Z                     │
│ x-amz-meta-department:finance                   │
│                                                 │
│ host;x-amz-date;x-amz-meta-department           │
│ <payload-hash>                                  │
└─────────────────────────────────────────────────┘
                    |
                    v  SHA256 哈希
第二步:构造待签名字符串(String to Sign)
┌─────────────────────────────────────────────────┐
│ AWS4-HMAC-SHA256                                │
│ 20240615T100000Z                                │
│ 20240615/us-east-1/s3/aws4_request              │
│ <canonical-request-hash>                        │
└─────────────────────────────────────────────────┘
                    |
                    v  HMAC-SHA256 链式计算
第三步:派生签名密钥(Signing Key)
┌─────────────────────────────────────────────────┐
│ DateKey    = HMAC-SHA256("AWS4"+SecretKey, Date) │
│ RegionKey  = HMAC-SHA256(DateKey, Region)        │
│ ServiceKey = HMAC-SHA256(RegionKey, Service)     │
│ SigningKey = HMAC-SHA256(ServiceKey,"aws4_request")│
└─────────────────────────────────────────────────┘
                    |
                    v  HMAC-SHA256
第四步:计算签名
┌─────────────────────────────────────────────────┐
│ Signature = HMAC-SHA256(SigningKey, StringToSign)│
└─────────────────────────────────────────────────┘

签名密钥的派生链使得每个签名只对特定日期、区域和服务有效,限制了凭证泄露时的影响范围。如果一个签名被截获,它只能在该日期、该区域、该服务下使用,不能扩展到其他区域或服务。

对于不需要长期凭证的场景(例如让前端用户直接上传文件到 S3),可以使用预签名 URL(Presigned URL):

import boto3

s3 = boto3.client('s3')

# 生成一个 1 小时有效的上传预签名 URL
presigned_url = s3.generate_presigned_url(
    'put_object',
    Params={
        'Bucket': 'my-bucket',
        'Key': 'uploads/user-avatar.jpg',
        'ContentType': 'image/jpeg'
    },
    ExpiresIn=3600
)

# 客户端可以直接用这个 URL 上传,不需要 AWS 凭证
# curl -X PUT -H "Content-Type: image/jpeg" --data-binary @avatar.jpg "$presigned_url"

预签名 URL 的安全机制是:URL 中包含签名和过期时间,服务端验证签名有效且未过期后才允许操作。URL 本身不包含密钥,即使被第三方截获,过期后也无法使用。


八、对象存储的典型应用场景

备份与灾备

对象存储是数据备份的天然选择。原因有三:存储成本低(S3 Glacier Deep Archive 的存储价格约为标准存储的 1/20)、持久性高(S3 标准存储的设计持久性为 99.999999999%,即 11 个 9)、支持跨区域复制(CRR)实现异地灾备。

典型的备份架构是:应用服务器通过备份代理将数据库快照、文件系统快照上传到对象存储的冷存储层(如 S3 Glacier),配合生命周期规则自动将超过 90 天的备份转移到更低成本的归档层(如 S3 Glacier Deep Archive)。

# 配置生命周期规则:30 天后转为低频访问,90 天后转为 Glacier,365 天后转为 Deep Archive
aws s3api put-bucket-lifecycle-configuration \
  --bucket backup-bucket \
  --lifecycle-configuration '{
    "Rules": [
      {
        "ID": "backup-lifecycle",
        "Status": "Enabled",
        "Filter": {"Prefix": "db-snapshots/"},
        "Transitions": [
          {"Days": 30, "StorageClass": "STANDARD_IA"},
          {"Days": 90, "StorageClass": "GLACIER"},
          {"Days": 365, "StorageClass": "DEEP_ARCHIVE"}
        ],
        "Expiration": {"Days": 2555}
      }
    ]
  }'

数据湖

数据湖(Data Lake)的核心理念是:将原始数据以原始格式存储在一个集中的存储层,按需进行转换和分析。对象存储是数据湖的标准底座,原因包括:

一个典型的数据湖分层结构:

数据湖分层结构

s3://data-lake/
├── raw/                          # 原始数据层(Bronze)
│   ├── clickstream/2024/06/15/   # 按日期分区的点击流数据
│   ├── transactions/2024/06/15/  # 交易数据
│   └── logs/nginx/2024/06/15/    # 日志数据
│
├── cleaned/                      # 清洗后数据层(Silver)
│   ├── clickstream/              # Parquet 格式,已去重、标准化
│   └── transactions/             # 已验证、已关联
│
└── curated/                      # 加工后数据层(Gold)
    ├── user_profiles/            # 用户画像宽表
    ├── daily_metrics/            # 每日指标聚合
    └── ml_features/              # 机器学习特征表

静态资源托管

对象存储天然适合 Web 静态资源的托管。S3 支持将桶配置为静态网站(Static Website Hosting),配合 CloudFront 等 CDN 服务,可以低成本地服务全球用户。

典型场景包括:前端应用的 HTML/CSS/JS 文件、用户上传的图片和视频、软件发布的二进制包、API 文档站点。

日志归档

日志系统每天产生大量数据,热数据需要实时查询(通常存在 Elasticsearch 或 ClickHouse 中),冷数据需要长期保留以满足合规要求。对象存储是日志冷归档的标准方案:

# 使用 S3 Select 在压缩的 CSV 日志中查询特定 IP 的访问记录
aws s3api select-object-content \
  --bucket log-archive \
  --key "nginx/2024/06/15/access.csv.gz" \
  --expression "SELECT * FROM s3object s WHERE s.client_ip = '192.168.1.100'" \
  --expression-type SQL \
  --input-serialization '{"CSV": {"FileHeaderInfo": "USE"}, "CompressionType": "GZIP"}' \
  --output-serialization '{"CSV": {}}' \
  output.csv

S3 Select 的价值在于:它在服务端执行过滤,只将匹配的行返回给客户端,避免了下载整个压缩文件后再在本地过滤的带宽浪费。对于 TB 级别的日志归档,这种”下推”(Pushdown)查询可以节省数个数量级的数据传输量和查询时间。

存储类别与成本优化

对象存储通常提供多种存储类别(Storage Class),在访问速度、可用性和成本之间提供不同的权衡。以 S3 为例:

存储类别 典型场景 首字节延迟 最低存储时长 相对成本(存储)
Standard 热数据,频繁访问 毫秒级 1x(基准)
Standard-IA 低频访问但需要快速获取 毫秒级 30 天 约 0.5x
One Zone-IA 低频访问,可接受单可用区 毫秒级 30 天 约 0.4x
Glacier Instant Retrieval 归档数据,偶尔需要毫秒级获取 毫秒级 90 天 约 0.2x
Glacier Flexible Retrieval 归档数据,可等几分钟到几小时 分钟到小时 90 天 约 0.1x
Glacier Deep Archive 长期归档,极少访问 12 到 48 小时 180 天 约 0.05x

选择存储类别的关键指标是访问频率获取延迟要求。一个常见的错误是把所有数据都存在 Standard 类别——对于归档日志和旧备份,使用 Glacier 可以将存储成本降低 90% 以上。但要注意:低成本存储类别的读取请求费用更高,频繁读取反而更贵。正确做法是通过生命周期规则自动转换,而不是手动管理。


九、对象存储 vs 文件存储 vs 块存储选型

三种存储范式对比

维度 块存储(Block Storage) 文件存储(File Storage) 对象存储(Object Storage)
数据单元 固定大小的块(通常 512B 或 4KB) 文件(可变大小,有层级路径) 对象(可变大小,有扁平键和元数据)
访问接口 SCSI/NVMe/iSCSI NFS/SMB/POSIX HTTP/REST(S3 API)
命名空间 无(块地址) 层次化目录树 扁平键值空间
访问粒度 字节级别(任意偏移读写) 字节级别(通过文件描述符) 对象级别(整体读写或 Range 读取)
修改方式 就地修改(覆盖任意块) 就地修改(覆盖文件任意位置) 整体覆盖(不支持就地修改)
典型延迟 微秒到毫秒 毫秒(本地)到几十毫秒(NFS) 几十到几百毫秒
可扩展性 单卷受限(TB 级) 中等(PB 级,受限于元数据服务器) 极高(EB 级)
并发访问 通常单挂载(共享需 SAN) 多客户端挂载(NFS/SMB) 无限并发(HTTP 无状态)
持久性 依赖 RAID 或副本 依赖底层存储 11 个 9(S3 标准存储)
成本 高(SSD/NVMe) 中等 低(尤其是冷存储层)
典型产品 AWS EBS、Azure Disk AWS EFS、Azure Files、NFS AWS S3、Azure Blob、GCS

选型决策树

选型的核心判断依据不是”哪个更先进”,而是应用的访问模式是否匹配存储系统的设计假设。

选择块存储的场景

选择文件存储的场景

选择对象存储的场景

混合使用的工程实践

实际系统中三种存储范式往往混合使用:

混合存储架构示例

┌─────────────┐
│  应用服务器  │
│             │──── EFS (NFS) ──── 共享配置/会话文件
│             │
│   ┌─────┐   │
│   │ DB  │───│──── EBS (块存储) ── 数据库主存储
│   └─────┘   │
│             │──── S3 (对象存储) ── 备份/日志/静态资源/数据湖
└─────────────┘
                         |
                    CloudFront (CDN)
                         |
                    ┌─────────┐
                    │ 终端用户 │
                    └─────────┘

关键的设计原则是:让每种存储做它最擅长的事。块存储处理低延迟事务,文件存储处理共享访问,对象存储处理大规模持久化。不要试图用一种存储范式解决所有问题——用对象存储跑数据库是灾难,用块存储存 PB 级日志是浪费。

从文件系统迁移到对象存储的注意事项

如果现有系统基于文件系统(NFS/POSIX),迁移到对象存储时需要注意以下工程问题:

语义差异处理:依赖 rename() 原子性的逻辑需要重写。文件系统中常见的”写临时文件再 rename”的模式在对象存储中无法直接使用——S3 的 CopyObject + DeleteObject 不是原子操作。替代方案是使用版本控制或在应用层通过幂等写入来保证一致性。

小文件合并:如果原来的文件系统中存在大量小文件(例如每个请求一个日志文件),直接迁移到对象存储会导致海量的 PUT 请求,成本和延迟都不可接受。建议在迁移前将小文件按时间窗口合并为大文件(例如每 5 分钟的日志合并为一个 Parquet 或 gzip 文件),然后再上传。

访问模式适配:如果应用有随机读写需求(seek + read/write),不能直接用对象存储替代。需要在应用层引入本地缓存(如 JuiceFS、s3fs-fuse 等 FUSE 挂载方案)或重构访问逻辑为整体读写模式。FUSE 方案的性能取决于缓存策略和网络带宽,通常比本地文件系统慢一个数量级以上,仅适合对延迟不敏感的场景。

权限模型转换:文件系统的权限模型是 UNIX 用户/组 + rwx 位,对象存储的权限模型是 IAM 策略 + 桶策略 + ACL。两者不能直接映射,需要重新设计权限体系。常见做法是用 IAM 角色(Role)替代 UNIX 用户,用桶策略的 Condition 子句实现细粒度访问控制。


十、参考文献

论文与规范

官方文档与公告

开源项目

工具


上一篇: 存储加密工程 下一篇: S3 API 深度解析

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · storage

存储工程索引

汇总本站存储工程系列文章,覆盖 HDD、SSD、NVMe、持久内存、索引结构、压缩、分布式存储与对象存储。

2025-10-19 · storage

【存储工程】云对象存储内部架构

深入剖析云对象存储——S3的11个9持久性实现、元数据-索引-存储三层架构、跨AZ复制策略、存储类别实现差异与成本模型分析

2025-10-02 · storage

【存储工程】元数据管理

深入分析分布式存储的元数据架构——集中式 vs 分布式 vs 无元数据方案、元数据分片与缓存、HDFS NameNode 与 Ceph CRUSH 的工程实践

2025-09-25 · storage

【存储工程】S3 API 深度解析

全面剖析 S3 API 的工程细节——Multipart Upload、S3 Select、生命周期策略、跨区域复制、性能优化与 boto3 高级用法


By .