40 | 第三章:DuckLake 架构深度剖析
- 给对象存储带来不必要的压力
- 拖慢规划和文件发现速度
如第一章所述,DuckLake 通过数据内联避免了这个问题(小文件问题)。但这究竟是什么呢?
数据内联允许 DuckLake 将小型插入的数据临时直接存储在目录中,而不是立即在对象存储中创建 Parquet 文件。乍一看,这似乎与本章前面介绍的架构模型(目录存储元数据,对象存储数据文件)相矛盾。数据内联是该规则的一个有意为之的例外,旨在提高小型写入操作的效率。
如果没有数据内联,每次插入,即使只包含几行,都需要创建一个新的 Parquet 文件。随着时间的推移,这可能导致大量微小文件,增加存储开销,并对查询性能产生负面影响。通过将少量数据临时缓存在目录中,DuckLake 可以聚合多个小型写入,并最终将它们作为更大、更高效的 Parquet 文件刷新到对象存储。
此行为由 DATA_INLINING_ROW_LIMIT 设置控制。当事务中插入的行数小于或等于配置的限制时,行将内联存储在目录中。当插入的行数超过限制时,DuckLake 将数据直接写入对象存储中的 Parquet 文件。DATA_INLINING_ROW_LIMIT 的默认值为 10 行。为了演示目的,我们将使用一个较小的值,以便更容易观察行为:
让我们先创建一个干净的 DuckLake 目录,如下所示:
sql
-- 清理之前运行的内容
.shell rm -f -- "local_inst.ducklake"
.shell rm -rf -- "./dw3"
ATTACH 'ducklake:local_inst.ducklake' as my_ducklake
(
DATA_PATH './dw3',
DATA_INLINING_ROW_LIMIT 3
);
您会注意到,在创建 DuckLake 目录之前,我们使用了点命令 .shell。这会针对您的操作系统运行一条 shell 命令。使用此命令时请务必小心。我们在这里使用它来清理当前工作目录中本教程之前运行的所有内容,以防止显示旧的数据副本。
您会看到我们将 DATA_INLINING_ROW_LIMIT 设置为 3。现在我们已经建立了数据内联行限制,让我们创建一个表并插入一条记录:
sql
CREATE TABLE my_ducklake.test (id int);
INSERT INTO my_ducklake.test VALUES (1);
如果我们去检查该表的存储目录 ./dw3/main/test,您会注意到没有写入任何 Parquet 文件。这是因为我们的插入没有超过内联阈值。然而,如果我们再运行另一个插入 4 条记录的 SQL 语句(超过了我们 3 行的 DATA_INLINING_ROW_LIMIT),如下所示:
sql
INSERT INTO my_ducklake.test VALUES (2), (3), (4), (5);
您现在会看到该文件夹中有一个 Parquet 文件。这很合理,因为我们在单个事务中插入了 4 行,而内联限制是 3;但这里还有更多值得注意的地方;如果我们直接用以下 SQL 查询这个 Parquet 文件:
sql
SELECT * FROM read_parquet('./dw3/main/test/*') order by id;
我们期望的结果是什么?人们可能会认为是记录 1-5,但这实际上是不正确的!记录 1 仍然保存在 DuckLake 目录缓冲区中;这个 Parquet 文件只包含记录 2-5。这是否意味着我们无法在单个简洁的设置中同时查询缓存数据和 Parquet 文件?不,我们完全可以,因为如果我们改为针对 DuckLake 表进行查询,如下所示:
sql
SELECT * FROM my_ducklake.test order by id;
那么,DuckLake 足够智能,可以合并内联数据和未内联(写入磁盘)的数据。
Alex 的点评:当我第一次了解内联功能时,我以为它就像一个缓冲区(当内联行数超过某个总数时,它会将一批数据刷新到 Parquet------错了!)。然而,一个更好的类比是低通滤波器。声学中的低通滤波器只允许低音通过,因此它会滤除所有高频。这是过滤静态噪音,或让低音炮只专注于低音音符的好方法。在 DuckLake 中,只有小型插入会被内联,而较大的插入则直接通过到 Parquet 文件(而不会刷新已在目录中的现有小型插入集)。因此,思考内联的一种方式是,让 DuckLake 专注于批量输出那些悦耳的低音,而不受那些铙钹声的干扰。
为了将这些内容放到上下文中,并回答数据内联"那又怎样?"的问题,想象一下您正在将数据流式传输到日志表,并且到目前为止您已经作为单独的插入了 500 条记录;如果没有内联,这将产生 500 个独立的 Parquet 数据文件;然而,通过内联,您不会拥有那么多文件;数量会显著减少。假设我们的 DATA_INLINING_ROW_LIMIT 仍然是 10,那会是 50 个文件吗?不一定;这完全取决于每个事务插入的行数。如果所有 500 条日志流都是单独插入的,那么它们都将位于 DuckLake 目录缓冲区中。但是,您可以使用此 DuckLake 过程强制将这 500 条记录刷新到磁盘:
sql
CALL ducklake_flush_inlined_data('my_ducklake');
随着时间的推移,您需要监控内联数据并定期刷新目录,作为标准维护任务,以防止目录变得臃肿。
计算层
计算层是执行查询并生成结果的地方。在 DuckLake 中,计算被有意设计为无状态的,并且与目录和存储层解耦。这种分离允许计算资源根据工作负载需求独立扩展,而无需更改数据存储方式或元数据管理方式。
DuckLake 的主要执行引擎当然是 DuckDB。其他执行引擎选项包括 Apache Spark、Apache DataFusion 和 Trino。然而,DuckDB 与 DuckLake 配合得特别好,是参考实现,因此这是本节的重点。
需要记住的一个关键点是 DuckDB 在单节点上运行,因此要加速特定的 SQL 查询,您需要扩展单台机器的核心和 RAM。但考虑到当今计算机的强大功能,单台机器已经证明可以轻松处理数百 GB 的数据;因此,DuckDB 的计算架构应该能满足大多数工作负载。此外,这种单节点架构极大地简化了计算设置;您可以在笔记本电脑本地运行计算,在大型 AWS EC2 实例上运行,或在任何介于两者之间的环境上运行。
然而,对于 DuckLake 来说,这只是故事的一半。得益于管理并发性和可独立访问的对象存储的独立目录数据库,您可以根据需要使用任意数量的并行运行的单节点 DuckDB 实例。唯一的限制是每个 SQL 查询必须适合单个节点------您可以在不同的计算节点上并行运行多个 SQL 查询。这极大地扩展了 DuckDB 以前可以处理的工作负载类别。DuckLake 的架构通过最小化计算节点之间所需的协调量,充分利用了这一点。
从架构角度来看,计算层执行以下 3 项主要任务:
- 从目录读取元数据
- 从存储层读取数据文件
- 执行查询以生成结果
这里的一个关键点是计算不需要自己管理事务一致性。相反,它依赖 DuckLake 目录来提供一致的数据快照。这使得计算节点保持轻量级、可扩展,并且仅在需要时被调用。
虽然计算节点可以是临时的,但它们会缓存数据文件以便在多个查询中重用,因此为后续查询重用相同的计算环境可以显着提高速度。
为了进一步深入计算层,让我们考虑以下查询:
sql
SELECT order_id, order_date
FROM my_ducklake.cust_orders
WHERE order_id in (2,3);
计算引擎将执行以下步骤:
- DuckDB 解析语句并构建逻辑查询计划。
- DuckDB 向 DuckLake 目录请求相关的快照元数据。
- DuckLake 目录返回代表查询开始时间戳表状态的 Parquet 文件列表。
- DuckDB 仅从 DuckLake 提供的 Parquet 文件中读取必要的行组和列。
- DuckDB 执行查询。
- 结果返回给用户。
再次,我们想在这里强调,DuckLake 计算的一个重要方面是它可以在许多不同的环境中运行;这使得在本地测试、构建 CICD 管道和/或在云计算环境中调度作业变得容易。
从每个 Parquet 文件中只读取最少量的数据是计算层职责的关键组成部分。对于湖仓工作负载中涉及的大型数据集,缓存无法覆盖所有情况,因此读取尽可能少的数据是关键。第五章包含了性能调优建议,介绍如何尽可能多地跳过数据以实现更快的查询。
关注点分离
我们已经介绍了 DuckLake 的 3 个核心架构组件------目录、存储和计算。每个组件都专注于出色地完成一项工作,这是有意为之,并且旨在为用户提供一个可扩展、动态且灵活的数据管理环境。
换句话说:
- 目录提供一致性
- 存储层提供数据持久性
- 计算层提供性能
目录数据库内部机制
现在我们已经明确了 DuckLake 的 3 个架构组件,让我们深入了解 DuckLake 目录的内部机制。如果您想直观地查看数据模型,它发布在 DuckLake 的网站上,链接在此。乍一看,这个数据模型可能有点令人生畏。目前,目录中有 28 个表。然而,我们可以简化这个架构并将其分解为几个组,如下表所示:
| 类别 | 分组 | 说明 |
|---|---|---|
| 基础表 | 快照 | 保存数据快照信息;实现时间旅行和一致性的关键组件 |
| Schema | 存储所有表元数据,例如列、数据类型、视图 | |
| 数据文件 | 存储表到数据文件的映射 + 内联表数据;存储逻辑删除的表行指针 | |
| 选项 | 存储 DuckLake 配置参数的值 | |
| 统计信息 | 保存记录数、最大值/最小值等统计信息;对于查询规划和快速计数至关重要 | |
| 功能特定表 | 分区 | 存储已分区表的分区信息 |
| 排序 | 存储数据文件中行如何排序的信息 | |
| 宏 | 存储宏定义和宏参数 | |
| 映射 | 对于导入的预先存在的 Parquet 文件,将 Parquet 字段映射到 DuckLake 中的表列 | |
| 辅助 | 保存杂项数据,例如表和列的注释/标签;对 AI 和语义建模/上下文可能有用 |
如果您想实际查看表的内容是什么样子,它们都很容易查询。让我们考虑下面的例子,它创建了一个新的 DuckLake 目录,创建了一个简单的表,并插入了一行:
sql
-- 清理之前运行的内容
.shell rm -f -- "local_inst.ducklake"
.shell rm -rf -- "./dw3"
ATTACH 'ducklake:local_inst.ducklake' as my_ducklake (DATA_PATH './dw3');
CREATE OR REPLACE TABLE my_ducklake.orders
AS
SELECT 1 as order_id;
-- 切换 DuckLake 元数据目录以无需在查询前添加限定
USE __ducklake_metadata_my_ducklake;
现在,例如,如果我们想查看表本身如何在 DuckLake 目录中存储元数据,我们可以运行如下查询:
sql
SELECT * FROM ducklake_table;
此查询返回表 ID、其对应的快照信息以及实际表数据的路径。
您甚至可以使用此查询来查看表的列:
sql
SELECT * FROM ducklake_column;
这两个例子提供了探索内部目录表的简单方法;但让我们退后一步,从 30,000 英尺的高度来看待这一切。当用户运行查询从 DuckLake 表 SELECT 数据时,DuckLake 为 DuckDB 提供了如何连接这些内部元数据表以获得有效数据文件列表的蓝图,然后随后对它们执行查询。但 DuckDB 还会考虑查询的重要部分以进一步优化,例如分区上的过滤器、列的最大值和最小值等。因此,即使数据模型提供了所需数据文件的路径,DuckDB 在执行查询时也会在其查询规划中考虑更多因素。
如果您想知道所有这些目录表是如何创建的,它只是在 ATTACH 语句时发生的。如果您附加一个尚不存在的 DuckLake 目录,则会创建该目录,并自动创建所有元数据表。如果您附加一个已存在的 DuckLake 目录,DuckDB 将简单地附加它,而不会重新创建元数据表。生成这些表的脚本也可以在 DuckLake 文档中找到,我们之前已链接过。
至于目录如何处理事务,这又回到了我们之前提到的 DuckLake 内置的乐观并发控制(OCC)模型。OCC 使用基于重试的方法来处理事务冲突,而不是完全锁定。考虑到大多数 DuckLake 将使用对象存储来存储数据,这是有道理的;团队可以运行他们的 ETL 将数据文件加载到 S3,并且除非发生冲突的