Author: 田镜祺(缘祺)
DuckDB在2025年5月的新版本中发布了新的插件:DuckLake,该插件可以理解为是一种完全基于SQL数据库的Lakehouse系统的实现。在本文中,将首先说明该插件的使用场景,然后通过对DuckLake的简单使用来说明其基本原理。
DuckLake: SQL as a Lakehouse Format
在论文《Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics》中对LakeHouse系统这样定义:基于低成本并且可直接访问的存储构建的数据管理系统,能提供传统分析型DBMS的管理和性能特性,如ACID事务,数据版本控制,审计,索引,缓存和查询优化。
简单来说,所谓的低成本并可直接访问的存储一般就指对象存储服务上保存的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是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的使用方法和基本原理,个人认为使用一个标准的SQL数据库进行LakeHouse中元数据的管理确实大大简化了LakeHouse系统的复杂度。而DuckDB其实又是这样一个比较好的SQL数据库载体,它原生支持对Parquet文件的读写,拥有高效的列存执行器,支持事务,支持通过插件连接其它SQL数据库,在其之上构建LakeHouse系统无疑是比较简单的,而实际上,DuckLake整个项目的代码量也确实不算多。DuckLake是一个非常好的尝试,但距离一个可用于生产的LakeHouse系统还有很长的距离。