MinIO 版本控制、元数据和存储深入探讨

MinIO 版本控制、元数据和存储深入探讨

它始于一个SUBNET问题。一位客户观察到他们的部署速度变慢。经过一些调查,我们找到了客户正在运行的脚本作为原因。该脚本是一个同步脚本,用于上传目录的内容,每次运行之间有一分钟的延迟。

目标存储桶启用了版本控制,因为客户使用的是服务器端复制执行上传的脚本采用了一种天真的方法,它只是在每次运行时上传所有文件。为了对此进行检查,客户使用了自动对象过期规则来删除所有早于一天的版本。

我们确定的是,即使文件很小,在生命周期运行之间累积的大量版本也会给系统带来巨大的负载。

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

元数据结构

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 毫秒。用于读取的内存分配相当大,但由于大多数对象只有 1 个部分,所以认为没问题,因此 10,000 代表最坏的情况。

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

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

对象版本控制

让我们退后一步,将对象版本控制作为一个概念来看待。简而言之,它会保留对象更改的记录并允许您及时返回。简单的客户端不需要担心版本控制,可以只对最新的对象版本进行操作。
后端只是在上传时跟踪版本。例如:

λ 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)。元数据将跟踪版本。

在最简单的情况下,管理元数据纯粹是附加更改的问题。然而在现实中它可能并不那么简单。例如,当写入更改时,磁盘可能处于离线状态。如果我们可以写入足够多的磁盘,我们就会接受更改,但这意味着当磁盘恢复时,它们将需要修复。复制和分层可能需要更新旧版本,可以将标签添加到版本等。

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

当版本数变得非常高时,最初的设计开始看到它的局限性。对于某些操作,我们需要所有版本信息都可用,有时仅此一项就需要超过 1 GB 的内存。使用如此多的内存将大大限制可以发生的并发操作的数量,这当然是不可取的。

设计注意事项

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

列出单个版本还会返回版本计数和“Successor”修改时间,这意味着任何较新版本的时间戳。所以我们需要了解所有版本,这意味着每个版本一个文件会对性能产生反作用。

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

相反,我们决定查看所有版本的操作通常需要哪些信息,以及各个版本需要哪些信息。

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

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

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

执行

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

// 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 中的所有字段的大小都是固定的,这将是我们唯一需要的分配。

image4 (2).png
xl.meta的整体结构

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

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

对象版本可以(重新)排序,而无需移动标头和序列化元数据。此外,我们现在还保证保存的文件是预先分类存储的。这意味着我们现在可以快速识别最新版本并且获得“后续”修改时间是微不足道的。

对于最常见的读取操作,这意味着我们可以在找到所需内容后立即返回。列表现在可以一次反序列化一个版本——节省内存并提高性能。

升级中

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

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

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

  • 查找“版本”数组。

  • 阅读尺寸。

  • 分配[]xlMetaV2ShallowVersion我们需要的。

  • 对于数组中的每个元素:

  • 反序列化到临时位置

  • xlMetaV2VersionHeader基于反序列化的数据创建一个。

  • 观察反序列化时消耗了多少字节。

  • 从序列化数据创建一个子切片。

这和以前的版本一样快,因为我们处理的是相同的数据。唯一的区别是我们当时只需要内存中的一个版本,这显着减少了内存使用。

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

缩放基准和分析

为了评估缩放比例,我们总是尝试创建真实的负载,但也会针对最坏的情况。在这种情况下,我们有一个来自客户的明确案例,超过 1000 个版本开始导致性能问题。虽然我们确实对系统的各个部分进行了基准测试,但真正的测试总是伴随着端到端的整体测量。

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

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

PUT objects_sec (1).png

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

分析数字,我们看到 10 和 100 版本的上传速度几乎相同。这是意料之中的,因为我们没有观察到可被视为“正常”版本数量的问题。即便如此,轻微的加速还是值得观察的。

通过 1,000 个版本,我们观察到我们调查的问题,我们很高兴看到这种情况下的速度提高了 1.9 倍。

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

查看读取性能:

STAT objects_sec.png

首先要注意的是尺度不同。我们正在处理超过一个数量级的对象。您可以看到不必反序列化所有对象版本的明显好处——无论版本化的对象数量有多少,MinIO 都可以平滑地缩放以提供最佳性能。我们优化的结果是,读取具有 1000 个或更多版本的对象时,整体性能和系统响应速度提高了 3-4 倍。

回想一下,此测试是在具有单个 NVME 驱动器的单个服务器上完成的,而不是性能测试中配备最强大的系统。即使在这种配置中,我们以每秒大约 180 个请求读取对象版本信息,因此虽然读取 10,000 个对象版本比读取较少数量的对象慢,但它绝不是无响应的。

结论和未来的改进

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

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

我们可以观察到我们在某个点达到了 IO 限制,在这个点上,突变因总元数据大小而变得不堪重负。改变千字节范围内的文件没问题,但一旦我们达到兆字节范围,它就会对系统造成密集影响。

现在我们有一种称为“XL”的单一数据格式。将来我们可能会研究开始将标头和完整元数据/内联数据拆分为两个文件的选项。这对于少数版本来说不是最优的,因为 IOPS 很宝贵,但对于 1000 多个版本,我们可以采用这种方法。我们称这种格式为“XXL”。

控制系统使用的数据格式有很大的优势。这种控制级别意味着您可以不断改进系统,并确保您可以这样做。在 MinIO,我们的目标是不断改进并帮助我们的客户扩大规模。当然,我们可以简单地告诉客户他们将通过不拥有相同数据的这么多版本来获得最佳性能,但这不是 MinIO 的方式。我们做繁重的工作,所以你不必。MinIO 客户可以专注于他们的应用程序,而将后备存储留给我们。.

不要相信我们的话 -下载 MinIO并亲身体验不同之处。

上一篇 下一篇