原文:https://duckdb.org/2026/05/07/delta-uc-updates
Delta 成长记:写入、Unity Catalog 和时间旅行
Ben Fleis
2026-05-07 ·
摘要: DuckDB 的 Delta 和 Unity Catalog 扩展摘掉了"实验性"标签------现已支持写入、Unity Catalog 和时间旅行。
欢迎回来!虽然我们 DuckDB Labs 的成员通常以"quack"(鸭子叫)著称,但最近我们像海狸一样忙碌,努力巩固我们的 Delta 扩展,为接下来的大事做准备......那就是 Unity Catalog!让我们来看看 DuckDB 的 Delta 和 Unity Catalog 扩展已经发展成熟到足以摘掉"实验性"标签的程度,并看看自我们上次更新以来发生了哪些变化。
是时候打开 Delta 了
在我们深入探讨之前,先简要回顾一下。Delta 是一个基础性的开放表格式和工具集,用于构建和管理数据湖,与 Iceberg 和其他数据湖仓格式相关。DuckDB 通过其 Delta 扩展来支持 Delta 表。
在上次更新中,我们重点介绍了性能提升,特别是通过过滤器下推实现的文件跳过,以及通过快照固定实现的元数据缓存。现在我们在此基础上,增加了写入、时间旅行和 Unity Catalog 支持,以及更多的性能提升!
建设 Delta Lake:写入功能
没有写入的读取有什么意思?自我们上次交流以来,最大的新增功能是支持 INSERT(插入)!它的使用方式简单如您所愿。假设您已经准备好了一个 Delta 表。直接执行 INSERT 即可,就这么简单:
sql
-- 表结构:(text VARCHAR, code BIGINT)
ATTACH './path/to/my_table' AS my_table (TYPE delta);
INSERT INTO my_table
VALUES ('Question 2', 2), ('The Answer', 42);
-- 从查询中进行批量插入
INSERT INTO my_table
FROM (SELECT text || ' (copy)', code + 100 FROM my_table);
值得一提的是,在 BEGIN / COMMIT 块内的多个 INSERT 操作将存储为单个 Delta 版本:一次原子提交,一条新的日志条目。而且,正如您稍后将看到的,这也适用于目录!UPDATE、MERGE 和 DELETE 尚不支持,但已列入我们未来的工作计划中。
时间旅行
DuckDB 的 Delta 扩展现在支持时间旅行。任何 Delta 表都可以查询其特定版本。DuckDB 支持在 ATTACH 时或作为单个查询的一部分绑定到特定的 VERSION。
假设我们逐步构建了上面的 my_table 表,其中版本 0、1 和 2 包含:
| 版本 | 内容 |
|---|---|
| 0 | ('Question 1', 1) |
| 1 | + ('Question 2', 2), ('The Answer', 42) |
| 2 | + ('Question 1 (copy)', 101), ('Question 2 (copy)', 102), ('The Answer (copy)', 142) |
您可以正常 ATTACH,并根据需要内联查询任意版本。最灵活的方法:
sql
ATTACH './path/to/my_table' AS my_table (TYPE delta);
SELECT count() FROM my_table AT (VERSION => 0); -- 1 (只有 Question 1)
SELECT count() FROM my_table AT (VERSION => 1); -- 3 (第一次插入后)
SELECT count() FROM my_table; -- 6 (最新)
或者,在 ATTACH 时固定到特定版本,当您希望获得一个稳定的、无论后续如何写入都不会改变的引用时,这很有用:
sql
-- 始终是版本1,无论后续写入什么
ATTACH './path/to/my_table' AS my_table_v1
(TYPE delta, VERSION 1);
SELECT count() FROM my_table_v1; -- → 3
-- 锁定到 ATTACH 时的最新版本
ATTACH './path/to/my_table' AS my_table_pinned
(TYPE delta, PIN_SNAPSHOT);
SELECT count() FROM my_table_pinned; -- → 6
成长:不再是"套件" 🦫
DuckDB 的 Delta 扩展不再是一个"套件",自一年前以来已经成熟了许多。正如您刚刚所见,我们添加了写入和时间旅行功能。这些功能为更大的事情打开了大门:与 Unity Catalog 的协调。
基于 Delta 的 Unity Catalog 支持
数据湖系统在规模上表现优异。随着您的数据资产成倍增长,您需要一种方法来发现存在什么,控制谁能访问它,审计它如何被使用,并跨多个引擎协调写入。数据目录正是为了解决这些需求而发展起来的,它位于存储层之上,管理元数据、治理和事务性账务,使大规模数据湖变得高效。如果您想更深入地了解,OSS Unity Catalog 团队有一个很好的概述;无论您使用哪个目录,其概念都广泛适用。
什么是 Unity Catalog?
Unity Catalog(简称 UC)是一个开放标准,用于跨引擎和云管理数据和 AI 资产,包括表、卷、模型和函数。它把您的数据湖变成了一个数据湖仓,并为您提供了一个统一的地方来发现、审计和控制对数据的访问,无论是什么在读取或写入数据。DuckDB 的 Unity Catalog 扩展是构建在 Unity Catalog 开放 API 之上的。有两个主要的实现:您可以自行托管的 OSS Unity Catalog(几分钟内即可 Docker 化),以及托管版本的 Databricks Unity Catalog。与 Delta 一样,DuckDB 的 Unity Catalog 扩展也已摘掉"实验性"标签。让我们将两者投入使用。
入门指南:OSS Unity Catalog
我们设置了一个 Docker 镜像游乐场,将 OSS Unity Catalog 和 DuckDB 捆绑在一起,因此您可以通过简单的 docker 构建和运行设置来跟随操作。如果您想亲身体验示例或进行实验,可以获取它。(如果您更愿意直接运行 OSS UC,官方镜像是我们游乐场的上游。)
让我们从 Docker 开始。假设您现在正在运行该镜像,它已经在构建阶段执行了(大致)以下步骤来准备我们的游乐场:
bash
# 创建一个 schema
/home/unitycatalog/bin/uc schema create --catalog unity --name my_schema
# 创建 "pets" 表
/home/unitycatalog/bin/uc table create \
--full_name unity.my_schema.pets \
--columns "uuid STRING, name STRING, age INT, adopted BOOLEAN" \
--format DELTA \
--storage_location file:///home/unitycatalog/etc/data/external/unity/my_schema/tables/pets
之后,我们可以从 DuckDB 进行测试。要亲自查看,运行 docker exec -it duckdb-playground duckdb 将为您提供一个容器内的 DuckDB shell。
在做任何有意义的事情之前,我们需要设置一个 DuckDB 密钥(secret)。在这个例子中,本地的 OSS UC 服务器会忽略 TOKEN 的值,但该字段是必需的。创建密钥,然后您可以立即 ATTACH 并读取:
sql
LOAD unity_catalog;
CREATE SECRET (
TYPE unity_catalog,
TOKEN 'demo-ignored-token',
ENDPOINT 'http://unitycatalog:8080'
);
ATTACH 'unity' AS my_catalog
(TYPE unity_catalog, DEFAULT_SCHEMA 'my_schema');
SELECT name, age, adopted FROM my_catalog.pets ORDER BY name;
-- 返回一个 'Seed' 行
就是这样!您刚刚查询了由 Unity Catalog 管理、以 Delta 格式存储的 pets 数据。
提示
想要在 Databricks Unity Catalog 上尝试这个?设置 Databricks Unity Catalog 超出了本文的范围,但如果您已经准备好了一个,您需要以下内容来引导 DuckDB:
- 将
ENDPOINT设置为您的工作区 URL(通常为:https://{instance}.cloud.databricks.com/) - 正确设置
TOKEN(例如,创建一个具有 unity-catalog 作用域的 PAT);获取正确的令牌完全取决于您的设置。要深入了解,请参阅 Unity Catalog 中的访问控制。
有了这些,您就可以直接使用 DuckDB,或者直接访问广泛的 UC Open API。
接下来,让我们完成这个循环,向我们 pets 表中写入一些数据:
sql
INSERT INTO my_catalog.pets
(uuid, name, age, adopted)
SELECT
gen_random_uuid()::VARCHAR,
['Luna', 'Milo', 'Bella', 'Charlie', 'Max', 'Lucy', 'Cooper',
'Daisy', 'Buddy', 'Lily', 'Rocky', 'Molly', 'Bear', 'Lola',
'Duke', 'Sadie', 'Tucker', 'Zoe', 'Oliver', 'Stella'
][1 + (random() * 19)::INT],
(1 + (random() * 14)::INT)::INT,
random() > 0.5
FROM range(10);
SELECT count() FROM my_catalog.pets;
您还可以轻松找到并查看创建的文件;检查本地数据目录(在 Docker 中也绑定了挂载),您应该会找到既有的文件和包含插入行的新 Parquet 文件。在我的例子中,它看起来像这样:
tree data
data
└── external
└── unity
└── my_schema
└── tables
└── pets
├── _delta_log
│ ├── 00000000000000000000.json
│ ├── 00000000000000000001.json
│ └── 00000000000000000002.json
├── duckdb-19cb47ae-9f35-4126-b67d-c94fcade68cc.parquet
└── duckdb-e3bb0336-f16a-4d21-9495-0fbf55c6cba8.parquet
7 directories, 5 files
目录托管表
了解了基础知识后,我们可以讨论目录托管表(Catalog Managed Tables,CMT)。这在今天的 OSS 和 Databricks Unity Catalog 中都可用。
CMT 的主要功能是目录提交(Catalog Commits),它支持协调的并发写入。没有目录提交,DuckDB 的写入会直接写入 Delta 日志。虽然现代存储后端可以防止直接的写入丢失,但 UC 完全被排除在循环之外。它的元数据、审计跟踪和统计信息会与实际表状态不同步,通过 UC 查询的其他引擎可能会看到过时的视图。
目录提交解决了这个问题:每次写入在被可见之前,都会通过 UC 进行暂存和注册。UC 充当提交仲裁者,保留第一个写入者的提交,并向后续写入者发送冲突错误。这在多个写入者同时追加数据时非常重要,例如并行 ETL 管道、分区批量加载和并发分析插入。每个写入者独立工作;UC 确保每个版本只有一个提交成功,并使其自身的目录与每一个提交保持同步。
一致的读取和审计历史分别是 Delta 和 UC 固有的特性。目录提交并没有增加新功能,它只是确保 UC 与每次提交保持同步。而且,目录提交是按表进行协调的;没有跨表的原子性。如果您在同一个 BEGIN / COMMIT 块中写入两个表,每个表都是独立提交的。
要选择让一个表使用 CMT(从而使用目录提交),需要在创建表时设置 delta.feature.catalogManaged 表属性。这需要通过 Spark 或 UC CLI 完成,因为 DuckDB 的 Unity Catalog 扩展尚不支持 CREATE TABLE DDL:
sql
-- 通过 Spark
CREATE TABLE my_catalog.my_schema.concurrent_tbl (
uuid STRING NOT NULL,
name STRING NOT NULL,
age INT NOT NULL,
adopted BOOLEAN NOT NULL
)
TBLPROPERTIES ('delta.feature.catalogManaged' = 'supported');
一旦启用,DuckDB 的写入会自动通过 UC 的提交暂存流程------INSERT 语法保持不变:
sql
INSERT INTO my_catalog.my_schema.concurrent_tbl
(uuid, name, age, adopted)
VALUES (gen_random_uuid()::VARCHAR, 'Luna', 3, true);
现在,每个 DuckDB 写入者在数据可见之前,都会将其提交暂存到 _staged_commits/ 目录并向 UC 注册。UC 进行仲裁:在竞争中,每个版本只有一个写入者获胜,其他写入者会收到冲突错误并可以重试。接下来,让我们看看 UC 如何处理竞争。
深入探讨
竞争提交
为了观察目录提交如何仲裁,我们启动了 20 个并发的 DuckDB 写入者,每次 8 个,全部插入到同一个托管表中:
bash
seq 1 20 | xargs -P 8 -I{} scripts/unity/05-cmc/write-single {}
[worker 6] OK - inserted 5 rows
[worker 5] CONFLICT - another writer won this version, retry needed
[worker 2] CONFLICT - another writer won this version, retry needed
[worker 8] CONFLICT - another writer won this version, retry needed
[worker 7] CONFLICT - another writer won this version, retry needed
[worker 3] CONFLICT - another writer won this version, retry needed
[worker 1] OK - inserted 5 rows
[worker 4] CONFLICT - another writer won this version, retry needed
[worker 16] OK - inserted 5 rows
[worker 13] CONFLICT - another writer won this version, retry needed
[worker 15] CONFLICT - another writer won this version, retry needed
[worker 11] CONFLICT - another writer won this version, retry needed
[worker 14] CONFLICT - another writer won this version, retry needed
[worker 12] OK - inserted 5 rows
[worker 9] CONFLICT - another writer won this version, retry needed
[worker 10] CONFLICT - another writer won this version, retry needed
[worker 17] CONFLICT - another writer won this version, retry needed
[worker 20] CONFLICT - another writer won this version, retry needed
[worker 18] OK - inserted 5 rows
[worker 19] CONFLICT - another writer won this version, retry needed
这里我们看到 5 次成功的写入,以及 15 次标记的冲突。让我们在数据中确认一下:
sql
SELECT count() AS total_rows FROM my_catalog.my_schema.concurrent_tbl;
┌────────────┐
│ total_rows │
│ int64 │
├────────────┤
│ 35 │
└────────────┘
10 行初始数据 + (5 次写入 × 每次 5 行) = 总共 35 行。(在实际工作负载中,您会重试冲突的写入,最终完成全部 20 次插入。)正如承诺的那样,目录托管表的提交在高并发写入期间为我们提供了清晰的信号和语义。
更快的时间旅行
DuckDB 的 Delta 快照加载速度正在得到提升:快照将在可能时增量加载,使得跨相邻版本的时间旅行显著加快。考虑一个表,其中一些初始查询是针对版本 16 进行的:
sql
ATTACH './path/to/table' AS t (TYPE delta, VERSION 16);
SELECT count() FROM t; -- → 17
现在需要针对版本 20 进行一些工作。如果我们窥探一下幕后(警告:以下是不宜公开的代码),我们会发现之前加载的 Delta 日志元数据文件都没有被重新加载:
sql
SET enable_logging = true;
SET delta_kernel_logging = true;
CALL enable_logging('DeltaKernel', level = 'trace');
ATTACH './path/to/table' AS t (TYPE delta, VERSION 20);
SELECT count() FROM t; -- → 21
-- Delta 内核日志在从头读取日志文件时会记录 'Provisionally selecting ... <version>.json'
-- 我们搜索任何引用以零填充的日志文件名的此类消息。
-- 零匹配意味着缓存的 v16 快照被重用,而不是被重建。
SELECT count() FROM duckdb_logs
WHERE type = 'DeltaKernel'
AND message LIKE '%00000000000000000%.json%';
-- → 0
在拥有数千或数百万个快照的 Delta 湖中,当跨多个版本工作时,增量加载提供了巨大的优势。
在撰写本文时,增量快照加载在 nightly 构建版本中受支持。您可以使用以下命令安装它:
sql
FORCE INSTALL delta FROM core_nightly;
请注意,nightly 构建版本不适用于生产环境。该实现将包含在下一个稳定版本 v1.5.3 中。
结论
一年前,DuckDB 可以读取 Delta 表。今天,它可以向其中插入数据,穿越其历史,并通过受治理的目录进行查询和写入------所有这些都不再带有"实验性"的警告。将 Delta 用于开放存储,Unity Catalog 用于治理和协调,以及 DuckDB 用于快速分析查询,这是一个您可以构建于此之上的技术栈。
未来还有更多:支持 DDL 以直接创建和管理表,支持删除/更新/合并,以及跨越多个表的写入的跨表原子性。与此同时,上面链接的游乐场镜像拥有您进行初步尝试所需的一切。与往常一样,欢迎在 GitHub 上提供反馈和错误报告。