DuckLake:作为数据湖仓格式的SQL
作者:Mark Raasveldt 和 Hannes Mühleisen
发布日期:2025-05-27
摘要:DuckLake通过使用标准SQL数据库管理所有元数据来简化数据湖仓,而非采用复杂的基于文件的系统,同时仍将数据存储在Parquet等开放格式中。这使得它更可靠、更快速且更易于管理。
背景
像BigQuery和Snowflake这样的创新数据系统已经证明,在存储已成为虚拟化商品的时代,存储与计算分离是一个绝佳的理念。这样,存储和计算都可以独立扩展,我们不必为了存储那些可能永远不会读取的表而购买昂贵的数据库服务器。
同时,市场力量推动人们坚持要求数据系统使用开放格式,如Parquet,以避免数据被单一供应商"劫持"的常见问题。在这个新世界中,许多数据系统愉快地围绕着建立在Parquet和S3上的纯净"数据湖"嬉戏,一切看似很好。谁还需要那些老式的数据库呢!
但很快就发现——令人震惊的是——人们希望对他们的数据集进行更改。简单的追加操作通过将更多文件放入文件夹中工作得相当好,但任何超出这个范围的操作都需要复杂且容易出错的自定义脚本,这些脚本没有任何正确性概念,更不用说——Codd保佑——事务保证了。
Iceberg和Delta
为了解决在数据湖中更改数据的基本任务,各种新的开放标准应运而生,最突出的是Apache Iceberg和Linux Foundation Delta Lake。这两种格式的设计初衷都是在不放弃基本前提的情况下恢复对表进行更改的一些理性:在Blob存储上使用开放格式。例如,Iceberg使用一系列JSON和Avro文件来定义模式、快照以及哪些Parquet文件在某个时间点属于表的一部分。这一成果被命名为"数据湖仓"(Lakehouse),实际上是在数据湖之上增加了数据库功能,为数据管理启用了许多令人兴奋的新用例,例如跨引擎数据共享。
但这两种格式都遇到了一个障碍:在具有变幻莫测的一致性保证的Blob存储中,找到表的最新版本是棘手的。要确保每个人都能看到最新版本,原子性地(ACID中的"A")交换指针是很复杂的。Iceberg和Delta Lake实际上也只了解单个表,但人们——再次令人震惊的是——希望管理多个表。
目录服务
解决方案是另一层技术:我们在各种文件之上添加了一个目录服务。这个目录服务反过来与一个数据库通信,该数据库管理所有表文件夹名称。它还管理着有史以来最悲哀的表,这个表对每个表只包含一行,其中有当前版本号。现在我们可以借用数据库围绕更新该数字的事务保证,大家都很满意。
你说的是数据库吗?
但问题是:Iceberg 和 Delta Lake 的设计初衷是不需要数据库。它们的设计者竭尽全力将高效读取和更新表所需的所有信息编码到 Blob 存储中的文件中。为了实现这一点,他们做出了许多妥协。例如,Iceberg 中的每个根文件都包含所有现有快照、完整的架构信息等。对于每个更改,都会写入一个包含完整历史记录的新文件。许多其他元数据必须批量处理,例如在两层清单文件中,以避免写入或读取过多的小文件,这在 Blob 存储上效率不高。对数据进行细微更改也是一个很大程度上尚未解决的问题,需要复杂的清理程序,而这些程序至今仍未被开源实现所充分理解和支持。许多公司已经存在并仍在努力解决管理快速变化数据的问题。这几乎就像一个专门的数据管理系统是一个好主意。
但正如上文所指出的,Iceberg 和 Delta Lake 的设计已经不得不做出妥协,将数据库作为目录的一部分来保持一致性。然而,他们从未重新审视其余的设计约束和技术栈,以适应这一根本性的设计变更。
DuckLake
在 DuckDB,我们真的热爱的数据库。它们是安全高效管理相当大数据集的出色工具。既然数据库已经进入了数据湖仓技术栈,用它来管理表的其余元数据也是极其合理的!我们仍然可以利用Blob存储"无尽"的容量和"无限"的可扩展性,以开放格式如Parquet存储实际的表数据,但我们可以更高效、更有效地在数据库中管理支持变更所需的元数据!巧合的是,这也是Google BigQuery(使用Spanner)和Snowflake(使用FoundationDB)所选择的方式,只是底层没有开放格式。
为了解决现有数据湖仓架构的根本问题,我们创建了一种名为DuckLake的新开放表格式。DuckLake通过承认两个简单的事实,重新构想了"数据湖仓"格式应该是什么样子:
- 在Blob存储上以开放格式存储数据文件对于可扩展性和防止锁定是一个好主意。
- 管理元数据是一项复杂且相互关联的数据管理任务,最好交给数据库管理系统处理。
DuckLake的基本设计是将所有元数据结构移入SQL数据库,包括目录和表数据。该格式被定义为一组关系表和纯SQL事务,这些事务描述了数据操作,如模式创建、修改以及数据的添加、删除和更新。DuckLake格式可以管理任意数量的表,支持跨表事务。它还支持"高级"数据库概念,如视图、嵌套类型、事务性模式更改等;详见下文列表。此设计的一大优势是通过利用引用一致性(ACID 中的"C"),该模式可以确保不存在重复的快照 ID。
具体使用哪种SQL数据库由用户决定,唯一的要求是系统支持ACID操作和主键,以及标准SQL支持。DuckLake内部表模式被有意保持简单,以最大限度地兼容不同的SQL数据库。以下是通过一个示例展示的核心模式。
让我们跟踪在新的空表上运行以下查询时DuckLake中发生的查询序列:
INSERT INTO demo VALUES (42), (43);
BEGIN TRANSACTION;
-- 此处省略一些元数据读取
INSERT INTO ducklake_data_file VALUES (0, 1, 2, NULL, NULL, 'data_files/ducklake-8196...13a.parquet', 'parquet', 2, 279, 164, 0, NULL, NULL);
INSERT INTO ducklake_table_stats VALUES (1, 2, 2, 279);
INSERT INTO ducklake_table_column_stats VALUES (1, 1, false, NULL, '42', '43');
INSERT INTO ducklake_file_column_statistics VALUES (0, 1, 1, NULL, 2, 0, 56, '42', '43', NULL)
INSERT INTO ducklake_snapshot VALUES (2, now(), 1, 2, 1);
INSERT INTO ducklake_snapshot_changes VALUES (2, 'inserted_into_table:1');
COMMIT;
我们看到一个连贯的SQL事务,它:
- 插入新的Parquet文件路径
- 更新全局表统计信息(现在有更多行)
- 更新全局列统计信息(现在有不同的最小值和最大值)
- 更新文件列统计信息(也记录最小/最大值等)
- 创建新的模式快照(#2)
- 记录快照中发生的变更
请注意,对Parquet的实际写入不是这个序列的一部分,它在此之前发生。但无论添加多少值,这个序列都具有相同的(低)成本。
让我们讨论DuckLake的三个原则:简单性、可扩展性和速度。
简单性
DuckLake遵循DuckDB的设计原则,保持事物简单且增量式。要在笔记本电脑上运行DuckLake,只需安装带有ducklake扩展的DuckDB即可。这对于测试目的、开发和原型设计非常好。在这种情况下,目录存储只是一个本地DuckDB文件。
下一步是利用外部存储系统。DuckLake数据文件是不可变的,它从不需要就地修改文件或重用文件名。这允许与几乎任何存储系统一起使用。DuckLake支持与任何存储系统集成,如本地磁盘、本地NAS、S3、Azure Blob Store、GCS等。数据文件的存储前缀(例如,s3://mybucket/mylake/)在创建元数据表时指定。
最后,托管目录服务器的SQL数据库可以是任何支持ACID和主键约束的半合格SQL数据库。大多数组织已经有很多运行这类系统的经验。这大大简化了部署,因为除了SQL数据库外,不需要额外的软件栈。此外,SQL数据库在近年来已经大量商品化,有无数的托管PostgreSQL服务,甚至托管DuckDB可以用作目录存储!同样,这里的锁定非常有限,因为转换不需要任何表数据移动,且模式简单且标准化。
没有Avro或JSON文件。没有额外的目录服务器或需要集成的额外API。一切都只是SQL。我们都懂SQL。
可扩展性
DuckLake实际上增加了数据架构中关注点的分离,分为三部分:存储、计算和元数据管理。存储仍然在专门构建的文件存储(例如,blob存储)上,DuckLake在存储方面可以无限扩展。
任意数量的计算节点查询和更新目录数据库,然后独立地从存储中读取和写入。DuckLake在计算方面可以无限扩展。
最后,目录数据库需要能够仅运行计算节点请求的元数据事务。这些事务的规模比实际数据更改要小几个数量级。但 DuckLake 并不局限于单一的目录数据库,因此随着需求的增长,例如从 PostgreSQL 迁移到其他数据库,是可行的。最终,DuckLake 使用简单的表和基本的可移植 SQL。不过不用担心,基于 PostgreSQL 的 DuckLake 已经能够扩展到数百 TB 的数据和数千个计算节点。
再次强调,这正是 BigQuery 和 Snowflake 所采用的设计,它们已经成功管理了海量数据集。而且,如果需要,你也可以使用 Spanner 作为 DuckLake 目录数据库。
速度
与 DuckDB 本身一样,DuckLake 也非常注重速度。Iceberg 和 Delta Lake 最大的痛点之一是运行最小查询所需的文件 IO 序列。跟踪目录和文件元数据路径需要许多单独的顺序 HTTP 请求。因此,读取或事务的运行速度存在下限。事务提交的关键路径上会花费大量时间,导致频繁发生冲突,并带来高昂的冲突解决成本。虽然可以使用缓存来缓解其中一些问题,但这会增加额外的复杂性,并且仅对"热"数据有效。
SQL 数据库中统一的元数据还支持低延迟查询规划。为了从 DuckLake 表中读取数据,只需向目录数据库发送一个查询,该数据库会执行基于模式、分区和统计信息的修剪,本质上是从 Blob 存储中检索要读取的文件列表。无需多次往返存储来检索和重建元数据状态。出错的可能性也更小,没有 S3 限流、没有失败的请求、没有重试、没有导致文件不可见的存储视图不一致等等。
DuckLake 还能够改善数据湖的两个最大性能问题:小更改和大量并发更改。
对于细微的变更,DuckLake 将大幅减少写入存储的小文件数量。与上一个相比,无需生成包含细微变更的新快照文件,也无需生成新的清单文件或清单列表。DuckLake 甚至支持将表的细微变更直接透明地内联到元数据存储中的实际表中!事实证明,数据库系统也可以用来管理数据。这可以实现亚毫秒级的写入,并通过减少需要读取的文件数量来提升整体查询性能。通过写入更少的文件,DuckLake 还大大简化了清理和压缩操作。
在 DuckLake 中,表更改包括两个步骤:将数据文件(如果有)暂存到存储,然后在目录数据库中运行单个 SQL 事务。这大大减少了在事务提交的关键路径上花费的时间,因为只需运行一个事务。SQL 数据库非常擅长消除事务冲突。这意味着计算节点在可能发生冲突的关键路径上花费的时间要少得多。这允许更快地解决冲突并允许更多并发事务。本质上,DuckLake 支持目录数据库可以提交的尽可能多的表更改。即使是古老的 Postgres每秒也可以运行数千个事务。可以运行一千个计算节点,以一秒的间隔运行向表的追加操作,它也可以正常工作。
此外,DuckLake 快照只需在元数据存储中添加几行数据,即可同时存储多个快照。无需主动修剪快照。快照还可以引用Parquet 文件的部分内容,从而允许存储比磁盘上文件数量更多的快照。综合起来,DuckLake 可以管理数百万个快照!
功能
DuckLake拥有您喜爱的所有数据湖仓功能:
- 任意SQL:DuckLake支持与DuckDB支持的相同广泛的SQL功能。
- 数据变更:DuckLake支持高效的追加、更新和删除数据。
- 多模式、多表:DuckLake可以在相同的元数据表结构中管理任意数量的模式,每个模式包含任意数量的表。
- 多表事务:DuckLake支持对所有管理的模式、表及其内容的完全ACID兼容事务。
- 复杂类型:DuckLake支持您喜爱的所有复杂类型,如列表,可任意嵌套。
- 完整模式演化:表模式可以任意更改,例如,可以添加、删除列或更改其数据类型。
- 模式级时间旅行和回滚:DuckLake支持完整的快照隔离和时间旅行,允许查询特定时间点的表。
- 增量扫描:DuckLake支持仅检索指定快照之间发生的变更。
- SQL视图:DuckLake支持定义延迟评估的SQL级视图。
- 隐藏分区和修剪:DuckLake了解数据分区以及表级和文件级统计信息,允许早期修剪扫描以获得最大效率。
- 事务性DDL:模式、表和视图的创建、演化和删除是完全事务性的。
- 避免数据压缩:DuckLake比可比格式需要更少的压缩操作。DuckLake支持高效的快照压缩。
- 内联:在对数据进行小更改时,DuckLake可以选择使用目录数据库直接存储这些小更改,避免写入许多小文件。
- 加密:DuckLake可以选择加密写入数据存储的所有数据文件,允许零信任数据托管。密钥由目录数据库管理。
- 兼容性:DuckLake写入存储的数据和(位置)删除文件与Apache Iceberg完全兼容,允许仅元数据迁移。
ducklake DuckDB扩展
凭空指定数据格式很容易,但真正实现起来却很难。正因如此,我们今天还发布了 DuckLake 的计算节点实现,即ducklake
DuckDB 扩展。该扩展实现了上述 DuckLake 格式,并支持所有描述的功能。该扩展是遵循 MIT 许可证的免费开源软件,所有知识产权均归非营利性 DuckDB 基金会所有。
从概念上讲,该ducklake
扩展极大地提升了 DuckDB 的性能,使其从最初的单节点架构提升到无需额外支持基础设施即可支持集中式客户端-服务器数据仓库用例的水平。借助 DuckLake,组织可以设置集中式目录数据库和文件存储(例如 AWS RDS 和 S3 或自托管),然后ducklake
在大量参与设备(例如员工工作站、手机、应用服务器或无服务器计算代码)上运行带有该扩展的 DuckDB 实例。
该扩展程序能够使用本地 DuckDB 文件作为其目录数据库,独立运行 DuckLake。它还可以使用 DuckDB 可以通信的任何第三方数据库。目前,这包括 PostgreSQL、SQLite、MySQL 和 MotherDuck 作为外部集中式目录数据库。该扩展程序可以使用 DuckDB 支持的任何文件系统,例如本地文件、S3、Azure Blob Store、GCS 等。
当然,DuckLake 扩展的推出只是增强了 DuckDB 对Iceberg、Delta及其相关目录的现有和持续支持,而非取代。DuckLake 也非常适合用作这些格式的本地缓存或加速功能。
DuckLake从DuckDB发布v1.3.0(代号"Ossivalis")开始可用。
安装与加载
要使用DuckLake,首先需要安装DuckDB,然后安装ducklake扩展。安装过程非常简单:
- 安装DuckDB
- 启动DuckDB
- 在DuckDB命令行中输入:
INSTALL ducklake;
ducklake扩展会在首次使用ATTACH子句时自动加载。如果您想手动加载它,可以运行:
LOAD ducklake;
使用方法
DuckLake可以通过DuckDB中的ATTACH命令初始化。基本语法如下:
ATTACH 'ducklake:metadata.ducklake' AS my_ducklake (DATA_PATH 'data_files');
USE my_ducklake;
这将在DuckDB中创建一个名为my_ducklake的新附加数据库。在这个完全本地的情况下,元数据表存储在metadata.ducklake文件中,而带有数据的Parquet文件存储在当前工作目录的data_files文件夹中。当然,绝对路径也可以工作。
接下来让我们创建一个表并插入一些数据:
CREATE TABLE my_ducklake.demo (i INTEGER);
INSERT INTO my_ducklake.demo VALUES (42), (43);
您可以使用USE命令切换默认数据库,例如,USE my_ducklake;
。
让我们再次查询表:
FROM my_ducklake.demo;
┌───────┐
│ i │
│ int32 │
├───────┤
│ 42 │
│ 43 │
└───────┘
到目前为止,一切顺利。我们可以检查生成的Parquet文件:
FROM glob('metadata.ducklake.files/*');
┌───────────────────────────────────────────────────────────────────────────────┐
│ file │
│ varchar │
├───────────────────────────────────────────────────────────────────────────────┤
│ metadata.ducklake.files/ducklake-019711dd-6f55-7f41-ab99-6ac7d9de6ef3.parquet │
└───────────────────────────────────────────────────────────────────────────────┘
我们可以看到已创建了一个包含两行的单个Parquet文件。
现在,让我们删除一行:
DELETE FROM my_ducklake.demo WHERE i = 43;
FROM my_ducklake.demo;
┌───────┐
│ i │
│ int32 │
├───────┤
│ 42 │
└───────┘
我们可以看到行已经消失。如果我们再次检查文件夹,我们会看到出现了一个新文件:
FROM glob('metadata.ducklake.files/*');
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ file │
│ varchar │
├──────────────────────────────────────────────────────────────────────────────────────┤
│ metadata.ducklake.files/ducklake-019711dd-6f55-7f41-ab99-6ac7d9de6ef3.parquet │
│ metadata.ducklake.files/ducklake-019711e0-16f7-7261-9d08-563a48529955-delete.parquet │
-delete
出现了名称中带有的第二个文件,这也是一个包含已删除行的标识符的 Parquet 文件。
时间旅行与快照管理
DuckLake支持强大的时间旅行功能,允许您查询表的历史版本。您可以使用ducklake_snapshots()
函数查询可用的快照:
FROM ducklake_snapshots('my_ducklake');
┌─────────────┬────────────────────────────┬────────────────┬──────────────────────────────┐
│ snapshot_id │ snapshot_time │ schema_version │ changes │
│ int64 │ timestamp with time zone │ int64 │ map(varchar, varchar[]) │
├─────────────┼────────────────────────────┼────────────────┼──────────────────────────────┤
│ 0 │ 2025-05-27 15:10:04.953+02 │ 0 │ {schemas_created=[main]} │
│ 1 │ 2025-05-27 15:10:14.079+02 │ 1 │ {tables_created=[main.demo]} │
│ 2 │ 2025-05-27 15:10:14.092+02 │ 1 │ {tables_inserted_into=[1]} │
│ 3 │ 2025-05-27 15:13:08.08+02 │ 1 │ {tables_deleted_from=[1]} │
└─────────────┴────────────────────────────┴────────────────┴──────────────────────────────┘
假设我们想要读取第 43 行被删除之前的表,我们可以使用DuckDB中的AT
语法:
FROM my_ducklake.demo AT (VERSION => 2);
┌───────┐
│ i │
│ int32 │
├───────┤
│ 42 │
│ 43 │
└───────┘
版本 2 仍然有此行。这也适用于快照时间戳而不是版本号:只需使用TIMESTAMP
而不是VERSION
。
我们还可以使用ducklake_table_changes()
函数查看版本之间发生了什么变化:
FROM ducklake_table_changes('my_ducklake', 'main', 'demo', 2, 3);
┌─────────────┬───────┬─────────────┬───────┐
│ snapshot_id │ rowid │ change_type │ i │
│ int64 │ int64 │ varchar │ int32 │
├─────────────┼───────┼─────────────┼───────┤
│ 2 │ 0 │ insert │ 42 │
│ 2 │ 1 │ insert │ 43 │
│ 3 │ 1 │ delete │ 43 │
└─────────────┴───────┴─────────────┴───────┘
我们可以看到,在第二个版本中,我们在快照 2 中添加了两个值 42 和 43,然后在快照 3 中再次删除了 43。
事务支持
DuckLake 中的更改是完全事务性的。默认情况下,DuckDB运行在"自动提交"模式,每个命令都是一个独立的事务。但我们可以使用BEGIN TRANSACTION
、COMMIT
或ROLLBACK
来控制事务边界:
BEGIN TRANSACTION;
DELETE FROM my_ducklake.demo;
FROM my_ducklake.demo;
┌────────┐
│ i │
│ int32 │
├────────┤
│ 0 rows │
└────────┘
ROLLBACK;
FROM my_ducklake.demo;
┌───────┐
│ i │
│ int32 │
├───────┤
│ 42 │
└───────┘
这里,我们启动一个事务,然后从my_ducklake.demo
中删除所有行,结果确实显示为空。但是,由于我们随后执行了ROLLBACK
,未提交的删除操作被还原了。
表信息与管理
DuckLake提供了多个函数来查询和管理表信息:
ducklake_table_info
ducklake_table_info
函数返回DuckLake目录中存储的表信息:
FROM ducklake_table_info('my_ducklake');
返回的信息包括表名、模式ID、表ID、表UUID、文件数量、文件大小、删除文件数量和删除文件大小等。
ducklake_table_insertions
ducklake_table_insertions
函数返回在给定快照版本或时间戳之间插入到表中的行:
FROM ducklake_table_insertions('my_ducklake', 'main', 'demo', 1, 2);
ducklake_table_deletions
ducklake_table_deletions
函数返回在给定快照版本或时间戳之间从表中删除的行:
FROM ducklake_table_deletions('my_ducklake', 'main', 'demo', 2, 3);
维护命令
DuckLake提供了几个用于维护的命令:
ducklake_cleanup_old_files
ducklake_cleanup_old_files
函数清理DuckLake中的旧文件:
SELECT * FROM ducklake_cleanup_old_files('my_ducklake', cleanup_all => true);
参数包括:
catalog
:目录名称cleanup_all
:是否清理所有旧文件(布尔值)dry_run
:是否只模拟而不实际删除(布尔值)older_than
:只清理早于指定时间戳的文件
ducklake_expire_snapshots
ducklake_expire_snapshots
函数使指定版本的快照过期:
SELECT * FROM ducklake_expire_snapshots('my_ducklake', versions => [1, 2]);
参数包括:
catalog
:目录名称versions
:要使其过期的快照版本数组older_than
:使早于指定时间戳的快照过期
ducklake_merge_adjacent_files
ducklake_merge_adjacent_files
函数合并存储中的相邻文件:
SELECT * FROM ducklake_merge_adjacent_files('my_ducklake');
这有助于优化存储和提高查询性能。
总结
DuckLake是一种基于纯SQL构建的全新数据湖仓格式,它通过将元数据管理委托给SQL数据库,同时保持数据存储在开放格式中,解决了传统数据湖仓架构的根本问题。它提供了简单性、可扩展性和速度三大优势,使数据管理更加高效和可靠。
DuckLake的设计理念是:在Blob存储上以开放格式存储数据文件,同时使用数据库管理元数据。这种方法避免了复杂的基于文件的元数据管理,简化了数据操作,并提供了完整的ACID事务支持。
通过DuckDB的ducklake扩展,您可以轻松地开始使用DuckLake,无论是在本地开发环境还是在生产系统中。该扩展支持多种存储系统和数据库后端,提供了灵活的部署选项。
如果您想以简单、可扩展且快速的Lakehouse格式使用数据,DuckLake是一个值得考虑的选择。我们期待看到您的使用案例!在这里插入图片描述