01
序言
原文:
https://jack-vanlightly.com/analyses/2024/7/3/understanding-apache-paimon-consistency-model-part-1
作者:
Jack Vanlightly, Staff Technologist, Technical Strategy Group, at Confluent.
Apache Paimon是一种开源表格格式,是在更成熟的 Apache Iceberg、Delta Lake 和 Apache Hudi 项目之后出现的。它诞生于 Apache Flink 项目中,当时被称为 Flink Table Store,但后来成为顶级 Apache 项目。
当我第一次开始深入研究 Paimon 时,我说如果 Iceberg、Delta 和 Hudi 生了一个孩子,那可能就是 Paimon。但 Paimon 有许多自己的创新,使其有别于三大表格格式。
Paimon 有很多功能,不可能全部包含,所以我将尝试专注于核心机制。正如我之前关于各种表格式的一致性模型的文章一样,这篇文章将重点介绍逻辑构建块,而不是过多地介绍实现细节。在这篇文章中,我将从高层次构建思维模型,然后介绍一致性模型,就像我之前对 Apache Hudi 和 Delta Lake 所做的那样。
02
Paimon 的逻辑模型
Paimon 有目录、数据库和表的概念。在本次分析中,我们只关注主键表,尽管也存在 Append 表和 Changelog。
Paimon 将表格分成两层:
元数据层:告诉计算引擎表的模式、存在哪些数据文件、这些文件中数据的统计信息等信息。
数据层:构成表的实际数据的一组数据和索引文件。此层被组织成一个或多个 LSM 树。Paimon 与 Iceberg、Hudi 和 Delta 最大的不同就是将数据组织成一组 LSM 树。
03
元数据层
Paimon 将元数据层组织成一组文件,这些文件在每个时间点形成一棵树。也就是说,每个写入操作都会写入一组文件,其中包括:
树根(快照文件)。
两个清单文件(基本和增量)。
索引清单文件。
一个或多个清单文件。
一个或多个数据文件。
图 1. 形成 Paimon 表的一个时间点(一个版本)的元数据和数据文件树。
快照文件写入快照目录,并以单调递增的整数(表格版本)作为后缀,使得编号没有间隙。这形成了一个快照文件日志,其顺序是文件名的字典顺序。
虽然此日志看起来类似于 Delta Log 或 Hudi Timeline,但两者完全不同。它更类似于 Apache Iceberg 元数据文件中的快照列表。在 Paimon 中,就像 Iceberg 一样,每个快照文件都是给定时间点文件树的单个根。Delta 和 Hudi 通过读取日志(从最后一个检查点开始)并汇总条目以形成逻辑快照来获取表的当前状态 - 没有单个树根文件。
目录中文件的这种字典顺序排序允许计算引擎知道哪个快照文件是最新的(尽管还有一个名为 LATEST 的最新快照副本),而无需像 Apache Iceberg 那样使用外部目录。它还允许通过查询先前时间点的表来进行时间旅行。
每个快照文件都有多个字段,但与元数据组织相关的重要字段是:
版本:此快照的表版本。
SchemaId:指向包含字段集、主键列和分区列的 Schema 文件。
baseManifestList:包含前一个快照的清单文件的清单列表文件。
deltaManifestList:包含此快照中写入的新清单文件的清单列表文件。
indexManifest:包含当前索引文件的列表,其中包括布隆过滤器、存储桶映射和删除向量(我们将在后面介绍)。
commitKind:提交的类型,例如 Append、Compact、Overwrite。在本文中,我们将重点介绍:
Append 提交,可以附加插入/更新/删除行操作。
Compact 提交,将数据文件重写为在 LSM 树中的更高级别上形成更少、更大的文件。
快照 S 的基本清单列表是快照 S-1 的基本清单列表 + 增量清单列表的合并。
图 2. 基本清单文件。实际上,清单文件也会被压缩以减少元数据文件的数量,但这里并未体现这一点。
每个清单文件可以指向一个或多个数据文件,并包含有关这些数据文件的统计信息,计算引擎可以利用这些数据文件从查询中删减数据文件。使用两个清单列表文件使得流式读取器可以只读取增量清单列表指向的数据的最新更改。标准的基于批处理的查询需要组合快照的两个清单列表文件才能获得表的全局视图(在该版本号下)。
创建新快照时,它会合并上一个快照的基本清单列表 + 增量清单列表,并将清单文件压缩为更少、更大的文件。此压缩与数据文件压缩不同,只是提交期间执行的清理工作。
04
数据层
任何给定的表都存储为一组数据文件,例如 Parquet 或 ORC。这些数据文件被组织成一个或多个分区,每个分区可以进一步细分为一个或多个存储桶。分区是根据一组分区列定义的。存储桶的数量可以是固定的,也可以是在写入数据时动态创建的。
分区允许计算引擎删除与查询无关的整个分区,从而提高查询效率。通常,分区是基于时间的,例如日期。按日期过滤的查询可以避免仅由于分区而加载大量数据文件。
图 3. 一个包含两个分区(按日期)且每个分区有 4 个存储桶的表。
在每个分区中,数据根据存储桶键(或在没有存储桶键的情况下的主键)分布在存储桶中,文件中的数据按主键排序。每个存储桶都组织成自己的 LSM 树。对于固定数量的存储桶,数据根据存储桶键(或主键)的哈希值路由到存储桶,对于按需创建的动态存储桶,在全局存储桶索引中维护存储桶键到存储桶的映射(就像 Apache Hudi 及其文件切片一样)。
分区和存储桶满足不同的需求。分区允许计算引擎在读取时更有效地修剪数据文件,从而实现更高效的查询。另一方面,存储桶的存在是为了为读取和写入提供更多的并行性。我们稍后将更详细地介绍并发性、并行性和存储桶。
数据和元数据文件被组织到一组目录中(对象存储中没有目录抽象的前缀)。数据文件根据分区和存储桶写入子目录中。元数据文件写入分区和存储桶目录之外的目录中。
图 4. 文件组织(来自https://paimon.apache.org/docs/master/concepts/specification)
05
LSM 树存储
(每个存储桶一个 LSM 树)
通用 LSM 树
LSM 树通常由内存中的 memtable 缓冲区、预写日志 (WAL) 和一组标有级别编号的排序运行文件组成。Paimon 不使用 WAL,因为它旨在回退到 Flink 的检查点功能。这意味着它可以从导致内存数据丢失的任何故障中恢复。
图 5. LSM 树架构,取自https://www.researchgate.net/figure/Architecture-of-an-LSM-tree-as-implemented-by-RocksDB-taken-from-RocksDB-wiki-11_fig2_358081916
排序运行是一组一个或多个排序字符串表 (SST) 文件,这些文件在内部按键排序,但其键范围可以与其他排序运行重叠。写入将命中内存中的 memtable 缓冲区和 WAL(如果存在),然后异步地将内存中的 memtable 缓冲区刷新为排序运行。同样异步地,压缩作业将排序运行合并为更少、更大的更深层次的排序文件,以提高读取效率。
与 B 树不同,给定键的数据可能存在于任何排序运行文件中。给定键的读取将首先到达内存中的 memtable 缓冲区,如果未找到匹配项,则按级别顺序 (0-N) 遍历每个级别的排序运行。假设读取想要键的最新版本,它会在第一次匹配时停止遍历排序运行(因为这将是最新的)。任何给定的键都可以存在于多个排序运行文件中(键的旧版本存在于较旧的排序运行中)。随着时间的推移,排序运行文件被压缩为更高级别的更大文件。旧版本的行可以通过压缩过期,而不会将它们重写到新的更大的排序运行文件中。采用过期策略仅保留最后 V 个版本的数据,以避免磁盘继续增长。
Paimon 的 LSM 树方法
Paimon 采用了一些经典的 LSM 树方法,它让我想起了 ClickHouse,只是ClickHouse不会进一步细分分区。它们都有许多相似之处:
尽管没有使用预写日志,数据仍缓冲在内存中,并以 0 级排序运行的形式刷新。
压缩是时间对齐的,也就是说,只有一组覆盖连续时间范围的排序运行才能被压缩。
虽然数据文件可能带有层级标记,但这些层级不会像常规 LSM 树引擎(例如 RocksDB)那样影响读取路径。读取路径不会遍历层级(从 0 到 N)来挑选任何给定键的最新值。相反,必须读取与查询键范围相交的所有数据文件,并且必须采用合并策略来执行诸如重复数据删除之类的操作。
支持不同的合并引擎。Paimon 默认使用重复数据删除合并引擎(相当于 ClickHouse 的 ReplacingMergeTree),但它也有一个聚合合并引擎(相当于 ClickHouse 的 AggregatingMergeTree)和其他引擎。Paimon 还有部分更新合并引擎,我将在下文中介绍。
图 6. 时间对齐压缩。更高级别的压缩可以追溯到更早的时间。基于时间的到期可能非常高效,因为它意味着在保留期结束时删除整个文件。
在 Paimon 中,每个排序运行都可以由多个排序数据文件组成。在排序运行中,数据分布在数据文件中,键范围没有重叠。但是,排序运行之间的数据可以在键范围内重叠,因此给定键的数据可以存在于存储桶内的多个排序运行中。
每个数据文件包含多行,每行都有一种行类型(用字母表示),该类型将行标记为插入 (+I)、更新 (+U) 或删除 (-D)。更新可以支持变更日志生成器的更新前 (-U) 和更新后 (+U)。为了尽可能简洁,我不会在这篇文章中讨论变更日志生成器,但要知道 Paimon 将变更日志纳入了规范。
图 7. 插入、更新和删除操作序列转换为跨数据文件的行。
除了重复数据删除和聚合合并引擎之外,Paimon 还包含一个部分更新合并引擎。这允许写入者在更新操作中仅包含一行的列子集。然后,合并引擎可以应用每个主键的部分更新集(按序列号排序),以形成该行的最终结果。
06
读取时合并
读取器的工作是将多个排序运行的数据合并为最终结果(对于重复数据删除合并引擎,每个主键对应一行)。为了正确合并数据,读取器必须知道合并结果的顺序,这是通过序列号来实现的。每个写入器都维护一个计数器,用于设置序列号。也可以使用序列字段代替序列号,例如使用表的时间戳字段。
Paimon 写入器可以在写入一个排序运行后立即执行完全压缩,或者每写入第 N 个排序运行后执行一次完全压缩。或者,在单独的线程中运行的专用压缩作业可以异步执行压缩。压缩文件的大小取决于表大小、分区数和每个分区的存储桶数。文档建议存储桶为 200MB-1GB,考虑到其他分析系统最终可能会得到数 GB 大小的数据文件,这个大小相当小。存储桶大小相对较小的一个原因是,并行性可能根据存储桶的数量而受到限制。我们将在并发部分更详细地介绍 Paimon 的这一方面。但默认情况下,并行性限制为每个存储桶一个读取器/写入器。拥有少量大存储桶可能会限制读取器并发的数量并影响性能。
07
删除向量
引入删除向量
引入删除向量是为了提高读取性能,同时又不会过度影响写入性能。其理念是使数据文件中在删除或更新操作后变得陈旧的特定行失效。
图 8。存储桶的删除向量文件有 5 个删除向量,使 2 个数据文件中的 5 行无效。0 级文件永远不会失效。
如果没有删除向量,读取器的数量将受到存储桶数量的限制(每个存储桶最多一个读取器)。如果没有删除向量,单个存储桶的读取就无法并行化的原因是,读取器必须将存储桶的所有排序运行读入内存,然后根据行序列号执行合并操作。如果读取器只读取了数据文件的子集,则它无法知道任何给定的行是否有效。这也意味着谓词不能被下推到低级读取器级别,因为合并逻辑必须看到所有行。整个存储桶需要由单个读取器应用合并逻辑读取和合并。对于任何给定的主键,具有最高序列号的行将添加到查询结果中(使用重复数据删除合并引擎时)。您可以在此处阅读有关多路合并算法的更多信息。
实际上,对于这种合并存在一些优化。例如,只有键范围与查询重叠的数据文件(如果它具有基于键的谓词)才需要读取和合并。
删除向量允许将基于序列号的合并替换为基于位置删除的过滤器(如 Iceberg、Delta 和 Hudi),从而将存储桶的读取分散到多个读取器中。这也是一种更高效的合并操作,因为它不是数据文件之间的多向合并,而只是一次一个文件,并根据删除向量跳过数据。
删除向量维护
删除向量不会在写入操作期间更新,而是通过压缩来维护。写入者会继续照常写入级别 0。因此,删除向量通常不会给写入增加任何开销,额外的成本是在压缩期间支付的。但是,有两种情况会影响写入:
写入器自己执行压缩,后台压缩作业运行太慢,并对写入器施加背压(因为小文件的数量超过了配置的阈值)。
专用压缩作业运行太慢,会导致大量中间数据不可见,详见下文分析。
删除向量永远不会为级别 0 生成;写入器写入级别 0,而压缩写入更高级别。这意味着,当启用删除向量时,读取器必须跳过级别 0;否则,它们将需要在位置删除过滤器之上应用序列号合并操作(失去每个存储桶并行读取器的好处)。根据压缩的频率,等待行达到级别 1 可能会在给定行更新的写入和读取之间增加一些额外的延迟。
删除向量存储在每个存储桶的单个 DV 文件中,并通过写时复制操作进行维护,即,在任何添加或删除删除向量的压缩之后重写该文件。
当压缩作业运行时,它会选择一组要压缩的级别,并执行操作以将源文件重写为更少的较大文件。当压缩器包含级别 0 时,它必须对源数据文件执行基于序列号的常规合并。过时和已删除的行将被删除,结果将作为一组新的较大数据文件写入更高级别。
现在介绍生成或删除删除向量的部分。压缩数据文件中具有 +U 或 -D 行的主键也可能具有更高级别(压缩级别之上)的数据文件中的行。这些行位于压缩级别之外,需要使用删除向量使其无效。
图 9. 压缩之前,“Jack” 有两个有效行,一个在级别 0,一个在级别 3。压缩之后,级别 3 中的行已被存储桶删除向量文件中的删除向量条目无效。
我们可以通过更详细的场景来举例,以多个用户及其之前的观点为例:
图 10. 当更高级别(比正在压缩的级别)中的行被压缩级别中的行无效时,在压缩期间会添加删除向量。当删除向量指向逻辑上被删除的文件时,删除向量会被移除。DV 文件本身只有在它们及其版本的其他数据文件过期时才会被删除。
保证在 1 级及以上级别中,主键最多可以有一行未被删除向量标记。0 级未压缩,并且没有与之关联的删除向量。
读取器必须先加载删除向量,然后在读取 1 级及以上级别的排序运行数据文件时,它会跳过删除向量文件中指示的行。本质上,过时的行已被屏蔽。这允许从单个存储桶读取时实现并行性,因为每个读取器都可以读取部分数据文件并使用删除文件的屏蔽来过滤掉过时的行。这也意味着谓词可以下推到读取器逻辑的较低级别,因为合并逻辑没有义务读取存储桶的所有行。然后可以从各个读取器组合最终结果。请记住,读取器会跳过 0 级,因此可能必须等待最新的更改,尽管压缩应该很频繁。
08
COW & MOR
Iceberg、Delta 和 Hudi 支持写时复制 (COW) 和读取时合并 (MOR) 表,由用户决定使用哪种。
COW 意味着对给定行的任何更新或删除都意味着必须重写受影响数据文件的新版本。例如,1 GB 的数据文件中只有一行更新,则将被完全重写。这对于突变繁重的工作负载来说是低效的,因为它会导致大量的写入放大,但它对读取性能有很大帮助。
MOR 采用不同的方法,突变会导致写入额外的小文件,读取器必须将这些小文件与较大的基础文件合并才能获得结果。例如,当 1 GB 数据文件中的一行更新时,1 GB 文件保持不变。如果是删除,则 1 GB 文件中的行会因删除文件中的条目而失效。
Iceberg 使用术语“删除文件”,而 Delta 与 Paimon 一样使用术语“删除向量”。更新操作会将新值写入新数据文件,旧值将以与删除操作相同的方式通过删除文件失效。读取器必须将原始 1 GB 文件与这些新的小文件合并。这对于写入性能非常有用,但会增加一些读取开销(由于必须读取更多文件而导致读取放大)。
Paimon 自然是一种读取时合并的设计,因为读取器必须合并多个排序运行的行才能返回结果。删除向量使此合并过程更加高效,但它仍然被归类为 MOR 方法。但是,可以通过在每次写入后执行完全压缩来在一定程度上模拟 COW,这将减少所需的合并量。因此,Paimon 提供了一些方法来控制读取与写入放大。
现在我们已经建立了基本机制的心理模型,在第 2 部分中,我们将深入研究一致性模型。
活动推荐
阿里云基于 Apache Flink 构建的企业级产品-实时计算 Flink 版现开启活动:
新用户复制下方链接或者扫描二维码即可0元免费试用 Flink + Paimon
了解活动详情:https://free.aliyun.com/?pipCode=sc
▼ 关注「Apache Flink」,获取更多技术干货 ▼