系统设计面试笔记 第 24 章

第 24 章 类 S3 对象存储

引言

本章我们将设计一个对象存储(Object Storage)服务,类似 Amazon S3。

存储系统大致分为三类:

  • 块存储(Block Storage)
  • 文件存储(File Storage)
  • 对象存储(Object Storage)

块存储是 20 世纪 60 年代出现的设备,HDD 和 SSD 就是例子。 这些设备通常物理连接在服务器上,不过也可以通过高速网络协议以网络方式挂载。 服务器可以把原始块格式化后作为文件系统使用,也可以把块的控制权直接交给服务器。

文件存储构建在块存储之上。它提供了更高层次的抽象,让文件夹和文件的管理更加容易。

对象存储牺牲了性能,换取高持久性、超大规模和低成本。 它面向「冷」数据,主要用于归档和备份。 它没有层级目录结构,所有数据都以对象的形式存储在扁平结构中。 与其他存储类型相比,它的速度相对较慢。大多数云服务商都提供对象存储产品,例如 Amazon S3、Google GCS 等。

存储类型对比
块存储 文件存储 对象存储
内容可变 是 是 否(支持对象版本控制)
成本 高 中到高 低
性能 中到高,非常高 中到高 低到中
一致性 强一致性 强一致性 强一致性 [5]
数据访问 SAS/iSCSI/FC 标准文件访问、CIFS/SMB 和 NFS RESTful API
可扩展性 中等可扩展性 高可扩展性 超大规模可扩展性
适用场景 虚拟机(VM)、数据库 通用文件系统访问 二进制数据、非结构化数据

与对象存储相关的一些术语:

  • 存储桶(Bucket):对象的逻辑容器。名称全局唯一。
  • 对象(Object):存储在存储桶中的单个数据单元。包含对象数据和元数据。
  • 版本控制(Versioning):在同一个存储桶中保留一个对象多个版本的功能。
  • 统一资源标识符(Uniform Resource Identifier,URI):每个资源都由一个 URI 唯一标识。
  • 服务等级协议(Service-level Agreement,SLA):服务提供商与客户之间的合约。

Amazon S3 标准-不频繁访问(Standard-Infrequent Access)存储类别的 SLA:

  • 跨多个可用区(Availability Zone)实现 99.999999999% 的持久性
  • 即使整个可用区被摧毁,数据仍然可以保全
  • 设计可用性为 99.9%

第 1 步:理解问题并确定设计范围

  • 候选人:应该包含哪些功能?
  • 面试官:创建存储桶、上传/下载对象、版本控制、列出存储桶中的对象
  • 候选人:典型的数据大小是多少?
  • 面试官:我们需要高效地存储超大对象和小对象
  • 候选人:一年要存储多少数据?
  • 面试官:100 PB
  • 候选人:可以假设数据持久性为 6 个 9(99.9999%),服务可用性为 4 个 9(99.99%)吗?
  • 面试官:可以,听起来很合理

非功能性需求

  • 100 PB 数据
  • 6 个 9 的数据持久性
  • 4 个 9 的服务可用性
  • 存储效率。在保持高可靠性和高性能的同时降低存储成本

粗略估算

对象存储的瓶颈很可能出现在磁盘容量或每秒 IO 次数(IOPS)上。

假设:

  • 20% 为小对象(小于 1 MB),60% 为中等对象(1 到 64 MB),20% 为大对象(大于 64 MB),
  • 一块硬盘(SATA,7200 转)每秒可以完成 100 到 150 次随机寻道(100 到 150 IOPS)

基于这些假设,我们可以估算系统能够持久化的对象总数。

  • 为简化计算,各类对象取中位大小:小对象 0.5 MB,中等对象 32 MB,大对象 200 MB。
  • 给定 100 PB 存储(10^11 MB)、40% 的存储使用率,可得约 6.8 亿个对象
  • 如果假设每个对象的元数据为 1 KB,那么需要 0.68 TB 的空间来存储元数据

第 2 步:提出高层设计并获得认可

在深入设计之前,我们先来看看对象存储的一些有意思的特性:

  • 对象不可变性:对象存储中的对象是不可变的(其他存储系统并非如此)。我们可以删除或替换对象,但不能更新。
  • 键值存储:对象的 URI 就是它的键,我们可以通过一次 HTTP 调用获取其内容
  • 一次写入,多次读取:数据访问模式是写一次、读多次。根据 LinkedIn 的一些研究,95% 的操作是读操作
  • 同时支持小对象和大对象

对象存储的设计理念与 UNIX 类似:保存文件时,文件名被创建在一个叫作 inode 的数据结构中,而文件数据存储在磁盘的不同位置。 inode 包含一个文件块指针列表,这些指针指向磁盘上的不同位置。

访问文件时,我们先从 inode 中获取元数据,然后再获取文件内容。

对象存储的工作方式类似:元数据存储用于保存文件信息,而内容存储在磁盘上:

对象存储与 UNIX 文件系统对比

通过将元数据与文件内容分离,我们可以独立扩展不同的存储:

存储桶与对象

高层设计

高层设计
  • 负载均衡器(Load Balancer):将 API 请求分发到各个服务副本
  • API 服务:无状态服务器,负责编排对元数据存储、对象存储以及 IAM 服务的调用。
  • 身份与访问管理(Identity and Access Management,IAM):集中处理认证、授权和访问控制的地方。
  • 数据存储:存储和检索实际数据。操作基于对象 ID(UUID)。
  • 元数据存储:存储对象的元数据

上传对象

上传对象
  • 通过 HTTP PUT 请求创建一个名为「bucket-to-share」的存储桶
  • API 服务调用 IAM,确认用户已获授权并拥有写权限
  • API 服务调用元数据存储创建一条存储桶记录。创建完成后,返回成功响应。
  • 存储桶创建后,发送 HTTP PUT 请求创建一个名为「script.txt」的对象
  • API 服务验证用户身份,并确认用户拥有写权限
  • 验证通过后,对象数据通过 HTTP PUT 发送到数据存储。数据存储将其持久化并返回一个 UUID。
  • API 服务调用元数据存储创建一条新记录,其中包含 object_id、bucket_id、bucket_name 以及其他元数据。

上传对象的请求示例:

PUT /bucket-to-share/script.txt HTTP/1.1
Host: foo.s3example.org
Date: Sun, 12 Sept 2021 17:51:00 GMT
Authorization: authorization string
Content-Type: text/plain
Content-Length: 4567
x-amz-meta-author: Alex

[4567 bytes of object data]

下载对象

存储桶没有目录层级,但我们可以通过拼接存储桶名和对象名来创建逻辑层级,模拟文件夹结构。

获取对象的 GET 请求示例:

GET /bucket-to-share/script.txt HTTP/1.1
Host: foo.s3example.org
Date: Sun, 12 Sept 2021 18:30:01 GMT
Authorization: authorization string
下载对象
  • 客户端向负载均衡器发送 HTTP GET 请求,即 GET /bucket-to-share/script.txt
  • API 服务查询 IAM,验证用户拥有读取该存储桶的正确权限
  • 验证通过后,从元数据存储中取出对象的 UUID
  • 根据 UUID 从数据存储中取出对象数据,并返回给客户端

// sprint 1

第 3 步:设计深入探讨

数据存储

API 服务与数据存储的交互方式如下:

数据存储交互

数据存储的主要组件:

数据存储主要组件

数据路由服务提供 RESTful 或 gRPC API,用于访问数据节点集群。 它是一个无状态服务,通过增加服务器来扩展。

它的主要职责是:

  • 查询放置服务(Placement Service),获取存储数据的最佳数据节点
  • 从数据节点读取数据并返回给 API 服务
  • 向数据节点写入数据

放置服务决定一个对象应该由哪些数据节点来存储。 它维护着一张虚拟集群图(Virtual Cluster Map),用来确定集群的物理拓扑。

虚拟集群图

该服务还会向所有数据节点发送心跳,以确定是否应将它们从虚拟集群中移除。

由于这是一个关键服务,建议维护一个由 5 个或 7 个副本组成的集群,通过 Paxos 或 Raft 共识算法进行同步。 例如,一个 7 节点的集群可以容忍 3 个节点故障。

数据节点存储实际的对象数据。 通过将数据复制到多个数据节点来保证可靠性和持久性。

每个数据节点上都运行着一个守护进程,负责向放置服务发送心跳。

心跳包含:

  • 该数据节点管理多少块磁盘驱动器(HDD 或 SSD)?
  • 每块驱动器上存储了多少数据?

数据持久化流程

数据持久化流程
  • API 服务将对象数据转发给数据存储
  • 数据路由服务将数据发送到主数据节点
  • 主数据节点在本地保存数据,并将其复制到两个从数据节点。复制成功后才发送响应。
  • 对象的 UUID 被返回给 API 服务。

注意事项:

  • 给定一个对象 UUID,它的复制组是通过一致性哈希(Consistent Hashing)确定性地选出的
  • 在第 4 步中,主数据节点先复制对象数据再返回响应。这是以更高的延迟换取强一致性。
一致性与延迟

数据如何组织

管理数据的一个简单方法是把每个对象存储在一个单独的文件中。

这样可行,但当文件系统中有大量小文件时,性能不佳:

  • HDD 上的数据块会被浪费,因为每个文件都会占用整个块。典型的块大小为 4 KB。
  • 文件多意味着 inode 多。操作系统不擅长处理过多的 inode,而且 inode 数量也有上限。

这些问题可以通过预写日志(Write-ahead Log,WAL)把许多小文件合并成大文件来解决。当文件达到容量上限(通常为几 GB)时,就创建一个新文件:

WAL 优化

这种方法的缺点是,对文件的写访问必须串行化。访问同一个文件的多个核心必须相互等待。 为了解决这个问题,我们可以将文件绑定到特定的核心,以避免锁争用。

对象查找

为了支持在同一个文件中存储多个对象,我们需要维护一张表,告诉数据节点:

  • object_id
  • 对象所在的文件名 filename
  • 对象在文件中的起始偏移量 file_offset
  • 对象大小 object_size

这张表可以部署在 RocksDB 这类基于文件的数据库中,也可以部署在传统的关系型数据库中。 由于访问模式是写少读多,关系型数据库效果更好。

应该如何部署它? 我们可以把数据库单独部署成一个集群并独立扩展,由所有数据节点访问。

缺点:

  • 我们需要大力扩展该集群才能处理所有请求
  • 数据节点与数据库集群之间存在额外的网络延迟

另一种方案是利用这样一个事实:数据节点只关心与自己相关的数据, 因此我们可以把关系型数据库部署在数据节点内部。

SQLite 是个不错的选择,它是一个轻量级的基于文件的关系型数据库。

更新后的数据持久化流程

更新后的数据持久化流程
  • API 服务发送保存新对象的请求
  • 数据节点服务将新对象追加到名为「/data/c」的文件末尾
  • 在对象映射表中插入该对象的一条新记录

持久性

数据持久性(Durability)是我们设计中的一项重要需求。为了达到 6 个 9 的持久性,每一种故障情况都需要仔细考察。

首先要解决的问题是硬件故障。我们可以通过复制数据节点来降低故障概率。 但除此之外,我们还应该跨不同的故障域(Failure Domain)进行复制(跨机架、跨数据中心、隔离的网络等)。 一个重大事件可能导致同一故障域内的多个硬件同时故障:

故障域隔离

假设典型 HDD 的年故障率为 0.81%,保存三份副本就能达到 6 个 9 的持久性。

像这样复制数据节点可以提供我们想要的持久性,但我们也可以利用纠删码(Erasure Coding)来降低存储成本。

纠删码让我们可以使用校验位(Parity Bits),在发生故障时重建丢失的位:

纠删码

把这些位想象成数据节点。如果其中两个宕机,可以用其余四个恢复它们。

纠删码有不同的方案。在我们的场景中,可以使用 8+4 纠删码,并分散到不同的故障域中以最大化可靠性:

跨故障域的纠删码

纠删码能让我们以低得多的成本存储数据(提升 50%),代价是访问速度变慢,因为数据路由服务必须从多个位置收集数据:

纠删码与复制对比

其他注意事项:

  • 复制需要 200% 的存储开销(3 副本的情况下),而纠删码只需 50%
  • 纠删码能提供 11 个 9 的持久性,而复制只有 6 个 9
  • 纠删码需要更多的计算来计算和存储校验数据

总之,复制更适合对延迟敏感的应用,而纠删码在存储成本效率和持久性方面更有吸引力。 纠删码的实现难度也要大得多。

正确性校验

如果整块磁盘发生故障,故障很容易被检测到。但如果只是磁盘的一部分数据损坏,就没那么容易发现了。

为了检测这种情况,我们可以使用校验和(Checksum):它是文件内容的哈希值,可用来验证文件的完整性。

在我们的设计中,会为每个文件和每个对象都存储校验和:

用校验和保证正确性

在纠删码(8+4)的情况下,我们需要分别获取 8 份数据中的每一份,并逐一验证它们的校验和。

// sprint 2

元数据的数据模型

表结构:

元数据的数据模型

我们需要支持的查询:

  • 根据名称查找对象 ID
  • 根据名称插入/删除对象
  • 列出存储桶中具有相同前缀的对象

用户能创建的存储桶数量通常有上限,因此存储桶表很小,可以放进单台数据库服务器。 但我们仍然需要扩展服务器以提升读吞吐量。

不过,对象表可能放不进单台数据库服务器。因此,我们可以通过分片来扩展该表:

  • 按 bucket_id 分片会导致热点问题,因为一个存储桶可能包含数十亿个对象
  • 按 bucket_id 分片能让负载分布更均匀,但我们的查询会很慢
  • 我们选择按 hash(bucket_name, object_name) 分片,因为大多数查询都是基于对象名/存储桶名进行的。

但即使采用这种分片方案,列出存储桶中的对象仍会很慢。

列出存储桶中的对象

在单个数据库中,基于前缀(看起来像目录)列出对象的方式如下:

SELECT * FROM object WHERE bucket_id = "123" AND object_name LIKE `abc/%`

当数据库分片后,这就很难实现了。为此,我们可以在每个分片上执行该查询,再在内存中聚合结果。 但这会让分页变得困难,因为不同分片返回的结果数量不同,我们需要为每个分片分别维护 limit/offset。

我们可以利用这样一个事实:对象存储通常并不针对列出对象做优化,因此可以牺牲列表性能。 我们也可以创建一张专门用于列出对象的反规范化(Denormalized)表,按存储桶 ID 分片。 这样列表查询就足够快了,因为它只涉及单个数据库实例。

对象版本控制

版本控制的实现方式是增加一个类型为 TIMEUUID 的 object_version 列,使我们可以据此对记录排序。

每个新版本都会产生一个新的 object_id:

对象版本控制

删除对象会创建一个新版本,其 object_id 是一个特殊值,表示该对象已被删除。查询该对象将返回 404:

删除带版本的对象

优化大文件上传

大文件上传可以通过分段上传(Multipart Upload)来优化:把一个大文件拆分成若干块,分别独立上传:

分段上传
  • 客户端调用服务,发起一次分段上传
  • 数据存储返回一个上传 ID,用于唯一标识这次上传
  • 客户端把大文件拆分成若干块,使用该上传 ID 分别独立上传
  • 每上传完一块,数据存储会返回一个 etag,它是一个 MD5 校验和,用于标识该上传块
  • 所有分段上传完成后,客户端发送一个完成分段上传的请求,其中包含 upload_id、分段编号以及所有 etag
  • 数据存储将各分段重新组装成对象。这个过程可能需要几分钟。完成后,向客户端返回成功响应。

此时,不再有用的旧分段可以被删除。我们可以引入一个垃圾回收器来处理这件事。

垃圾回收

垃圾回收(Garbage Collection)是回收不再使用的存储空间的过程。数据变成垃圾有以下几种情况:

  • 延迟删除对象:对象被标记为已删除,但实际上并没有被删除
  • 孤立数据:例如上传中途失败,需要删除旧的分段
  • 损坏数据:未通过校验和验证的数据

垃圾回收器还负责回收副本中未使用的空间。 采用复制时,数据会从主节点和副本上一并删除。采用纠删码(8+4)时,数据会从全部 12 个节点上删除。

为了便于删除,我们将使用一个称为压缩(Compaction)的过程:

  • 垃圾回收器把未被删除的对象从「data/b」复制到「data/d」
  • 复制完成后,通过一个数据库事务更新 object_mapping 表
  • 为了避免产生太多小文件,只对增长超过一定阈值的文件进行压缩
压缩

第 4 步:总结

我们讨论了以下内容:

  • 设计一个类 S3 的对象存储
  • 比较对象存储、块存储和文件存储之间的差异
  • 介绍了存储桶中对象的上传、下载、列出和版本控制
  • 深入探讨了设计细节:数据存储与元数据存储、复制与纠删码、分段上传、分片