数据库内核月报

数据库内核月报 - 2025 / 06

DuckLake初探

Author: 田镜祺(缘祺)

DuckDB在2025年5月的新版本中发布了新的插件:DuckLake,该插件可以理解为是一种完全基于SQL数据库的Lakehouse系统的实现。在本文中,将首先说明该插件的使用场景,然后通过对DuckLake的简单使用来说明其基本原理。

DuckLake: SQL as a Lakehouse Format

Lakehouse

在论文《Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics》中对LakeHouse系统这样定义:基于低成本并且可直接访问的存储构建的数据管理系统,能提供传统分析型DBMS的管理和性能特性,如ACID事务,数据版本控制,审计,索引,缓存和查询优化。

LakeHouse架构示意图

简单来说,所谓的低成本并可直接访问的存储一般就指对象存储服务上保存的Parquet或者ORC等open direct-access data formats文件。LakeHouse系统直接操作和管理对象存储服务上的文件,并且能够针对这些文件提供ACID事务,版本控制,查询优化等一系列传统分析型DBMS能够提供的能力。

对比LakeHouse系统和传统分析型数据库,最主要的差别就是在数据的组织方式上,传统分析型数据库的所有文件由自己管理,自己定义文件格式,好处的方便管理,坏处是其它系统想要使用数据必须使用ETL将数据整合到数据仓库中。而LakeHouse管理Parquet或者ORC等open format的文件,open format文件允许数据能够在多个系统中方便的读取和分析,但这些文件原生不支持ACID事务,版本控制,查询优化等高级功能,因此必须在其之上构建LakeHouse系统进行数据管理。

在DuckLake中,metadata layer是一个核心组件,它用来保存元数据,例如哪些文件是哪些表的哪个版本的一部分,基于这个layer,就可以实现ACID事务,版本控制等功能。而DuckLake相较于其他LakeHouse系统的主要区别其实就是metadata的管理方式不同,其他LakeHouse系统通过读写json,manifest等文件进行元数据记录,而DuckLake使用SQL修改表的方式来进行元数据管理。

DuckLake

DuckLake是LakeHouse的系统的一种基于SQL数据库的实现,它能够用来管理Parquet文件。DuckLake的架构也就非常直观:就是一些Parquet文件 + 一个SQL数据库。

需要注意的是,得益于DuckDB对于MySQL,PGSQL等插件的支持,DuckLake中具体使用哪种SQL数据库可以由用户自行决定,唯一的要求是该数据库必须支持ACID操作和主键,并具备标准的SQL支持。在本文中,为了简便,直接使用DuckDB作为元数据管理的数据库。

初始化

在DuckLake中,所有metadata信息都是以表的形式存在的,因此对DuckLake系统初始化其实就是创建一系列表。 启动DuckDB客户端,执行以下语句:

ATTACH 'ducklake:metadata.ducklake' AS my_ducklake (DATA_PATH '/root/data');

该语句会在DuckDB中创建两个database,第一个是用来记录metadata信息的数据库,名字叫做__ducklake_metadata_my_ducklake,并且会在该数据库下创建一系列用于保存元信息的表。第二个是DuckLake中数据在逻辑上归属的数据库,名字叫做my_ducklake。my_ducklake中所有数据文件都将报存在/root/data路径下,这个路径可以是S3,OSS等对象存储服务。

我们可以创建一张表:

USE my_ducklake;
CREATE TABLE t1 (id INT, col1 VARCHAR);

通过查询duckdb_tables视图来看下此时实例中的所有表:

D select database_name, schema_name, table_name from duckdb_tables;
┌─────────────────────────────────┬─────────────┬───────────────────────────────────────┐
│          database_name          │ schema_name │              table_name               │
│             varchar             │   varchar   │                varchar                │
├─────────────────────────────────┼─────────────┼───────────────────────────────────────┤
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_column                       │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_column_mapping               │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_column_tag                   │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_data_file                    │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_delete_file                  │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_files_scheduled_for_deletion │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_file_column_statistics       │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_file_partition_value         │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_inlined_data_tables          │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_metadata                     │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_name_mapping                 │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_partition_column             │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_partition_info               │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_schema                       │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_snapshot                     │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_snapshot_changes             │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_table                        │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_table_column_stats           │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_table_stats                  │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_tag                          │
│ __ducklake_metadata_my_ducklake │ main        │ ducklake_view                         │
│ my_ducklake                     │ main        │ t1                                    │
├─────────────────────────────────┴─────────────┴───────────────────────────────────────┤
│ 22 rows                                                                     3 columns │
└───────────────────────────────────────────────────────────────────────────────────────┘

可以看到,在初始化DuckLake系统时创建了21张用于记录元信息的表,而我们新创建的t1表位于my_ducklake数据库下。

__ducklake_metadata_my_ducklake数据库中的表会持久在metadata.ducklake文件中,当我们重启DuckDB实例,再次Attach时,就会读取这个文件,重新载入元信息。

在DuckLake中创建的一张表,在物理上是由多个parquet文件组成,parquet文件主要有两种,记录数据的data_file和记录数据删除的delete_file。

我们来通过一个例子进行说明,对之前创建的t1表执行DML操作:

INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c');
UPDATE t1 SET col1 = 'a_1' WHERE id = 1;

可以通过以下SQL来查询此时的data_file:

D select * from __ducklake_metadata_my_ducklake.ducklake_data_file;
┌──────────────┬──────────┬────────────────┬──────────────┬────────────┬────────────────────────────────────────────────────────┬──────────────────┬─────────────┬──────────────┬─────────────────┬─────────────┬──────────────┬──────────────┬────────────────┬───────────────────┬────────────┐
│ data_file_id │ table_id │ begin_snapshot │ end_snapshot │ file_order │                          path                          │ path_is_relative │ file_format │ record_count │ file_size_bytes │ footer_size │ row_id_start │ partition_id │ encryption_key │ partial_file_info │ mapping_id │
│    int64     │  int64   │     int64      │    int64     │   int64    │                        varchar                         │     boolean      │   varchar   │    int64     │      int64      │    int64    │    int64     │    int64     │    varchar     │      varchar      │   int64    │
├──────────────┼──────────┼────────────────┼──────────────┼────────────┼────────────────────────────────────────────────────────┼──────────────────┼─────────────┼──────────────┼─────────────────┼─────────────┼──────────────┼──────────────┼────────────────┼───────────────────┼────────────┤
│            0 │        1 │              2 │         NULL │       NULL │ /ducklake-01975dc4-3fac-73f2-8b20-3bb3c89c444c.parquet │ true             │ parquet     │            3 │             467 │         238 │            0 │         NULL │ NULL           │ NULL              │       NULL │
│            1 │        1 │              3 │         NULL │       NULL │ /ducklake-01975dc4-4234-74d9-aa03-374a4326fe3e.parquet │ true             │ parquet     │            1 │             689 │         388 │            3 │         NULL │ NULL           │ NULL              │       NULL │
└──────────────┴──────────┴────────────────┴──────────────┴────────────┴────────────────────────────────────────────────────────┴──────────────────┴─────────────┴──────────────┴─────────────────┴─────────────┴──────────────┴──────────────┴────────────────┴───────────────────┴────────────┘

我们可以看到,此时创建了两个parquet文件,由于DuckDB是支持直接读取parquet文件内容的,因此我们可以直接通过SQL查询这两个parquet文件:

D select * from '/root/data/main/t1/ducklake-01975dc4-3fac-73f2-8b20-3bb3c89c444c.parquet';
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┼─────────┤
│     1 │ a       │
│     2 │ b       │
│     3 │ c       │
└───────┴─────────┘
D select * from '/root/data/main/t1/ducklake-01975dc4-4234-74d9-aa03-374a4326fe3e.parquet';
┌───────┬─────────┬───────────────────────────┐
│  id   │  col1   │ _ducklake_internal_row_id │
│ int32 │ varchar │           int64           │
├───────┼─────────┼───────────────────────────┤
│   1   │ a_1     │             0             │
└───────┴─────────┴───────────────────────────┘

在第一个parquet文件中,记录了插入的三条记录,在第二个parquet文件中,记录了id = 1更新后的数据。

然后,我们再来看下delete_file:

D select * from __ducklake_metadata_my_ducklake.ducklake_delete_file;
┌────────────────┬──────────┬────────────────┬──────────────┬──────────────┬──────────────────────────────────────────────────────────────┬──────────────────┬─────────┬──────────────┬─────────────────┬─────────────┬────────────────┐
│ delete_file_id │ table_id │ begin_snapshot │ end_snapshot │ data_file_id │                             path                             │ path_is_relative │ format  │ delete_count │ file_size_bytes │ footer_size │ encryption_key │
│     int64      │  int64   │     int64      │    int64     │    int64     │                           varchar                            │     boolean      │ varchar │    int64     │      int64      │    int64    │    varchar     │
├────────────────┼──────────┼────────────────┼──────────────┼──────────────┼──────────────────────────────────────────────────────────────┼──────────────────┼─────────┼──────────────┼─────────────────┼─────────────┼────────────────┤
│       2        │    1     │       3        │     NULL     │      0       │ ducklake-01975dc4-4239-7974-a6e6-0cbd5f38fed2-delete.parquet │ true             │ parquet │      1       │       970       │     671     │ NULL           │
└────────────────┴──────────┴────────────────┴──────────────┴──────────────┴──────────────────────────────────────────────────────────────┴──────────────────┴─────────┴──────────────┴─────────────────┴─────────────┴────────────────┘

可以看到,此时也是存在delete_file的,同样查看这个delete_file的文件内容:

D select * from '/root/data/main/t1/ducklake-01975dc4-4239-7974-a6e6-0cbd5f38fed2-delete.parquet';
┌──────────────────────────────────────────────────────────────────────────┬───────┐
│                                file_path                                 │  pos  │
│                                 varchar                                  │ int64 │
├──────────────────────────────────────────────────────────────────────────┼───────┤
│ /root/data/main/t1/ducklake-01975dc4-3fac-73f2-8b20-3bb3c89c444c.parquet │   0   │
└──────────────────────────────────────────────────────────────────────────┴───────┘

该文件记录了将会从/root/data/main/t1/ducklake-01975dc4-3fac-73f2-8b20-3bb3c89c444c.parquet文件中删除第0行数据。

到这里我们就基本知道了DuckLake记录数据增删改的逻辑了,每次增删改其实都是创建一个新的parquer文件,对于插入操作,直接将待插入的数据插入到新的parquet文件中,对于删除操作,创建一个delete parquet文件,来记录删除哪个文件的第几行数据,对于更新操作,等价于一次删除操作+插入操作。

每次对表的更改,除了新增parquet文件外,还需要对元信息数据库中的表进行修改,由于更改可能涉及多个表,因此我们用一个事务来保证修改的原子性,例如,当我们执行update t1 set col1 = 'a_2' where id=1;更新一行时,除了新增了parquet文件,还执行了以下一个复杂事务:

BEGIN;

SELECT snapshot_id, schema_version, next_catalog_id, next_file_id
FROM __ducklake_metadata_my_ducklake.main.ducklake_snapshot
WHERE snapshot_id = (
	SELECT MAX(snapshot_id)
	FROM __ducklake_metadata_my_ducklake.main.ducklake_snapshot
);

SELECT data.data_file_id, del.delete_file_id, data.record_count, data.path, data.path_is_relative
	, data.file_size_bytes, data.footer_size, data.row_id_start, del.path, del.path_is_relative
	, del.file_size_bytes, del.footer_size
FROM __ducklake_metadata_my_ducklake.main.ducklake_data_file data
	LEFT JOIN (
		SELECT *
		FROM __ducklake_metadata_my_ducklake.main.ducklake_delete_file
		WHERE table_id = 1
			AND 3 >= begin_snapshot
			AND (3 < end_snapshot
				OR end_snapshot IS NULL)
	) del USING (data_file_id)
WHERE data.table_id = 1
	AND 3 >= data.begin_snapshot
	AND (3 < data.end_snapshot
		OR data.end_snapshot IS NULL);

SELECT data.path, data.path_is_relative, data.file_size_bytes, data.footer_size, data.row_id_start
	, data.begin_snapshot, data.partial_file_info, data.mapping_id, del.path, del.path_is_relative
	, del.file_size_bytes, del.footer_size
FROM __ducklake_metadata_my_ducklake.main.ducklake_data_file data
	LEFT JOIN (
		SELECT *
		FROM __ducklake_metadata_my_ducklake.main.ducklake_delete_file
		WHERE table_id = 1
			AND 3 >= begin_snapshot
			AND (3 < end_snapshot
				OR end_snapshot IS NULL)
	) del USING (data_file_id)
WHERE data.table_id = 1
	AND 3 >= data.begin_snapshot
	AND (3 < data.end_snapshot
		OR data.end_snapshot IS NULL)
	AND data_file_id IN (
		SELECT data_file_id
		FROM __ducklake_metadata_my_ducklake.main.ducklake_file_column_statistics
		WHERE table_id = 1
			AND column_id = 1
			AND (max_value IS NULL
				OR min_value IS NULL
				OR 1 BETWEEN TRY_CAST(min_value AS INTEGER) AND TRY_CAST(max_value AS INTEGER))
	);

UPDATE __ducklake_metadata_my_ducklake.main.ducklake_table_stats
SET record_count = 5, file_size_bytes = 1845, next_row_id = 5
WHERE table_id = 1;

WITH new_values (tid, cid, new_contains_null, new_contains_nan, new_min, new_max) AS (
		VALUES (1, 1, false, NULL, '1', '3'), (1, 2, false, NULL, 'a', 'c')
	)
UPDATE __ducklake_metadata_my_ducklake.main.ducklake_table_column_stats
SET contains_null=new_contains_null, contains_nan=new_contains_nan, min_value=new_min, max_value=new_max
FROM new_values 
WHERE table_id=tid AND column_id=cid

INSERT INTO __ducklake_metadata_my_ducklake.main.ducklake_data_file
VALUES (3, 1, 4, NULL, NULL
	, '/ducklake-01975df1-6b22-7542-91a2-5d40635bb3a7.parquet', true, 'parquet', 1, 689
	, 388, 4, NULL, NULL, NULL
	, NULL)

UPDATE __ducklake_metadata_my_ducklake.main.ducklake_data_file
SET end_snapshot = 4
WHERE end_snapshot IS NULL
	AND data_file_id IN (1);

INSERT INTO __ducklake_metadata_my_ducklake.main.ducklake_snapshot
VALUES (4, NOW(), 1, 2, 4);

INSERT INTO __ducklake_metadata_my_ducklake.main.ducklake_snapshot_changes
VALUES (4, 'inserted_into_table:1,deleted_from_table:1');

COMMIT;

这个事务的大致逻辑是首先获取到该表在最新快照下的所有parquet文件,对这些文件扫描后,创建新的parquet文件,写入更改内容,然后将这个新的parquet文件的元信息,新的统计信息写入到元信息数据库,最后commit这个事务。

查询

DuckLake在每次对表进行写操作时时,都会创建一个快照,允许用户在查询时传入一个SNAPSHOT_ID的参数,去查询在该快照ID下的表数据。在ducklake_data_file表和ducklake_delete_file表中,都有两个很重要的列叫做begin_snapshot和end_snapshot,只有当begin_snapshot <= SNAPSHOT_ID < end_snapshot时,这个文件对于该SNAPSHOT_ID才是可见的。

因此,在每个查询开始时,其实都要去ducklake_data_file和ducklake_delete_file中获取到当前快照可见的文件列表,将这些文件列表的查询结果整合起来就是最终的查询结果。

SELECT data.path, data.path_is_relative, data.file_size_bytes, data.footer_size, 
       data.row_id_start, data.begin_snapshot, data.partial_file_info, del.path,
       del.path_is_relative, del.file_size_bytes, del.footer_size
FROM {METADATA_CATALOG}.ducklake_data_file data
	LEFT JOIN (
		SELECT *
		FROM {METADATA_CATALOG}.ducklake_delete_file
		WHERE table_id = {TABLE_ID}
			AND {SNAPSHOT_ID} >= begin_snapshot
			AND ({SNAPSHOT_ID} < end_snapshot
				OR end_snapshot IS NULL)
	) del USING (data_file_id)
WHERE data.table_id = TABLE_ID
	AND {SNAPSHOT_ID} >= data.begin_snapshot
	AND ({SNAPSHOT_ID} < data.end_snapshot
		OR data.end_snapshot IS NULL);

举个例子,对于t1表,我们其实是有三个快照的,第一个快照是刚创建的空表,第二个快照是插入三行数据的表,第三个快照是更新了一行数据的表,我们可以通过以下SQL对t1的三个快照进行查询:

D select * from t1 at (version => 1);
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┴─────────┤
│     0 rows      │
└─────────────────┘
D select * from t1 at (version => 2);
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┼─────────┤
│     1 │ a       │
│     2 │ b       │
│     3 │ c       │
└───────┴─────────┘
D select * from t1 at (version => 3);
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┼─────────┤
│     2 │ b       │
│     3 │ c       │
│     1 │ a_1     │
└───────┴─────────┘

对于快照1,没有任何parquet文件是可见的,因此直接返回空结果集,对于快照2,只有插入了三条数据的parquet文件是可见的,因此返回插入的三条原始数据,对于快照3,所有parquet文件都是可见的,因此返回了更新了id = 1后的数据。

删除快照和删除文件

我们之前说过,每次对表的增删改都是新增一个parquet文件,会创建一个快照,那么parquet文件数量会不断增加,快照数量也就会不断增加,因此DuckLake提供了ducklake_expire_snapshots函数,该函数能够指定哪些snapshot的过期的,如果一个parquet文件的begin_snapshot到end_snapshot的内所有snapshot都过期了,那么这个parquet文件也就能够被删除。

我们也通过一个例子来进行说明:

D use my_ducklake;
D create table t2 (id int, col1 varchar); #snapshot 5 []
D insert into t2 values (1, 'a'), (2, 'b'), (3, 'c'); #snapshot 6 [[1, 'a'], [2, 'b'], [3, 'c']]
D update t2 set col1 = 'a_1' where id = 1; #snapshot 7 [[1, 'a_1'], [2, 'b'], [3, 'c']]
D update t2 set col1 = 'new'; #snapshot 8 [[1, 'new'], [2, 'new'], [3, 'new']]

D select * from __ducklake_metadata_my_ducklake.ducklake_data_file where table_id = 2;
┌──────────────┬──────────┬────────────────┬──────────────┬────────────┬───────────────────────────────────────────────────────┬──────────────────┬─────────────┬──────────────┬─────────────────┬─────────────┬──────────────┬──────────────┬────────────────┬───────────────────┬────────────┐
│ data_file_id │ table_id │ begin_snapshot │ end_snapshot │ file_order │                         path                          │ path_is_relative │ file_format │ record_count │ file_size_bytes │ footer_size │ row_id_start │ partition_id │ encryption_key │ partial_file_info │ mapping_id │
│    int64     │  int64   │     int64      │    int64     │   int64    │                        varchar                        │     boolean      │   varchar   │    int64     │      int64      │    int64    │    int64     │    int64     │    varchar     │      varchar      │   int64    │
├──────────────┼──────────┼────────────────┼──────────────┼────────────┼───────────────────────────────────────────────────────┼──────────────────┼─────────────┼──────────────┼─────────────────┼─────────────┼──────────────┼──────────────┼────────────────┼───────────────────┼────────────┤
│            4 │        2 │              6 │            8 │       NULL │ /ducklake-01975e64-91d8-7610-9386-bde7fbdaede1.parq…  │ true             │ parquet     │            3 │             467 │         238 │            0 │         NULL │ NULL           │ NULL              │       NULL │
│            5 │        2 │              7 │            8 │       NULL │ /ducklake-01975e64-c441-743d-bbef-90c4c6dc2339.parq…  │ true             │ parquet     │            1 │             689 │         388 │            3 │         NULL │ NULL           │ NULL              │       NULL │
│            7 │        2 │              8 │         NULL │       NULL │ /ducklake-01975e64-f63a-78f2-a323-8927331a04b0.parq…  │ true             │ parquet     │            3 │             724 │         392 │            4 │         NULL │ NULL           │ NULL              │       NULL │
└──────────────┴──────────┴────────────────┴──────────────┴────────────┴───────────────────────────────────────────────────────┴──────────────────┴─────────────┴──────────────┴─────────────────┴─────────────┴──────────────┴──────────────┴────────────────┴───────────────────┴────────────┘

D select * from __ducklake_metadata_my_ducklake.ducklake_delete_file where table_id = 2;
┌────────────────┬──────────┬────────────────┬──────────────┬──────────────┬──────────────────────────────────────────────────────────────┬──────────────────┬─────────┬──────────────┬─────────────────┬─────────────┬────────────────┐
│ delete_file_id │ table_id │ begin_snapshot │ end_snapshot │ data_file_id │                             path                             │ path_is_relative │ format  │ delete_count │ file_size_bytes │ footer_size │ encryption_key │
│     int64      │  int64   │     int64      │    int64     │    int64     │                           varchar                            │     boolean      │ varchar │    int64     │      int64      │    int64    │    varchar     │
├────────────────┼──────────┼────────────────┼──────────────┼──────────────┼──────────────────────────────────────────────────────────────┼──────────────────┼─────────┼──────────────┼─────────────────┼─────────────┼────────────────┤
│       6        │    2     │       7        │     NULL     │      4       │ ducklake-01975e64-c448-7521-ac85-5069490754fe-delete.parquet │ true             │ parquet │      1       │       970       │     671     │ NULL           │
└────────────────┴──────────┴────────────────┴──────────────┴──────────────┴──────────────────────────────────────────────────────────────┴──────────────────┴─────────┴──────────────┴─────────────────┴─────────────┴────────────────┘

此时先调用ducklake_expire_snapshots来使得第7个snapshot过期:

D call ducklake_expire_snapshots('my_ducklake', versions => [7]);
┌─────────────┬───────────────────────────┬────────────────┬─────────────────────────────────────────────────────┐
│ snapshot_id │       snapshot_time       │ schema_version │                       changes                       │
│    int64    │ timestamp with time zone  │     int64      │               map(varchar, varchar[])               │
├─────────────┼───────────────────────────┼────────────────┼─────────────────────────────────────────────────────┤
│      7      │ 2025-06-11 17:49:11.15+08 │       2        │ {tables_inserted_into=[2], tables_deleted_from=[2]} │
└─────────────┴───────────────────────────┴────────────────┴─────────────────────────────────────────────────────┘

当第7个snapshot过期时,我们可以看到只有/ducklake-01975e64-c441-743d-bbef-90c4c6dc2339.parquet不会再被任何其他snapshot可见。此时可以调用ducklake_cleanup_old_files来清除文件:

D CALL ducklake_cleanup_old_files('my_ducklake', cleanup_all => true);
┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                 path                                                 │
│                                               varchar                                                │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ /root/data/main/t2/ducklake-01975e64-c441-743d-bbef-90c4c6dc2339.parquet                             │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘

如果此时我们再看t1的四个版本,可以发现snapshot 7 [[1, ‘a_1’], [2, ‘b’], [3, ‘c’]] 已经完全无法被查询出来。

D select * from my_ducklake.t2 at(version =>5);
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┴─────────┤
│     0 rows      │
└─────────────────┘
D select * from my_ducklake.t2 at(version =>6);
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┼─────────┤
│     1 │ a       │
│     2 │ b       │
│     3 │ c       │
└───────┴─────────┘
D select * from my_ducklake.t2 at(version =>7);
Invalid Input Error:
No snapshot found at version 7
D select * from my_ducklake.t2 at(version =>8);
┌───────┬─────────┐
│  id   │  col1   │
│ int32 │ varchar │
├───────┼─────────┤
│     2 │ new     │
│     3 │ new     │
│     1 │ new     │
└───────┴─────────┘

文件合并

当多次小批量插入数据时,会生成多个parquet文件,会影响性能,因此DuckLake允许在不expire snapshot的情况下对相邻的进行插入的parquet文件进行合并,合并要求这些parquet文件上没有任何删除操作。

D create table t5 (id int, col1 varchar);
D insert into t5 values (1, 'a'), (2, 'b');
D insert into t5 values (3, 'c'), (4, 'd');
D insert into t5 values (5, 'f'), (6, 'g');
D insert into t5 values (7, 'h'), (8, 'i');
D delete from t5 where id = 5;

该操作一共会创建4个parquet文件,1个delete parquet文件,由于第三个parquet文件上有删除操作,因此只有前两个parquet文件能合并。

调用ducklake_merge_adjacent_files进行文件合并。

D select * from __ducklake_metadata_my_ducklake.ducklake_data_file where table_id = 5;
┌──────────────┬──────────┬────────────────┬──────────────┬────────────┬───────────────────────────────────────────────────────┬──────────────────┬─────────────┬──────────────┬─────────────────┬─────────────┬──────────────┬──────────────┬────────────────┬───────────────────┬────────────┐
│ data_file_id │ table_id │ begin_snapshot │ end_snapshot │ file_order │                         path                          │ path_is_relative │ file_format │ record_count │ file_size_bytes │ footer_size │ row_id_start │ partition_id │ encryption_key │ partial_file_info │ mapping_id │
│    int64     │  int64   │     int64      │    int64     │   int64    │                        varchar                        │     boolean      │   varchar   │    int64     │      int64      │    int64    │    int64     │    int64     │    varchar     │      varchar      │   int64    │
├──────────────┼──────────┼────────────────┼──────────────┼────────────┼───────────────────────────────────────────────────────┼──────────────────┼─────────────┼──────────────┼─────────────────┼─────────────┼──────────────┼──────────────┼────────────────┼───────────────────┼────────────┤
│           23 │        5 │             27 │         NULL │       NULL │ /ducklake-01975ecd-58af-7582-9d75-ea1593bddf91.parq…  │ true             │ parquet     │            2 │             457 │         237 │            0 │         NULL │ NULL           │ NULL              │       NULL │
│           24 │        5 │             28 │         NULL │       NULL │ /ducklake-01975ecd-722a-7802-868f-32ea9288b1d4.parq…  │ true             │ parquet     │            2 │             457 │         237 │            2 │         NULL │ NULL           │ NULL              │       NULL │
│           25 │        5 │             29 │         NULL │       NULL │ /ducklake-01975ecd-deee-7e4e-9514-b447c8372a2e.parq…  │ true             │ parquet     │            2 │             457 │         237 │            4 │         NULL │ NULL           │ NULL              │       NULL │
│           26 │        5 │             30 │         NULL │       NULL │ /ducklake-01975ece-06f0-7e2e-bd05-45aefcc6d5da.parq…  │ true             │ parquet     │            2 │             457 │         237 │            6 │         NULL │ NULL           │ NULL              │       NULL │
└──────────────┴──────────┴────────────────┴──────────────┴────────────┴───────────────────────────────────────────────────────┴──────────────────┴─────────────┴──────────────┴─────────────────┴─────────────┴──────────────┴──────────────┴────────────────┴───────────────────┴────────────┘

call ducklake_merge_adjacent_files('my_ducklake');

D select * from __ducklake_metadata_my_ducklake.ducklake_data_file where table_id = 5;
┌──────────────┬──────────┬────────────────┬──────────────┬────────────┬───────────────────────────────────────────────────────┬──────────────────┬─────────────┬──────────────┬─────────────────┬─────────────┬──────────────┬──────────────┬────────────────┬───────────────────┬────────────┐
│ data_file_id │ table_id │ begin_snapshot │ end_snapshot │ file_order │                         path                          │ path_is_relative │ file_format │ record_count │ file_size_bytes │ footer_size │ row_id_start │ partition_id │ encryption_key │ partial_file_info │ mapping_id │
│    int64     │  int64   │     int64      │    int64     │   int64    │                        varchar                        │     boolean      │   varchar   │    int64     │      int64      │    int64    │    int64     │    int64     │    varchar     │      varchar      │   int64    │
├──────────────┼──────────┼────────────────┼──────────────┼────────────┼───────────────────────────────────────────────────────┼──────────────────┼─────────────┼──────────────┼─────────────────┼─────────────┼──────────────┼──────────────┼────────────────┼───────────────────┼────────────┤
│           25 │        5 │             29 │         NULL │       NULL │ /ducklake-01975ecd-deee-7e4e-9514-b447c8372a2e.parq…  │ true             │ parquet     │            2 │             457 │         237 │            4 │         NULL │ NULL           │ NULL              │       NULL │
│           26 │        5 │             30 │         NULL │       NULL │ /ducklake-01975ece-06f0-7e2e-bd05-45aefcc6d5da.parq…  │ true             │ parquet     │            2 │             457 │         237 │            6 │         NULL │ NULL           │ NULL              │       NULL │
│           31 │        5 │             27 │         NULL │       NULL │ /ducklake-01975ece-dc11-7b23-a3e9-937b8112a1ed.parq…  │ true             │ parquet     │            4 │             754 │         397 │            0 │         NULL │ NULL           │ 27:2|28:4         │       NULL │
└──────────────┴──────────┴────────────────┴──────────────┴────────────┴───────────────────────────────────────────────────────┴──────────────────┴─────────────┴──────────────┴─────────────────┴─────────────┴──────────────┴──────────────┴────────────────┴───────────────────┴────────────┘
可以看到前两个parquet文件进行了合并,合并后的文件用partial_file_info来记录snapshot信息,27:2 28:4代表第27个snapshot在该文件有两行,第28个snapshot在该文件有四行,从而实现了在不expire snapshot的情况下进行文件合并。

其他

DuckLake还支持一些其他的高级操作,例如获取两个snapshot之间的数据变更,利用inline data来避免small change写parquet文件,表加密,表分区等,感兴趣的读者可以查看官方文档:

DuckLake Documentation

总结

本文简单介绍了DuckLake的使用方法和基本原理,个人认为使用一个标准的SQL数据库进行LakeHouse中元数据的管理确实大大简化了LakeHouse系统的复杂度。而DuckDB其实又是这样一个比较好的SQL数据库载体,它原生支持对Parquet文件的读写,拥有高效的列存执行器,支持事务,支持通过插件连接其它SQL数据库,在其之上构建LakeHouse系统无疑是比较简单的,而实际上,DuckLake整个项目的代码量也确实不算多。DuckLake是一个非常好的尝试,但距离一个可用于生产的LakeHouse系统还有很长的距离。