首先来快速了解一下不同的数据湖。
Paimon
Apache Paimon还是孵化状态的一个数据湖。这是其官网介绍Paimon的架构图,从图里我们可以看到,
Paimon的写入支持从数据库的变更日志(CDC,Change Data Capture)以流模式同步数据,或从离线数据批量插入/覆盖写。
而读取支持从历史快照中以批处理模式消费数据,从最新的偏移量以流模式消费数据,或以混合方式读取增量快照。
同时,Paimon的存储是基于LSM存储Paimon 将列式文件存储在文件系统/对象存储上的。
Paimon由于是从Flink社区孵化出来的,所以很多设计理念是与flink更为切合一点的。其很多设计都是为了高效的流式读写。
基础概念
Paimon有一些与hudi类似的概念。比如分区概念,也可以指定一个分区列,作为分区键。除此之外,paimon还有桶的概念,桶类似于hudi的二级分区概念,如果没有分区,那么文件会直接分桶,否则则在分区内分桶。同时paimon也是通过快照保证一致性的。
文件布局
从数据湖产品的文件布局,我们就可以直观的感受到其数据读写的流程。所以会对每个数据湖的文件布局进行介绍。
paimon的文件布局概念与iceberg非常相似。
首先有一个快照版本的概念,每一个快照版本对应着一个schema和一系列manifest list。每个manifest list包含着不同的基础文件,这样就将每个快照版本与schema和基础文件对应上了。
paimon一个主要的点在于,data file里使用了LSM Tree的结构,每个bucket都包含一个LSM Tree,在保证了高效读取的情况下提高了写入效率。LSM Tree关键点在于每个sorted runs里面包含一个或多个由主键排序的数据文件,且范围互相不重叠。同时可以对sorted runs执行compaction操作,减少小文件个数。
这里我们用一个例子说明文件结构:
create table my_table (
k int,
v string
) USING paimon
tblproperties (
'primary-key' = 'k'
) ;
INSERT INTO my_table VALUES (1, 'Hi'), (2, 'Hello');
这时文件结构如下:
snapshot里有一个LATEST和EARLIEST文件指向最新和最早的snapshot,而我们刚刚的提交生成了一个snapshot-1文件,其内容如下:
{
"version" : 3,
"id" : 1,
"schemaId" : 0,
"baseManifestList" : "manifest-list-670d4762-9620-4ddc-991e-36a0e9c89b29-0",
"deltaManifestList" : "manifest-list-670d4762-9620-4ddc-991e-36a0e9c89b29-1",
"changelogManifestList" : null,
"indexManifest" : "index-manifest-834ca29b-4007-4e33-895e-d60171c59f36-0",
"commitUser" : "0465b4ad-d20b-4e1a-9316-881816604f56",
"commitIdentifier" : 9223372036854775807,
"commitKind" : "APPEND",
"timeMillis" : 1708427871282,
"logOffsets" : { },
"totalRecordCount" : 2,
"deltaRecordCount" : 2,
"changelogRecordCount" : 0,
"watermark" : null
}
而schema文件则是指向了当前表的schema。
manifest里面有三个文件,其中有两个list文件和一个manifest文件,其中manifest-list-670d4762-9620-4ddc-991e-36a0e9c89b29-0是被最早的snapshot指向的文件,所以内容是空的,此时还没有写入。
当有新的写入产生时,就产生了新的版本的list文件manifest-list-670d4762-9620-4ddc-991e-36a0e9c89b29-1,其内容如下:
{'_VERSION': 2, '_FILE_NAME': 'manifest-1d2e0917-710a-48be-b4c2-2237b6e5dc61-0', '_FILE_SIZE': 1757, '_NUM_ADDED_FILES': 2, '_NUM_DELETED_FILES': 0, '_PARTITION_STATS':省略}
其指向的manifest文件manifest-1d2e0917-710a-48be-b4c2-2237b6e5dc61-0内容如下:
{'_VERSION': 2, '_KIND': 0, '_PARTITION': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', '_BUCKET': 3, '_TOTAL_BUCKETS': -1, '_FILE': {'_FILE_NAME': 'data-81f20858-6e8d-4c80-be1b-0a3fc2f1ea80-0.orc', '_FILE_SIZE': 532, '_ROW_COUNT': 1, 省略后面}
{'_VERSION': 2, '_KIND': 0, '_PARTITION': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', '_BUCKET': 9, '_TOTAL_BUCKETS': -1, '_FILE': {'_FILE_NAME': 'data-3d0ce932-67e8-4bcc-b3fc-7fcf662e6cdc-0.orc', '_FILE_SIZE': 553, '_ROW_COUNT': 1,省略后面}
可以看到manifest文件里面记录了分区、bucket和文件名等信息,通过这些信息,就可以直接去对应位置找到对应的文件。
不同的bucket里面对应的就是我们最终的数据文件了。
这里还有一个index目录,这个是将bucket设置为动态模式才会出现的文件,使用了哈希索引存储了哈希编码和桶的对应关系,目的是帮助我们快速确定哪个键对应哪个桶。
Paimon表
paimon表分为主键表和非主键表。主要区别是是否指定主键。指定主键的表,支持插入、更新和删除,同时会强制在bucket内根据主键排序。而非主键表则只会append,不会去重和排序。同时非分区表如果没有设置分桶键的话,会根据所有列进行哈希,效率较低,建议设置分桶键。
Paimon的主键表支持如下几种分桶模式:
固定桶
也就是固定桶的数量,然后通过哈希的方式将记录存到对应的桶。
动态桶
正常动态桶模式
当更新没有跨越分区时同样使用哈希来进行分桶。(没有分区或主键包含所有分区键)
跨分区Upsert动态桶模式
维护键到分区和桶的映射的模式,同时在不同的合并策略下会有不同的行为。
合并引擎
不同的合并引擎会有不同的合并策略,可以在建表时通过指定’merge-engine’ = 'xxxx’来决定。spark中只支持去重和部分更新。
去重
如字面意思,遇到主键重复则会直接覆盖旧的记录。
部分更新
每次更新可以只指定部分列,其他的为null。这样下次在对这列更新非null的值就会更新这一列。同时这次更新指定为null的列则不会被更新。
序列组
在部分更新的情况下,也可以对列进行分组并指定版本。官方文档给的一个例子,可以看到a、b被分为一组,其版本为g_1,c、d被分为一组,其版本为g_2:
CREATE TABLE T (
k INT,
a INT,
b INT,
g_1 INT,
c INT,
d INT,
g_2 INT,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine'='partial-update',
'fields.g_1.sequence-group'='a,b',
'fields.g_2.sequence-group'='c,d'
);
INSERT INTO T VALUES (1, 1, 1, 1, 1, 1, 1);
-- g_2 is null, c, d should not be updated
INSERT INTO T VALUES (1, 2, 2, 2, 2, 2, CAST(NULL AS INT));
SELECT * FROM T; -- output 1, 2, 2, 2, 1, 1, 1
-- g_1 is smaller, a, b should not be updated
INSERT INTO T VALUES (1, 3, 3, 1, 3, 3, 3);
SELECT * FROM T; -- output 1, 2, 2, 2, 3, 3, 3
聚合函数
序列组里也可以指定聚合函数,如例子:
在这里,b的版本是a,d的版本是c。同时b的值为第一个value,d的值为sum。
CREATE TABLE T (
k INT,
a INT,
b INT,
c INT,
d INT,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine'='partial-update',
'fields.a.sequence-group' = 'b',
'fields.b.aggregate-function' = 'first_value',
'fields.c.sequence-group' = 'd',
'fields.d.aggregate-function' = 'sum'
);
INSERT INTO T VALUES (1, 1, 1, CAST(NULL AS INT), CAST(NULL AS INT));
-- 1, 1, 1, null, null
INSERT INTO T VALUES (1, CAST(NULL AS INT), CAST(NULL AS INT), 1, 1);
-- 1, 1, 1, 1, 1
INSERT INTO T VALUES (1, 2, 2, CAST(NULL AS INT), CAST(NULL AS INT));
-- 1, 2, 1, 1, 1
INSERT INTO T VALUES (1, CAST(NULL AS INT), CAST(NULL AS INT), 2, 2);
-- 1, 2, 1, 2, 3
SELECT * FROM T; -- output 1, 2, 1, 2, 3
首行模式
这个模式下只会在文件中保留每个主键的第一行记录,其余修改只会生成changelog。
changelog
changelog是应用于流读场景的。主要是把对数据的更改全部记录下来,然后在流读时可以按照更改顺序进行读取。
主要分为四个模式:
None
这个是默认情况,即没有额外的日志生产者,Paimon源只能看到跨快照的合并变化,比如哪些键被移除以及一些键的新值。这种模式下会产生不完整的log,导致流读不准确。
input
这个是输入源本身就是changelog的情况,比如binlog,可以直接转为changelog代价较低。
lookup
这个是在没有输入的changelog同时还想要完整的changelog提供的工具,通过这个模式,可以对变更进行比较并生成changelog。代价较高。
Full Compaction
在full compaction之间比较结果并生成差异作为changelog。我理解这个就是延迟更高的lookup,牺牲实时性减少了生成changelog的代价。
Delta Lake
从delta lake的架构可以看出,Delta Lake是一个用于大数据处理和分析的存储层。支持ACID事务。数据来源可以是流式数据(Streaming)或者批次处理的数据(Batch)。随后经过delta的处理,可以直接用于分析或者机器学习。同时Delta Lake与多种数据处理和分析工具以及云服务提供商集成,包括Apache Spark、Presto、Apache Flink等数据处理工具,以及Azure、Amazon Web Services、Google Cloud、IBM Cloud等云服务平台。这意味着Delta Lake能够在多种不同的环境和工具中使用,提供了很好的灵活性和扩展性。
文件布局
delta的文件布局相对来说非常简洁。其一个表的目录下面只有两个部分,分别是数据文件和delta logs。
数据文件即parquet文件(如果是分区表则是各个分区目录及parquet),delta log是每次提交的元数据文件,里面是json文件及压缩的parquet文件。
以一个例子来说明其文件布局:
首先在/tmp/delta-table路径下创建一个表,并写入0,1,2,3,4,5五个值
CREATE TABLE delta.`/tmp/delta-table` USING DELTA AS SELECT col1 as id FROM VALUES 0,1,2,3,4;
此时文件结构如下:
此时生成了五个数据文件,分别对应着id=0,1,2,3,4.
而json里的内容为:
里面保存了这次提交的信息以及add了哪些文件,每个文件每个列的最大最小值及空值。
{"commitInfo":{"timestamp":1708502985156,"operation":"CREATE TABLE AS SELECT","operationParameters":{"isManaged":"false","description":null,"partitionBy":"[]","properties":"{}"},"isolationLevel":"Serializable","isBlindAppend":true,"operationMetrics":{"numFiles":"5","numOutputRows":"5","numOutputBytes":"2260"},"engineInfo":"Apache-Spark/3.3.0 Delta-Lake/2.3.0","txnId":"a29274fd-8c47-426c-8b6f-4ec289d6cc00"}}
{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}
{"metaData":{"id":"4feebd5e-0135-4542-ace1-c85256af6e18","format":{"provider":"parquet","options":{}},"schemaString":"{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}}]}","partitionColumns":[],"configuration":{},"createdTime":1708502984659}}
{"add":{"path":"part-00000-ca129d03-e5b9-4f44-97bb-fd3fec574e23-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708502985150,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":0},\"maxValues\":{\"id\":0},\"nullCount\":{\"id\":0}}"}}
{"add":{"path":"part-00001-a278b133-afe8-40fd-ab4a-0f8cdfb13a7b-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708502985129,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":1},\"maxValues\":{\"id\":1},\"nullCount\":{\"id\":0}}"}}
{"add":{"path":"part-00002-582bccf1-9290-4b6f-bdeb-fea6c87dfc3b-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708502985144,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":2},\"maxValues\":{\"id\":2},\"nullCount\":{\"id\":0}}"}}
{"add":{"path":"part-00003-97e10202-6aa1-4942-b2ea-dc09b046ee79-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708502985144,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":3},\"maxValues\":{\"id\":3},\"nullCount\":{\"id\":0}}"}}
{"add":{"path":"part-00004-17346b32-4be2-40dd-8a5a-6477ec401cd7-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708502985144,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":4},\"maxValues\":{\"id\":4},\"nullCount\":{\"id\":0}}"}}
此时我们执行更新语句,即对偶数的id加100,此时应该更新三个值:0->100, 2->102, 4->104
UPDATE delta.`/tmp/delta-table` SET id = id + 100 WHERE id % 2 == 0;
此时文件结构如下:
1.json显示了被更新的三个文件被remove掉,同时add了三个新的文件。
{"commitInfo":{"timestamp":1708572789395,"operation":"UPDATE","operationParameters":{"predicate":"((id#1923 % 2) = 0)"},"readVersion":0,"isolationLevel":"Serializable","isBlindAppend":false,"operationMetrics":{"numRemovedFiles":"3","numRemovedBytes":"1356","numCopiedRows":"0","numAddedChangeFiles":"0","executionTimeMs":"1770","scanTimeMs":"1550","numAddedFiles":"3","numUpdatedRows":"3","numAddedBytes":"1355","rewriteTimeMs":"220"},"engineInfo":"Apache-Spark/3.3.0 Delta-Lake/2.3.0","txnId":"f29a4b82-06c3-4836-aadc-0afc3a4e978e"}}
{"remove":{"path":"part-00002-582bccf1-9290-4b6f-bdeb-fea6c87dfc3b-c000.snappy.parquet","deletionTimestamp":1708572789395,"dataChange":true,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
{"remove":{"path":"part-00004-17346b32-4be2-40dd-8a5a-6477ec401cd7-c000.snappy.parquet","deletionTimestamp":1708572789395,"dataChange":true,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
{"remove":{"path":"part-00000-ca129d03-e5b9-4f44-97bb-fd3fec574e23-c000.snappy.parquet","deletionTimestamp":1708572789395,"dataChange":true,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
{"add":{"path":"part-00000-dba7dd7a-7ac4-4b8f-81ab-4b559f4c27b9-c000.snappy.parquet","partitionValues":{},"size":451,"modificationTime":1708572789377,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":102},\"maxValues\":{\"id\":102},\"nullCount\":{\"id\":0}}"}}
{"add":{"path":"part-00001-3edd4760-c859-4052-ae8a-0e97a9b93bbd-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708572789393,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":104},\"maxValues\":{\"id\":104},\"nullCount\":{\"id\":0}}"}}
{"add":{"path":"part-00002-93af4d60-2356-438f-bd88-97eeacfd1b0b-c000.snappy.parquet","partitionValues":{},"size":452,"modificationTime":1708572789388,"dataChange":true,"stats":"{\"numRecords\":1,\"minValues\":{\"id\":100},\"maxValues\":{\"id\":100},\"nullCount\":{\"id\":0}}"}}
这里我们也可以发现元数据日志是连续自增的数字.json,这是因为delta是支持snapshot读的,这个日志的前缀就代表了snapshot的版本。我们读取某个snapshot时只需要见到这个版本之前的所有元数据日志即可。所以这个前缀必须是连续自增的。
性能优化
在介绍delta lake的性能优化前,先简要介绍一下delta的查询原理:
从上文可以得知,delta每次提交都会生成元数据日志。所以delta的查询可以分解为以下步骤:
-
查询解析——计算引擎将查询解析成需要执行的操作
-
读取事务日志——这一步就是读取上文提到的delta log,将所有查询版本可见的delta log读取出来。
-
构建快照——根据读出的delta log将所有元数据操作进行合并,得到当前的有效文件列表作为当前版本快照。
-
data skipping——这一步理论上来说,我们可以根据查询条件和delta log里的文件信息对文件进行跳过。
-
返回所有有效的数据文件到计算引擎。
所以,delta的查询主要耗时的部分是读取事务日志部分。而delta对于这部分内容,也做了一些优化,提高其查询效率。
Compaction
为了减少小文件的数量,减少文件IO,delta提供了compaction合并小文件的功能。可以通过手动或自动调用的方式将小文件合并成指定的大小。
如上面的例子里,我们插入五个值生成了五个文件,这时手动触发compaction:
OPTIMIZE delta.`/tmp/delta-table`;
此时文件结构如下:
可以看到新生成了一个数据文件和元数据日志。
其中元数据日志内容如下:
{"commitInfo":{"timestamp":1708589482786,"operation":"OPTIMIZE","operationParameters":{"predicate":"[]","zOrderBy":"[]"},"readVersion":1,"isolationLevel":"SnapshotIsolation","isBlindAppend":false,"operationMetrics":{"numRemovedFiles":"5","numRemovedBytes":"2259","p25FileSize":"469","numDeletionVectorsRemoved":"0","minFileSize":"469","numAddedFiles":"1","maxFileSize":"469","p75FileSize":"469","p50FileSize":"469","numAddedBytes":"469"},"engineInfo":"Apache-Spark/3.3.0 Delta-Lake/2.3.0","txnId":"4304f694-276b-470a-8050-82c9a0ccd6e9"}}
{"add":{"path":"part-00000-16ad5d95-b321-4c78-a305-7ddde64147d3-c000.snappy.parquet","partitionValues":{},"size":469,"modificationTime":1708589482777,"dataChange":false,"stats":"{\"numRecords\":5,\"minValues\":{\"id\":1},\"maxValues\":{\"id\":104},\"nullCount\":{\"id\":0}}"}}
{"remove":{"path":"part-00000-dba7dd7a-7ac4-4b8f-81ab-4b559f4c27b9-c000.snappy.parquet","deletionTimestamp":1708589481295,"dataChange":false,"extendedFileMetadata":true,"partitionValues":{},"size":451}}
{"remove":{"path":"part-00002-93af4d60-2356-438f-bd88-97eeacfd1b0b-c000.snappy.parquet","deletionTimestamp":1708589481295,"dataChange":false,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
{"remove":{"path":"part-00001-a278b133-afe8-40fd-ab4a-0f8cdfb13a7b-c000.snappy.parquet","deletionTimestamp":1708589481295,"dataChange":false,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
{"remove":{"path":"part-00001-3edd4760-c859-4052-ae8a-0e97a9b93bbd-c000.snappy.parquet","deletionTimestamp":1708589481295,"dataChange":false,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
{"remove":{"path":"part-00003-97e10202-6aa1-4942-b2ea-dc09b046ee79-c000.snappy.parquet","deletionTimestamp":1708589481295,"dataChange":false,"extendedFileMetadata":true,"partitionValues":{},"size":452}}
可以看到remove了五个文件,同时add了一个文件。我们查看这个add的文件,内容符合我们之前的预期:
Data Skipping
当写入数据时,最大值和最小值会记录在对应的元数据日志里。查询时,可以根据这些信息去跳过一些文件,提高查询效率。
Z-Order
虽然有了data skipping 的能力,但是如果数据布局不好的话,也依然无法获得较好的data skipping效果,所以delta lake提供了z-order的能力。z-order是一种聚合排序,我们可以近似将其理解为mysql的最左前缀索引即可。
Checkpoint
Delta Lake支持通过Checkpoint操作将事务日志合并为parquet文件,为了避免事务日志变得过于庞大,Delta Lake 定期创建checkpoint文件。这些checkpoint文件是Parquet格式的,并且包含了到某个时刻为止所有数据的当前状态。因此,Delta Lake 可以从最近的checkpoint开始读取,而不是从头开始读取所有日志文件。这极大地减少了需要处理的日志数量。
检查点的类型:
1.基于UUID命名的检查点:使用V2规范,文件名类似n.checkpoint.u.{json/parquet},其中u是UUID,n是快照版本号。
2.经典检查点:文件名类似n.checkpoint.parquet,符合V1或V2规范。
3.多部分检查点:分成多个文件,每个文件名类似n.checkpoint.o.p.parquet,这些总是V1检查点。
接下来我将多次更新数据来触发检查点:
UPDATE delta.`/tmp/delta-table` SET id = id + 100 WHERE id % 2 == 0;
在执行了10次这个命令时,触发了检查点操作
而检查点内容大致如下,是对这个检查点之前的json文件的一个压缩。
同时这里也多了一个_last_checkpoint文件,这个是指向最新的检查点文件的,这让我们不用遍历可以直接拿到最新的检查点。
Iceberg
Iceberg是Netflix发起的一个用于大数据集的开放表格式。Iceberg 为包括 Spark、Trino、PrestoDB、Flink、Hive 和 Impala 在内的计算引擎提供了表格式支持。
文件布局
Iceberg的文件布局看起来与Paimon是比较相似的。每个table都有一个最新的元数据版本,被current metadata pointer指向。在元数据文件里,除了表的schema信息以外,还有包含的snapshot版本信息等其他重要信息。
下一层是元数据层,由一系列的元数据文件组成,被称为Snapshot(快照)。每个快照文件(s0, s1等)都是表在特定点的状态,它包含了指向manifest文件列表的指针。当对表进行更改(如添加、删除数据)时,就会生成新的快照文件。快照文件中的manifest列表是连接元数据和实际数据的桥梁。每个manifest文件都记录了数据文件列表,包括对实际存储在数据层中数据文件的指针。最底层是数据层,这里存放的是实际的数据文件。这些是存储在分布式文件系统(如HDFS)或对象存储(如S3)中的物理文件,它们包含了表的实际行数据。
例子:
-- 首先切换iceberg catalog,这个是启动spark时设置的local为iceberg
USE local;
-- 创建表
CREATE TABLE demo.nyc.taxis
(
vendor_id bigint,
trip_id bigint,
trip_distance float,
fare_amount double,
store_and_fwd_flag string
)
PARTITIONED BY (vendor_id);
-- 插入数据
INSERT INTO demo.nyc.taxis
VALUES (1, 1000371, 1.8, 15.32, 'N'), (2, 1000372, 2.5, 22.15, 'N'), (2, 1000373, 0.9, 9.01, 'N'), (1, 1000374, 8.4, 42.13, 'Y');
此时文件结构:
可以看到有两个目录,分别是数据目录和元数据目录。我们依照上面的图去介绍这个文件:
首先是当前版本的指针version-hint.text
其文件内容只有一个2,代表着当前最新版本的元数据是v2.metadata.json
{
"format-version" : 2,
"table-uuid" : "8c1f890f-fa1f-49eb-a852-00f5977e940a",
"location" : "/Users/majian/code/spark-3.3.0-bin-hadoop3/bin/warehouse/demo/nyc/taxis",
"last-sequence-number" : 1,
"last-updated-ms" : 1708654681091,
"last-column-id" : 5,
"current-schema-id" : 0,
"schemas" : [ {
"type" : "struct",
"schema-id" : 0,
"fields" : [ {
"id" : 1,
"name" : "vendor_id",
"required" : false,
"type" : "long"
}, {
"id" : 2,
"name" : "trip_id",
"required" : false,
"type" : "long"
}, {
"id" : 3,
"name" : "trip_distance",
"required" : false,
"type" : "float"
}, {
"id" : 4,
"name" : "fare_amount",
"required" : false,
"type" : "double"
}, {
"id" : 5,
"name" : "store_and_fwd_flag",
"required" : false,
"type" : "string"
} ]
} ],
"default-spec-id" : 0,
"partition-specs" : [ {
"spec-id" : 0,
"fields" : [ {
"name" : "vendor_id",
"transform" : "identity",
"source-id" : 1,
"field-id" : 1000
} ]
} ],
"last-partition-id" : 1000,
"default-sort-order-id" : 0,
"sort-orders" : [ {
"order-id" : 0,
"fields" : [ ]
} ],
"properties" : {
"owner" : "majian",
"write.parquet.compression-codec" : "zstd"
},
"current-snapshot-id" : 5503255007860479330,
"refs" : {
"main" : {
"snapshot-id" : 5503255007860479330,
"type" : "branch"
}
},
"snapshots" : [ {
"sequence-number" : 1,
"snapshot-id" : 5503255007860479330,
"timestamp-ms" : 1708654681091,
"summary" : {
"operation" : "append",
"spark.app.id" : "local-1708654034957",
"added-data-files" : "2",
"added-records" : "4",
"added-files-size" : "3074",
"changed-partition-count" : "2",
"total-records" : "4",
"total-files-size" : "3074",
"total-data-files" : "2",
"total-delete-files" : "0",
"total-position-deletes" : "0",
"total-equality-deletes" : "0"
},
"manifest-list" : "/Users/majian/code/spark-3.3.0-bin-hadoop3/bin/warehouse/demo/nyc/taxis/metadata/snap-5503255007860479330-1-e083c429-ece5-45f6-b736-0bdb9dc8fcf6.avro",
"schema-id" : 0
} ],
"statistics" : [ ],
"snapshot-log" : [ {
"timestamp-ms" : 1708654681091,
"snapshot-id" : 5503255007860479330
} ],
"metadata-log" : [ {
"timestamp-ms" : 1708654666297,
"metadata-file" : "/Users/majian/code/spark-3.3.0-bin-hadoop3/bin/warehouse/demo/nyc/taxis/metadata/v1.metadata.json"
} ]
}
这个json里 “manifest-list” key指向了当前的snapshot版本,这也就是上图里metadata里包含的s0,s1等内容。这个metadata里只包含了snap-5503255007860479330这个snapshot。
这时我们看snap-5503255007860479330-1-e083c429-ece5-45f6-b736-0bdb9dc8fcf6.avro的内容:
{'manifest_path': '/Users/majian/code/spark-3.3.0-bin-hadoop3/bin/warehouse/demo/nyc/taxis/metadata/e083c429-ece5-45f6-b736-0bdb9dc8fcf6-m0.avro', 'manifest_length': 7261, 'partition_spec_id': 0, 'content': 0, 'sequence_number': 1, 'min_sequence_number': 1, 'added_snapshot_id': 5503255007860479330, 'added_data_files_count': 2, 'existing_data_files_count': 0, 'deleted_data_files_count': 0, 'added_rows_count': 4, 'existing_rows_count': 0, 'deleted_rows_count': 0, 'partitions': [{'contains_null': False, 'contains_nan': False, 'lower_bound': b'\x01\x00\x00\x00\x00\x00\x00\x00', 'upper_bound': b'\x02\x00\x00\x00\x00\x00\x00\x00'}]}
里面指向了当前manifest list包含的manifest file。
这里也就指向了我们写入生成的manifest file e083c429-ece5-45f6-b736-0bdb9dc8fcf6-m0.avro
可以看到,manifest file里包含了指向的data file,以及对应data file的空值、最大最小值信息。通过这些信息,我们可以进行data skipping。
{'status': 1, 'snapshot_id': 5503255007860479330, 'sequence_number': None, 'file_sequence_number': None, 'data_file': {'content': 0, 'file_path': '/Users/majian/code/spark-3.3.0-bin-hadoop3/bin/warehouse/demo/nyc/taxis/data/vendor_id=1/00069-4-36d984a2-249b-4aca-9d6f-abb1f635d646-00001.parquet', 'file_format': 'PARQUET', 'partition': {'vendor_id': 1}, 'record_count': 2, 'file_size_in_bytes': 1516, 'column_sizes': [{'key': 1, 'value': 70}, {'key': 2, 'value': 48}, {'key': 3, 'value': 40}, {'key': 4, 'value': 48}, {'key': 5, 'value': 42}], 'value_counts': [{'key': 1, 'value': 2}, {'key': 2, 'value': 2}, {'key': 3, 'value': 2}, {'key': 4, 'value': 2}, {'key': 5, 'value': 2}], 'null_value_counts': [{'key': 1, 'value': 0}, {'key': 2, 'value': 0}, {'key': 3, 'value': 0}, {'key': 4, 'value': 0}, {'key': 5, 'value': 0}], 'nan_value_counts': [{'key': 3, 'value': 0}, {'key': 4, 'value': 0}], 'lower_bounds': [{'key': 1, 'value': b'\x01\x00\x00\x00\x00\x00\x00\x00'}, {'key': 2, 'value': b'\xb3C\x0f\x00\x00\x00\x00\x00'}, {'key': 3, 'value': b'ff\xe6?'}, {'key': 4, 'value': b'\xa4p=\n\xd7\xa3.@'}, {'key': 5, 'value': b'N'}], 'upper_bounds': [{'key': 1, 'value': b'\x01\x00\x00\x00\x00\x00\x00\x00'}, {'key': 2, 'value': b'\xb6C\x0f\x00\x00\x00\x00\x00'}, {'key': 3, 'value': b'ff\x06A'}, {'key': 4, 'value': b'q=\n\xd7\xa3\x10E@'}, {'key': 5, 'value': b'Y'}], 'key_metadata': None, 'split_offsets': [4], 'equality_ids': None, 'sort_order_id': 0}}
{'status': 1, 'snapshot_id': 5503255007860479330, 'sequence_number': None, 'file_sequence_number': None, 'data_file': {'content': 0, 'file_path': '/Users/majian/code/spark-3.3.0-bin-hadoop3/bin/warehouse/demo/nyc/taxis/data/vendor_id=2/00128-5-36d984a2-249b-4aca-9d6f-abb1f635d646-00001.parquet', 'file_format': 'PARQUET', 'partition': {'vendor_id': 2}, 'record_count': 2, 'file_size_in_bytes': 1558, 'column_sizes': [{'key': 1, 'value': 70}, {'key': 2, 'value': 48}, {'key': 3, 'value': 40}, {'key': 4, 'value': 48}, {'key': 5, 'value': 67}], 'value_counts': [{'key': 1, 'value': 2}, {'key': 2, 'value': 2}, {'key': 3, 'value': 2}, {'key': 4, 'value': 2}, {'key': 5, 'value': 2}], 'null_value_counts': [{'key': 1, 'value': 0}, {'key': 2, 'value': 0}, {'key': 3, 'value': 0}, {'key': 4, 'value': 0}, {'key': 5, 'value': 0}], 'nan_value_counts': [{'key': 3, 'value': 0}, {'key': 4, 'value': 0}], 'lower_bounds': [{'key': 1, 'value': b'\x02\x00\x00\x00\x00\x00\x00\x00'}, {'key': 2, 'value': b'\xb4C\x0f\x00\x00\x00\x00\x00'}, {'key': 3, 'value': b'fff?'}, {'key': 4, 'value': b'\x85\xebQ\xb8\x1e\x05"@'}, {'key': 5, 'value': b'N'}], 'upper_bounds': [{'key': 1, 'value': b'\x02\x00\x00\x00\x00\x00\x00\x00'}, {'key': 2, 'value': b'\xb5C\x0f\x00\x00\x00\x00\x00'}, {'key': 3, 'value': b'\x00\x00 @'}, {'key': 4, 'value': b'fffff&6@'}, {'key': 5, 'value': b'N'}], 'key_metadata': None, 'split_offsets': [4], 'equality_ids': None, 'sort_order_id': 0}}
Hudi
Apache Hudi是一个开源的数据湖平台,旨在简化对大型数据集的增量处理和流处理,以实现高效的存储管理和更快的数据处理。Hudi同时支持流处理和批处理,做到低延迟、即时可查询的分析。Hudi可以与各种数据源和存储服务集成,包括Apache Kafka、Amazon S3等。数据可以通过数据流、数据库变更捕获或批量数据加载的方式导入到Hudi。Hudi还支持ACID事务和快照隔离,使得对数据变更进行跟踪和处理更加容易和准确。
Hudi兼容包括Presto、Apache Spark、Apache Hive及各种云平台。此外,Hudi也能够与元存储(Metastore)集成,并且可以使用各种调度工具(如Airflow)来自动化数据处理流程。
数据布局
Hudi的文件也像Delta Lake一样,主要分为两个文件夹,分别是数据文件和元数据文件。
-
目录结构:Hudi在分布式文件系统(如HDFS)上将数据表组织成特定的目录结构。这个结构从一个基本路径开始,基本路径可以被认为是整个Hudi表的根目录。
-
分区:为了更好地管理和查询数据,Hudi表通常会被分割成多个分区(Partitions)。分区是数据组织的逻辑分组,通常基于某些关键字段,如日期或地区等。每个分区对应于文件系统中的一个目录。
-
File Group和File ID:在每个分区中,数据被进一步组织成File Group。每个File Group都由一个唯一的File ID标识。File Group是一个逻辑概念,表示一组在逻辑上相关联的文件,它们共同表示了表中的一部分数据。
-
File Slice:每个文件组包含多个File Slice。File Slice是文件组中的一个版本,代表了在一个特定时间点的数据视图。
-
基础文件和日志文件:每个文件片段由一个基础文件和若干日志文件组成。基础文件(通常是Parquet或ORC格式,取决于配置项
hoodie.table.base.file.format
)包含了某个提交(Commit)或压缩(Compaction)时的完整数据快照。而日志文件(.log文件)则存储了自基础文件生成以来的所有数据变更,包括插入和更新。 -
元数据文件:.hoodie目录存储于Hudi表的根目录下,包含了控制和管理Hudi表状态的元数据文件,例如记录提交历史、回滚和清理操作的文件。通过元数据文件,我们可以知道哪些版本的文件是有效的。同时,一些例如检查点、索引等相关文件也会存在元数据目录下。
在Hudi的文件布局中,可以看到在不同的文件组中会有不同版本的文件片段。随着数据的更新,新的日志文件被追加到文件组中,或通过压缩操作合并日志文件和基础文件来创建新的文件片段。清理操作最终会清除过时的或未使用的文件片段。这整个流程保证了数据的完整性和查询效率,同时也优化了存储使用。
接下来也依然是一个例子让我们了解hudi的文件布局:
-- create a Hudi table that is partitioned.
CREATE TABLE hudi_table (
ts BIGINT,
uuid STRING,
rider STRING,
driver STRING,
fare DOUBLE,
city STRING
) USING HUDI
PARTITIONED BY (city);
INSERT INTO hudi_table
VALUES
(1695159649087,'334e26e9-8355-45cc-97c6-c31daf0df330','rider-A','driver-K',19.10,'san_francisco'),
(1695091554788,'e96c4396-3fad-413a-a942-4cb36106d721','rider-C','driver-M',27.70 ,'san_francisco'),
(1695046462179,'9909a8b1-2d15-4d3d-8ec9-efc48c536a00','rider-D','driver-L',33.90 ,'san_francisco'),
(1695332066204,'1dced545-862b-4ceb-8b43-d2a568f6616b','rider-E','driver-O',93.50,'san_francisco'),
(1695516137016,'e3cf430c-889d-4015-bc98-59bdce1e530c','rider-F','driver-P',34.15,'sao_paulo' ),
(1695376420876,'7a84095f-737f-40bc-b62f-6b69664712d2','rider-G','driver-Q',43.40 ,'sao_paulo' ),
(1695173887231,'3eeb61f7-c2b0-4636-99bd-5d7a5a1d2c04','rider-I','driver-S',41.06 ,'chennai' ),
(1695115999911,'c8abbe79-8d89-47ea-b4ce-4d224bae5bfa','rider-J','driver-T',17.85,'chennai');
测试
测试环境:
阿里云ecs.r7.2xlarge 8核(vCPU) 64 GiB
系统镜像:centos_7_9_x64_20G_alibase_20240125.vhd
Spark版本:3.3.0
Hudi版本:0.14.1
Paimon版本:0.8
Delta Lake版本:2.3.0
Iceberg版本:1.4.3
python版本:3.9
通过Spark local模式测试四个数据湖在tpc-ds数据集上的加载数据和查询性能。
其中spark设置了driver 8core 内存32G
为了简化操作并避免类型问题,tpc-ds所有的数据类型被设置为了String。
Paimon使用的append表。
其中TPC-DS的q72由于是多表连接,在数据量增大时执行过于缓慢(最慢数个小时),所以移出了测试。
测试1:10G数据 数据湖默认配置
数据导入性能
查询性能
平均值 | 中位数 | 95% | 最大值 | 最小值 | |
---|---|---|---|---|---|
Hudi | 7.75s | 4.43s | 25.64s | 29.22s | 0.23s |
Delta | 8.58s | 5.45s | 27.12s | 30.92s | 0.73s |
Iceberg | 11.88s | 6.91s | 37.56s | 88.10s | 0.22s |
Paimon | 9.78s | 6.61s | 29.79s | 35.75s | 0.35s |
可以看到,在这组测试下,Hudi的整体查询性能比较好,但数据导入性能比较低。而iceberg虽然数据导入最快,但查询却是最慢的。delta的查询和写入处于都比较不错的水平。
测试2:50G数据 数据湖默认配置
数据导入性能
查询性能
平均值 | 中位数 | 95% | 最大值 | 最小值 | |
---|---|---|---|---|---|
Hudi | 29.97s | 15.09s | 125.46s | 154.81s | 0.22s |
Delta | 31.00s | 16.17s | 126.22s | 161.50s | 0.75s |
Iceberg | 56.66s | 23.41s | 169.24s | 605.54s | 0.21s |
Paimon | 39.91s | 23.97s | 133.40s | 267.90s | 0.36s |