Paimon&Iceberg&Hudi&Delta Lake原理学习+开箱性能自测

首先来快速了解一下不同的数据湖。

Paimon

image.png

Apache Paimon还是孵化状态的一个数据湖。这是其官网介绍Paimon的架构图,从图里我们可以看到,

Paimon的写入支持从数据库的变更日志(CDC,Change Data Capture)以流模式同步数据,或从离线数据批量插入/覆盖写。

而读取支持从历史快照中以批处理模式消费数据,从最新的偏移量以流模式消费数据,或以混合方式读取增量快照。

同时,Paimon的存储是基于LSM存储Paimon 将列式文件存储在文件系统/对象存储上的。

Paimon由于是从Flink社区孵化出来的,所以很多设计理念是与flink更为切合一点的。其很多设计都是为了高效的流式读写。

基础概念

Paimon有一些与hudi类似的概念。比如分区概念,也可以指定一个分区列,作为分区键。除此之外,paimon还有桶的概念,桶类似于hudi的二级分区概念,如果没有分区,那么文件会直接分桶,否则则在分区内分桶。同时paimon也是通过快照保证一致性的。

文件布局

从数据湖产品的文件布局,我们就可以直观的感受到其数据读写的流程。所以会对每个数据湖的文件布局进行介绍。

image.png

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');

这时文件结构如下:

image.png

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

image.png

从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能够在多种不同的环境和工具中使用,提供了很好的灵活性和扩展性。

文件布局

image.png

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;

此时文件结构如下:

image.png

此时生成了五个数据文件,分别对应着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;

此时文件结构如下:

image.png

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的查询可以分解为以下步骤:

  1. 查询解析——计算引擎将查询解析成需要执行的操作

  2. 读取事务日志——这一步就是读取上文提到的delta log,将所有查询版本可见的delta log读取出来。

  3. 构建快照——根据读出的delta log将所有元数据操作进行合并,得到当前的有效文件列表作为当前版本快照。

  4. data skipping——这一步理论上来说,我们可以根据查询条件和delta log里的文件信息对文件进行跳过。

  5. 返回所有有效的数据文件到计算引擎。

所以,delta的查询主要耗时的部分是读取事务日志部分。而delta对于这部分内容,也做了一些优化,提高其查询效率。

Compaction

为了减少小文件的数量,减少文件IO,delta提供了compaction合并小文件的功能。可以通过手动或自动调用的方式将小文件合并成指定的大小。

如上面的例子里,我们插入五个值生成了五个文件,这时手动触发compaction:

OPTIMIZE delta.`/tmp/delta-table`;

此时文件结构如下:

image.png

可以看到新生成了一个数据文件和元数据日志。

其中元数据日志内容如下:

{"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的文件,内容符合我们之前的预期:

image.png

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次这个命令时,触发了检查点操作

image.png

而检查点内容大致如下,是对这个检查点之前的json文件的一个压缩。

image.png

同时这里也多了一个_last_checkpoint文件,这个是指向最新的检查点文件的,这让我们不用遍历可以直接拿到最新的检查点。

Iceberg

image.png

Iceberg是Netflix发起的一个用于大数据集的开放表格式。Iceberg 为包括 Spark、Trino、PrestoDB、Flink、Hive 和 Impala 在内的计算引擎提供了表格式支持。

文件布局

image.png

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');

此时文件结构:

image.png

可以看到有两个目录,分别是数据目录和元数据目录。我们依照上面的图去介绍这个文件:

首先是当前版本的指针version-hint.text

image.png

其文件内容只有一个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

image.png

Apache Hudi是一个开源的数据湖平台,旨在简化对大型数据集的增量处理和流处理,以实现高效的存储管理和更快的数据处理。Hudi同时支持流处理和批处理,做到低延迟、即时可查询的分析。Hudi可以与各种数据源和存储服务集成,包括Apache Kafka、Amazon S3等。数据可以通过数据流、数据库变更捕获或批量数据加载的方式导入到Hudi。Hudi还支持ACID事务和快照隔离,使得对数据变更进行跟踪和处理更加容易和准确。

Hudi兼容包括Presto、Apache Spark、Apache Hive及各种云平台。此外,Hudi也能够与元存储(Metastore)集成,并且可以使用各种调度工具(如Airflow)来自动化数据处理流程。

数据布局

image.png

Hudi的文件也像Delta Lake一样,主要分为两个文件夹,分别是数据文件和元数据文件。

  1. 目录结构:Hudi在分布式文件系统(如HDFS)上将数据表组织成特定的目录结构。这个结构从一个基本路径开始,基本路径可以被认为是整个Hudi表的根目录。

  2. 分区:为了更好地管理和查询数据,Hudi表通常会被分割成多个分区(Partitions)。分区是数据组织的逻辑分组,通常基于某些关键字段,如日期或地区等。每个分区对应于文件系统中的一个目录。

  3. File Group和File ID:在每个分区中,数据被进一步组织成File Group。每个File Group都由一个唯一的File ID标识。File Group是一个逻辑概念,表示一组在逻辑上相关联的文件,它们共同表示了表中的一部分数据。

  4. File Slice:每个文件组包含多个File Slice。File Slice是文件组中的一个版本,代表了在一个特定时间点的数据视图。

  5. 基础文件和日志文件:每个文件片段由一个基础文件和若干日志文件组成。基础文件(通常是Parquet或ORC格式,取决于配置项 hoodie.table.base.file.format)包含了某个提交(Commit)或压缩(Compaction)时的完整数据快照。而日志文件(.log文件)则存储了自基础文件生成以来的所有数据变更,包括插入和更新。

  6. 元数据文件:.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数据 数据湖默认配置

数据导入性能

image.png

查询性能

平均值中位数95%最大值最小值
Hudi7.75s4.43s25.64s29.22s0.23s
Delta8.58s5.45s27.12s30.92s0.73s
Iceberg11.88s6.91s37.56s88.10s0.22s
Paimon9.78s6.61s29.79s35.75s0.35s

image.png

image.png

可以看到,在这组测试下,Hudi的整体查询性能比较好,但数据导入性能比较低。而iceberg虽然数据导入最快,但查询却是最慢的。delta的查询和写入处于都比较不错的水平。

测试2:50G数据 数据湖默认配置

数据导入性能

image.png

查询性能

平均值中位数95%最大值最小值
Hudi29.97s15.09s125.46s154.81s0.22s
Delta31.00s16.17s126.22s161.50s0.75s
Iceberg56.66s23.41s169.24s605.54s0.21s
Paimon39.91s23.97s133.40s267.90s0.36s

image.png

image.png

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值