第三章
DuckLake 架构深度剖析
写给早期读者的说明
通过早期电子书,您可以在书籍正式发布之前,就能以最早期的形式(即作者写作时的原始和未编辑内容)获取本书,从而尽早掌握这些技术。
这将是最终书籍的第 3 章。请注意,GitHub 仓库将在稍后激活。
如果您希望积极参与本草案的审阅和评论,请通过 gobrien@oreilly.com 联系编辑。
本章探讨 DuckLake 的内部设计及其架构背后的原理。我们将研究数据如何写入和存储、元数据如何管理,以及查询如何在系统中执行。通过本章学习,您将理解 DuckLake 如何协调目录、存储和计算组件,以高效管理大规模分析工作负载。
三组件架构
DuckLake 围绕三个主要架构组件构建:目录、存储层和计算层。
一个自然的问题是:为什么是三个独立的组件,而不是一个统一的系统?答案在于"关注点分离"原则。每个组件解决一个根本不同的问题,且每个问题的扩展方式也各不相同。
- 目录管理元数据:表定义、Schema 演变、事务历史以及对物理数据文件的引用。
- 存储层管理数据本身:通常是大型的不可变文件,必须随着数据量的增长而高效扩展。
- 计算层执行查询:从目录读取元数据、从存储读取数据以生成结果。
通过分离这些职责,DuckLake 允许每一层独立扩展,并使用最适合其角色的技术。
现代的湖仓系统已经证明了将元数据管理与数据存储紧密耦合的局限性。元数据工作负载通常需要快速的事务性更新和一致的状态管理,而存储系统则针对持久性、成本效益和水平扩展性进行了优化。随着数据量的增加,试图将这些关注点视为同一问题通常会导致性能瓶颈或操作复杂性。
DuckLake 有意避免了这种限制。目录专注于元数据一致性和事务完整性。存储层专注于高效存储大量数据。计算层专注于灵活高效的查询执行。
由于这些职责是解耦的,计算可以在任何需要的地方运行,存储可以随数据增长而扩展,并且目录可以维护系统的一致视图而不会成为瓶颈。
既然 DuckLake 建立在三个不同的架构组件之上,借助一个熟悉的思维模型来理解其设计会有所帮助。大多数数据工程师在日常工作中,作为 DevOps 生命周期的一部分,都会与 Git 打交道,而 Git 为理解 DuckLake 如何在其架构中分离职责提供了一个有用的类比。
Git 区分了描述仓库结构的元数据、底层文件内容以及用于与两者交互的操作。DuckLake 遵循类似的模式。
| DuckLake 组件 | Git 类比 | 角色 |
|---|---|---|
| 目录 | Git 提交历史和树 | 跟踪结构、版本和引用 |
| 存储 | Git 对象/文件 | 存储实际内容 |
| 计算 | Git 客户端操作 | 读取历史、合并更改等 |
现在我们已经概述了这 3 个组件,让我们深入了解每一个的更多细节。
目录层
在 DuckLake 的三层中,目录是系统的核心。它是所有操作的入口点,协调事务性保证,并维护表元数据的权威记录。由于每个读写操作都会以某种方式与目录交互,因此理解它如何履行这些职责对于构建 DuckLake 的坚实思维模型至关重要。
为了说明这一点,请考虑以下在 DuckLake 目录中创建新表的简单 SQL 语句:
sql
CREATE OR REPLACE TABLE my_ducklake.cust_orders (order_id int, order_date date);
尽管这看起来是一条简单的 SQL 命令,但目录内部会发生几个协调动作:
- 目录检查表是否已存在。因为语句包含
OR REPLACE,所以该表的任何现有元数据条目都将被逻辑上置为无效。 - 在 Schema 级别获取事务锁,以确保并发操作不会引入冲突的元数据更改。
- 写入新的目录条目,描述表定义,包括其 Schema、列类型和标识符。
- 事务提交,锁释放。
所有这些通常在毫秒内完成。DuckLake 不是直接向对象存储发出多个 API 调用,而是通过 SQL 目录路由操作,从而允许原子地协调元数据更新。这确保了 ACID 保证得到维护,并防止其他查询观察到部分或不一致的表定义。
现在,让我们更进一步,按如下方式插入单行数据:
sql
INSERT INTO my_ducklake.cust_orders VALUES (1, date '2025-01-01');
在后台,目录协调以下事件序列:
- 一个新的 Parquet 文件被写入为目录配置的存储层。
- 在表元数据上获取事务锁。
- 目录记录描述新写入文件的元数据。
- 创建额外的目录条目以捕获快照信息和数据统计信息。
- 事务提交,锁释放。
请注意,目录不存储实际数据本身;而是跟踪逻辑表与其物理数据文件之间的权威映射。
如果现在我们像下面的例子一样对表发出一个简单查询:
sql
FROM my_ducklake.cust_orders
将发生以下操作:
- 查询引擎建立事务开始时间戳。
- 扫描目录以确定在该时间戳下哪个表版本是有效的。
- 目录返回代表该表正确快照的数据文件列表。
- DuckDB 读取这些文件并生成查询结果。
重要的是,此读操作不需要阻塞锁。正如我们在前一章提到的,DuckLake 使用乐观并发控制(OCC)模型,这意味着读者可以针对目录的一致快照进行操作,而不会阻止并发写入。每个查询看到的是执行开始时数据库的确切状态。
随着时间的推移,可能会插入或修改更多行。偶尔,可能会发生错误------也许行被意外删除,或者更新引入了不正确的值。幸运的是,DuckLake 支持时间旅行查询,允许您查看表的先前版本。
以下是一个时间旅行查询的示例,选择 1 天前状态下表中的所有数据:
sql
SELECT *
FROM my_ducklake.cust_orders
AT (TIMESTAMP => NOW() - INTERVAL '1 day');
为了执行此查询,目录需要:
- 识别时间戳与请求时间点匹配的快照。
- 确定该快照对应的有效数据文件是哪些。
- 将相应的文件列表返回给 DuckDB 执行。
- 生成该时刻表存在时的结果。
虽然此示例简化了过程,但时间旅行突出了 DuckLake 的一个关键架构优势。由于目录维护结构化的快照元数据,DuckLake 可以快速确定有效文件的精确集合,而无需反复查询对象存储以获取元文件列表。
相比之下,许多传统的湖仓实现必须发出大量的对象存储 API 调用,检查多个文件中的元数据,并动态重建表状态。这可能会引入显著的延迟,特别是对于具有大量历史记录的大型表。
DuckLake 通过将元数据协调集中在 SQL 目录中来避免这种开销,使得快照解析能够在毫秒内完成,同时仍保留完整的事务性保证。
Matt 的点评:根据我的个人经验,能够进行时间旅行并回溯表之前的状态,在过去为我节省了大量时间。无论是需要在对数据更新后进行对账,还是我们加载了错误数据到表中,执行一个简单的时间旅行查询来将表恢复到之前的版本都带来了巨大的好处,这绝对比必须恢复整个数据库的副本然后再将旧版本的表拉取过来的老方法要强得多。
除了管理表元数据之外,目录还能够创建和管理视图和宏。
在 DuckLake 目录中创建视图非常简单:
sql
CREATE OR REPLACE VIEW my_ducklake.v_cust_orders
AS
SELECT *
FROM my_ducklake.cust_orders
WHERE order_id <= 3;
DuckDB 中的宏类似于其他数据库目录中的用户定义函数(UDF)。它们是封装业务逻辑并将其一致地应用于表和视图的优秀策略。下面是一个在 DuckLake 目录中注册宏的示例。该宏简单地判断提供的参数(此处为 order_id)是偶数还是奇数:
sql
CREATE OR REPLACE MACRO my_ducklake.is_even_order(order_id)
AS
CASE WHEN order_id % 2 == 0 THEN true ELSE false END;
要调用该宏,我们可以在查询中简单地调用它,如下所示:
sql
SELECT order_id
, my_ducklake.is_even_order(order_id) as is_even_ord
FROM my_ducklake.cust_orders;
您现在可能已经看出,DuckLake SQL 目录为您承担了大量繁重的工作,同时还为您提供了很大的灵活性。而且这种灵活性不仅限于单表操作。由于 DuckLake 是一个 SQL 目录,因此也支持多语句事务。考虑以下在单个事务中创建 2 个表的 SQL:
sql
BEGIN TRANSACTION;
CREATE OR REPLACE TABLE my_ducklake.orders AS
SELECT 1 as order_id, DATE '2024-01-01' AS order_date, 100.0 AS amount
UNION ALL
SELECT 2, DATE '2024-03-04', 150
;
CREATE OR REPLACE TABLE my_ducklake.customers AS
SELECT 1 as customer_id, 'Alice' as name
UNION ALL
SELECT 2, 'Bob'
;
COMMIT TRANSACTION;
DuckLake 将在单个事务中处理 orders 和 customers 表的创建------因此要么两个操作都成功,要么如果其中一个失败,则两者都失败。
Alex 的点评:拥有一个功能完备的数据库作为目录,在很多方面都非常有用。其他湖仓格式有复杂的元数据缓存系统,但在 DuckLake 中,目录数据库会自动为您处理。数据库内置的缓冲池会尽可能多地将数据保留在 RAM 中,实现无忧的速度。我们将在后续章节中看到一些调整目录的有趣方法。毕竟它就是一个数据库------我们知道如何摆弄它们!
存储层
乍一看,DuckLake 的存储层似乎微不足道:数据被写入 Parquet 文件。没有专有格式,没有专门的目录层次结构,也没有隐藏的二进制布局。DuckLake 表只是存储在用户定义位置的一组 Parquet 文件。
然而,简单并不代表幼稚。DuckLake 包含几种机制以确保这种简洁的设计在大规模下表现良好。其中最重要的机制之一是数据内联,它直接解决了众所周知的小文件问题。
当数据以非常小的批次插入时------例如,一次一行------一个简单的系统会为每次插入生成一个 Parquet 文件。随着时间的推移,这会导致成百上千个小文件,这些文件会:
- 降低查询性能
- 增加元数据开销