MinIO 版本控制、元数据和存储深度解析

MinIO Versioning, Metadata and Storage Deep Dive

这始于一个 子网 问题。一位客户观察到其部署中出现速度下降。经过一番调查,我们发现客户运行的脚本是导致问题的原因。该脚本是一个同步脚本,它以每分钟一次的频率上传目录的内容。

目标存储桶启用了 版本控制,因为客户正在使用 服务器端复制。执行上传的脚本采用了一种简单的方式,即每次运行时简单地上传所有文件。为了控制这一点,客户使用了一个 自动对象过期 规则,删除所有超过一天的版本。

我们发现,即使文件很小,在生命周期运行之间积累的版本数量也会对系统造成相当大的负载。

虽然我们可以建议客户快速解决问题,但我们认为这是一个改进我们处理过度版本数量的方式的机会。

元数据结构

MinIO 服务器没有数据库。这是一个早期的设计选择,也是 MinIO 能够跨数千台服务器以容错方式扩展的主要因素。MinIO 使用 一致性哈希 和文件系统来存储所有对象信息和内容,而不是数据库。

当我们实现版本控制时,我们调查了各种选项。在版本控制之前,我们将元数据存储为 JSON。这使得调试问题变得容易,因为我们可以直接查看元数据。然而,在我们的研究过程中,我们发现,虽然它很方便,但通过切换到二进制格式,我们可以将磁盘大小减少约 50%,并将 CPU 使用率减少约相同比例。


我们决定选择 MessagePack 作为我们的序列化格式。这通过允许添加/删除键来保持与 JSON 的可扩展性。最初的实现只是一个标头,后跟一个具有以下结构的 MessagePack 对象

{
  "Versions": [
    {
      "Type": 0, // Type of version, object with data or delete marker.
      "V1Obj": { /* object data converted from previous versions */ },
      "V2Obj": {
          "VersionID": "",  // Version ID for delete marker
          "ModTime": "",    // Object delete marker modified time
          "PartNumbers": 0, // Part Numbers
          "PartETags": [],  // Part ETags
          "MetaSys": {}     // Custom metadata fields.
          // More metadata
      },
      "DelObj": {
          "VersionID": "", // Version ID for delete marker
          "ModTime": "",   // Object delete marker modified time
          "MetaSys": {}    // Delete marker metadata
      }
    }
  ]
}

以前版本的元数据在更新时会进行转换,新版本根据操作添加为“V2Obj”或“DelObj”。

我们努力使我们的基准测试始终在真实数据上运行,以便它们适用于现实世界中的使用。我们评估了读取和写入多达 10,000 个版本(每个版本有 10,000 个部分)的时间,这代表了我们的最坏情况。基准测试表明,解码此操作需要大约 120 毫秒。读取的内存分配相当大,但被认为是可以接受的,因为大多数对象只有一个部分,因此 10,000 代表了最坏情况。

这是大约一年的磁盘表示。内联数据已添加到该格式,允许将小对象的其数据存储在与元数据相同的文件中。但这并没有改变元数据表示,因为内联数据存储在元数据之后。

这意味着,在只需要读取元数据的情况下,我们可以在到达元数据末尾时简单地停止读取文件。这最多可以通过两次连续读取来实现。

对象版本控制

让我们退一步,将对象版本控制作为一个概念来看。简而言之,它保留了对象更改的记录,并允许您回溯时间。简单的客户端无需担心版本控制,并且可以仅操作最新的对象版本。
后端只需在上传时跟踪版本。例如

λ mc ls --versions play/test/ok.html
[2021-12-14 18:00:52 CET]  18KiB 83b0518c-9080-45bb-bfd3-3aecfc00e201 v6 PUT ok.html
[2021-12-14 18:00:43 CET]     0B ff1baa7d-3767-407a-b084-17c1b333ea87 v5 DEL ok.html
[2021-12-11 10:01:01 CET]  18KiB ff471de8-a96b-43c2-9553-8fc21853bf75 v4 PUT ok.html
[2021-12-11 10:00:21 CET]  18KiB d67b20e2-4138-4386-87ca-b37aa34c3b2d v3 PUT ok.html
[2021-12-11 10:00:11 CET]  18KiB 47a4981a-c01b-4c6a-9624-0fa44f61c5e9 v2 PUT ok.html
[2021-12-11 09:57:13 CET]  18KiB f1528d08-482d-4945-b8ee-e8bd4038769b v1 PUT ok.html

这显示了已写入的 6 个 ok.html 版本和已删除的 1 个版本(v5)。元数据将跟踪这些版本。

在最简单的情况下,管理元数据纯粹是追加更改的问题。然而,在现实中,它可能并不像看起来那样简单。例如,当写入更改时,磁盘可能脱机。如果我们可以写入足够多的磁盘,则我们会接受更改,但这意味着当磁盘恢复联机时,它们将需要进行修复。复制和分层可能需要更新旧版本,标签可以添加到版本,等等。

几乎任何更新都意味着我们需要检查版本是否仍然按  正确顺序排序。对象版本严格按“修改时间”排序,即对象版本上传的时间。使用上面的结构,这意味着我们需要加载所有版本才能访问此信息。

当版本数量非常高时,最初的设计开始显露出其局限性。对于某些操作,我们需要所有版本信息可用,有时仅为此操作就需要超过 1GB 的内存。使用这么多的内存会对可以执行的并发操作数量设置很大的限制,这当然是不希望看到的。

设计考量

最初,我们评估了将所有版本的元数据存储在一个文件中是否可行。我们很快否决了将各个版本存储为单个文件。一个版本的元数据通常小于 1KB,因此列出所有版本会导致随机 I/O 大量增加。

列出单个版本还会返回版本计数和“后继者”修改时间,即任何较新版本的 timestamps。因此,我们需要了解所有版本,这意味着每个版本有一个文件对于性能来说是弊大于利的。

我们研究了一种日志类型方法,其中在每次更新时追加更改,而不是重写元数据。虽然这对于写入可能是一个优势,但它在读取时会带来很多额外的处理。这不仅适用于单个读取,还会大大降低列表速度。因此,这不是我们想要追求的方法。

相反,我们决定研究对于涉及 **所有** 版本的操作通常需要哪些信息,以及对于单个版本需要哪些信息。

大多数操作要么作用于“最新版本”,要么作用于特定版本。如果执行 GetObject 调用,则可以指定版本 ID 并获取该版本,或者您的请求被视为针对最新版本。大多数操作类似。唯一作用于所有版本的操作是 ListObjectVersions 调用,它返回所有对象的版本。

对象变异需要检查现有的版本 ID 和修改时间以进行排序。列表需要合并跨磁盘的版本,因此它需要能够检查元数据在各个磁盘上是否相同。

如果我们可以访问这些信息,那么就不需要将所有版本元数据一次性解压缩到内存中。对性能的影响是巨大的。

实现

在实践中,我们决定进行一个相当小的更改,以实现所有这些改进。我们没有将所有版本完全解压缩到内存中,而是将其更改为以下结构

// xlMetaV2 contains all versions of an object.
type xlMetaV2 struct {
	// versions sorted by modification time,
	// most recent version first. 
	versions []xlMetaV2ShallowVersion
}

// xlMetaV2ShallowVersion contains metadata information about
// a single object version.
// metadata is serialized.
type xlMetaV2ShallowVersion struct {
	header xlMetaV2VersionHeader
	meta   []byte
}



// xlMetaV2VersionHeader contains basic information about an object version.
type xlMetaV2VersionHeader struct {
	VersionID [16]byte
	ModTime   int64
	Signature [4]byte
	Type      VersionType
	Flags     xlFlags
}

我们仍然将所有版本“保存在内存中”,但现在我们将每个版本的元数据保存在序列化形式。在实践中,这些序列化数据只是从磁盘加载的元数据的子切片。这意味着我们只需要为所有版本分配一个固定大小的切片。为了执行我们的操作,我们有一个标头,其中包含有关每个版本的有限信息,只有在我们永远不需要扫描所有元数据时才足够的信息。

磁盘上的表示也发生了变化,以适应这一点。以前,所有元数据都存储为一个大型对象,其中包含所有版本。现在,我们改为这样写

  • 带有版本的签名
  • 标头数据的版本(整数)
  • 元数据的版本(整数)
  • 版本计数(整数)

读取此标头允许我们在 xlMetaV2 实例中分配“版本”。由于 xlMetaV2ShallowVersion 中的所有字段大小固定,因此这将是我们需要的唯一分配。

xl.meta 的总体结构

对于每个版本,都有 2 个二进制数组,一个包含序列化后的 xlMetaV2VersionHeader,另一个包含完整的元数据。对于所有操作,我们只读取标头并将序列化后的完整元数据按原样保存在内存中。在磁盘上,这会为每个版本增加约 30-40 字节,即使这些是重复数据,但由于性能提升,这仍然是可以接受的权衡。

这意味着,对于仅影响单个版本的常规变异,我们只需要反序列化该特定版本,应用变异并序列化该版本。类似地,对于删除和插入,我们永远不需要处理完整的元数据。

对象版本可以(重新)排序,而无需移动超过标头和序列化元数据。此外,我们现在还保证保存的文件按预先排序的顺序存储。这意味着我们现在可以快速识别最新版本,并且获取“后继者”修改时间变得非常简单。

对于最常见的读取操作,这意味着我们可以尽快返回,因为我们已经找到了我们正在寻找的内容。列表现在可以一次反序列化一个版本 - 节省内存并提高性能。

升级

MinIO 有数千个部署,存储了数十亿个对象,因此平滑升级非常重要。在更改我们存储对象的方式时,我们采用了一种“不转换”方法,这意味着我们不会更改存储的数据,即使它采用的是旧格式。对于许多对象,将它们全部转换将是一个耗时且资源密集的任务。

该任务的主要部分是确保现有数据不会消耗大量资源来读取。我们不能接受升级会显着降低性能。但我们更愿意不为旧版本保留重复的代码。现有数据通常最终需要在某个时间点进行转换,我们希望尽快处理这一点。

在这种情况下,我们能够利用 MessagePack 的一些优势。虽然我们仍然需要反序列化所有版本,但我们可以执行一些技巧

  • 查找“版本”数组。
  • 读取大小。
  • 分配我们需要的 []xlMetaV2ShallowVersion
  • 对于数组中的每个元素
  • 反序列化到一个临时位置
  • 根据反序列化数据创建一个 xlMetaV2VersionHeader
  • 观察反序列化时消耗了多少字节。
  • 从序列化数据中创建一个子切片。

这与之前版本一样快,因为我们正在处理相同的数据。唯一的区别是我们一次只需要在内存中保留一个版本,这极大地减少了内存使用量。

利用这种技巧,我们能够以与以前相同的处理量加载现有的元数据。如果需要将元数据写回磁盘,现在它将采用新的格式,在下次读取时无需转换。

扩展基准测试和分析

为了评估扩展性,我们始终尝试创建逼真的负载,同时也针对最坏情况的场景。在这种情况下,我们从客户那里得到了一个明确的案例,即超过 1000 个版本会导致性能问题。虽然我们确实对系统的各个部分进行了基准测试,但真正的测试始终是端到端的整体测量。

为了评估 MinIO 的扩展能力,我们使用了我们的 Warp S3 基准测试工具。Warp 使创建非常具体的负载成为可能。对于此测试,我们使用了 put(PutObject)和 stat(HeadObject)基准测试。我们创建了多个对象,每个对象都有不同数量的版本,并观察了我们从随机对象/版本中获取元数据的速度。

我们在一个带有单个 NVME 的分布式服务器上进行了测试。这些数字并不代表完整的 MinIO 集群,而只代表一个单个服务器。事不宜迟,让我们来看看这些数字。

纵轴表示已提交到服务器的每秒对象版本数量。请注意,每个样本在版本数量方面代表一个数量级。另请注意,较高的条形图表示更多操作/秒,这意味着更高的性能。

分析这些数字,我们发现使用 10 个和 100 个版本时,上传速度基本相同。这是预期的,因为我们没有观察到被认为是“正常”数量的版本出现问题。即使如此,观察到速度略有提高也是好事。

使用 1,000 个版本时,我们观察到我们正在调查的问题,并且我们很高兴看到这种情况下的速度提高了 1.9 倍。

查看 10,000 个版本时,出现了一个有趣的问题。此时,我们的服务器达到了我们 NVME 存储的 IO 饱和点,约为 ~1.5GB/s。这将系统瓶颈从一个部分转移到另一个部分,从内存转移到存储。添加对象第 10,000 个版本所需的时间仅为 350 毫秒。

查看读取性能

首先要注意的是比例尺不同。我们正在处理数量级更多的对象。您可以看到,不必反序列化所有对象版本带来的明显优势 - MinIO 平稳扩展,以提供最佳性能,无论版本化的对象数量多少。我们优化的结果是,对具有 1000 个或更多版本的对象的读取在整体性能和系统响应能力方面实现了 3-4 倍的速度提升。

请记住,此测试是在一台具有单个 NVME 驱动器的单台服务器上进行的,这并不是用于性能测试的最强大的系统。即使在这种配置下,我们每秒也能读取大约 180 个对象版本信息,因此虽然读取 10,000 个对象版本比读取较少数量的对象要慢,但这并不意味着响应能力不佳。

结论和未来改进

我们的主要目标是减少多个版本的处理开销。我们将内存使用量减少了几个数量级,并提高了版本处理速度。

解决问题的好处是,总会有更多挑战需要解决。对于这次迭代,我们已经将可行的版本数量提高了一个数量级。然而,在 MinIO,我们永远不会满足 - 我们已经在寻找进一步提高世界上最快分布式对象存储的性能和可扩展性的方法。

我们可以观察到,在某个点上,我们达到了 IO 限制,此时突变会受到总元数据大小的限制。对千字节范围内的文件进行突变是可以的,但一旦达到兆字节范围,就会对系统造成很大压力。

目前,我们有一个名为“XL”的单一数据格式。将来,我们可能会调查将头文件和完整元数据/内联数据拆分为两个文件的选项。对于少量版本来说,这是不理想的,因为 IOPS 非常宝贵,但对于 1000 个以上版本来说,这可能是我们可以采用的方法。让我们称这种格式为“XXL”。

能够控制系统使用的数据格式有很大的优势。这种控制水平意味着您可以不断改进系统,并确保您可以这样做。在 MinIO,我们的目标是不断改进,并帮助我们的客户扩展。当然,我们也可以简单地告诉客户,通过减少同一数据的版本数量,他们将获得最佳性能,但这并不是 MinIO 的方式。我们承担了繁重的工作,这样您就不必这样做。MinIO 客户可以专注于他们的应用程序,并将后备存储留给我们。

不要相信我们的话 - 下载 MinIO,亲身体验其中的区别。