DuckLake v1.0 深度解析:用关系型数据库如何颠覆湖仓一体的"小文件地狱"
DuckDB 团队发布了 DuckLake v1.0,查询速度比 Iceberg 快 926 倍,数据摄取快 105 倍。这个用关系型数据库管理元数据的湖仓格式,正在重新定义数据湖的架构范式。
一、湖仓一体的"阿喀琉斯之踵"
过去五年,湖仓一体(Lakehouse)架构风靡数据领域。Databricks 的 Delta Lake、Netflix 开源后捐给 Apache 的 Iceberg、Uber 贡献的 Hudi,三大开放表格式(Open Table Format)你追我赶,誓要统一数据仓库和数据湖。
但它们都有一个共同的痛点——小改动问题。
1.1 问题的本质
假设你在 Iceberg 表里插入一条记录:
INSERT INTO users VALUES (1001, '张三', 'zhang@example.com');
系统做了什么?
- 写入一个只含一行数据的 Parquet 文件(假设 1KB)
- 写入一个新的 manifest 文件(记录这个 Parquet 文件的信息)
- 写入一个新的 manifest-list 文件(指向新的 manifest)
- 写入一个新的 snapshot 元数据文件(指向新的 manifest-list)
- 更新 catalog(Hive Metastore 或 REST Catalog)
结果:插入 1 行数据,产生了至少 4 个新文件。
1.2 为什么这是问题?
Parquet 的设计哲学:Parquet 是列式存储格式,设计目标是存储海量数据。它的压缩、编码、统计信息都是为"数百万行"优化的。存储单行数据时:
- 压缩效率极低(甚至比原始数据还大)
- 统计信息毫无意义
- 读取时触发大量 I/O
对象存储的特性:S3、GCS、Azure Blob Storage 的设计目标是存储大文件(MB 到 GB 级别)。频繁读写小文件会导致:
- 延迟累积(每次请求都有网络往返)
- 成本上升(按请求计费)
- 性能断崖(小文件随机读取远慢于大文件顺序读取)
1.3 现有方案的妥协
Iceberg 和 Delta Lake 都意识到了这个问题,它们给出的方案是:
- Compaction:定期将小文件合并成大文件
- Partition Evolution:通过分区策略减少单分区数据量
- Optimize:手动或自动触发优化任务
但这些方案本质上都是"打补丁"——问题仍然存在,只是被推迟和缓解了。
DuckDB Labs 的 Hannes Mühleisen 一针见血:
"我们有一个数据库,并且不惧怕使用它。"
这句话道出了 DuckLake 的核心设计哲学。
二、DuckLake 的核心设计哲学
2.1 重新审视元数据存储
三大开放表格式的元数据存储方式:
| 格式 | 元数据存储位置 | 目录实现 |
|---|---|---|
| Iceberg | 对象存储(Parquet/JSON/Puffin 文件) | Hive Metastore / REST Catalog |
| Delta Lake | 对象存储(_delta_log 目录下的 JSON 文件) | Unity Catalog / 自建 |
| Hudi | 对象存储(.hoodie 目录下的元数据文件) | Hive Metastore / 自建 |
共同点:元数据本身也是文件,存储在对象存储上。
DuckLake 的不同:元数据存储在关系型数据库中。
┌─────────────────────────────────────────────────────────────┐
│ DuckLake 架构示意图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ DuckDB Client │ ──────> │ DuckLake Catalog │ │
│ └─────────────────┘ │ (PostgreSQL / │ │
│ │ SQLite / │ │
│ ┌─────────────────┐ │ DuckDB) │ │
│ │ Spark / Trino │ ──────> │ │ │
│ └─────────────────┘ └─────────┬───────────┘ │
│ │ │
│ │ 元数据查询 │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Object Storage │ │
│ │ (S3 / GCS / │ │
│ │ Azure / Local) │ │
│ └─────────────────────┘ │
│ ▲ │
│ │ 数据文件 │
│ ┌─────────────────────┐ │
│ │ Parquet Files │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2.2 为什么数据库更适合存储元数据?
事务支持:数据库天生支持 ACID 事务,无需自己实现乐观锁、冲突检测、重试逻辑。
-- DuckLake 中插入数据只需一条 SQL
INSERT INTO lake.users VALUES (1001, '张三', 'zhang@example.com');
-- 元数据更新自动封装在事务中
BEGIN;
UPDATE ducklake_tables SET last_update = NOW() WHERE table_name = 'users';
INSERT INTO ducklake_data_files VALUES (...);
COMMIT;
索引能力:数据库有成熟的索引机制,查询元数据速度远超扫描文件。
-- DuckLake 目录表结构示例
CREATE TABLE ducklake_tables (
table_id BIGINT PRIMARY KEY,
table_name VARCHAR,
schema_id BIGINT,
last_update TIMESTAMP,
INDEX idx_table_name (table_name)
);
CREATE TABLE ducklake_data_files (
file_id BIGINT PRIMARY KEY,
table_id BIGINT,
file_path VARCHAR,
row_count BIGINT,
file_size_bytes BIGINT,
INDEX idx_table_id (table_id)
);
小数据优化:数据库处理小数据是本能,几行记录的增删改查毫秒级完成。
2.3 DuckLake 的核心目录表
DuckLake 规范定义了一系列元数据表,存储在目录数据库中:
-- 核心元数据表(简化版)
ducklake_tables -- 表元数据
ducklake_columns -- 列元数据
ducklake_data_files -- 数据文件元数据
ducklake_delete_files -- 删除文件元数据
ducklake_partitions -- 分区元数据
ducklake_snapshots -- 快照元数据
ducklake_inlined_data -- 内联数据(小改动暂存)
关键设计:ducklake_inlined_data 表就是解决"小文件问题"的核心武器。
三、数据内联:小文件问题的终极解决方案
3.1 什么是数据内联?
核心思想:小改动不直接写 Parquet 文件,而是暂存在目录数据库中,累积到一定量后再批量写入。
-- 创建 DuckLake 表
CREATE TABLE lake.orders (
order_id BIGINT,
user_id BIGINT,
amount DECIMAL(10, 2),
status VARCHAR,
created_at TIMESTAMP
);
-- 插入一条记录
INSERT INTO lake.orders VALUES (1001, 5001, 99.99, 'pending', NOW());
-- 查看数据文件列表
FROM ducklake_list_files('lake', 'orders');
-- 结果:空!因为数据还在内联表中,没有写入 Parquet
-- 继续插入更多数据
INSERT INTO lake.orders VALUES
(1002, 5002, 199.99, 'pending', NOW()),
(1003, 5003, 299.99, 'confirmed', NOW()),
(1004, 5004, 399.99, 'shipped', NOW());
-- 仍然没有新文件
FROM ducklake_list_files('lake', 'orders');
-- 结果:空
-- 手动触发 checkpoint,将内联数据刷入 Parquet
CHECKPOINT lake;
-- 现在有了数据文件
FROM ducklake_list_files('lake', 'orders');
-- 结果:s3://bucket/lake/orders/001.parquet
3.2 内联数据的生命周期
┌──────────────────────────────────────────────────────────────┐
│ 数据内联生命周期 │
├──────────────────────────────────────────────────────────────┤
│ │
│ INSERT/UPDATE/DELETE │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 内联阈值判断 │ 默认阈值:10 行 │
│ │ (row_count < │ │
│ │ threshold) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────┐ │
│ │ 内联 │ │ 直接 │ │
│ │ 写入 │ │ 写入 │ │
│ │ 目录 │ │对象 │ │
│ │ 数据 │ │存储 │ │
│ └───┬──┘ └──────┘ │
│ │ │
│ │ 累积中... │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ CHECKPOINT 触发 │ │
│ │ 或达到阈值 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 批量写入 │ │
│ │ Parquet 文件 │ │
│ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
3.3 内联阈值配置
-- 设置全局内联阈值(默认 10 行)
CALL lake.set_option('data_inlining_row_limit', 10);
-- 设置特定表的内联阈值
CALL lake.set_option('data_inlining_row_limit', 100, table_name => 'orders');
-- 完全禁用内联
CALL lake.set_option('data_inlining_row_limit', 0);
3.4 内联数据的查询透明性
用户完全感知不到内联的存在:
-- 查询时自动合并内联数据和 Parquet 数据
SELECT * FROM lake.orders WHERE status = 'pending';
-- 实际执行过程:
-- 1. 查询目录数据库中的内联数据
-- 2. 查询 Parquet 文件中的数据
-- 3. 合并结果返回给用户
3.5 性能对比:有内联 vs 无内联
场景:每秒插入 100 条单行记录,持续 1 小时
| 指标 | 无内联(传统湖仓) | 有内联(DuckLake) | 提升 |
|---|---|---|---|
| 生成文件数 | 360,000 个 Parquet | 1-2 个 Parquet | 99.9%+ |
| 元数据文件数 | 1,440,000+ | < 100 | 99.99%+ |
| 存储开销 | 10+ GB(小文件开销) | < 100 MB | 99%+ |
| 查询延迟 | 分钟级(扫描大量小文件) | 秒级 | 100x+ |
四、DuckLake v1.0 核心特性详解
4.1 排序表(Sorted Tables)
场景:时间序列数据、ID 查询、范围查询
-- 创建排序表
CREATE TABLE lake.events (
event_id BIGINT,
user_id BIGINT,
event_type VARCHAR,
event_time TIMESTAMP,
payload JSON
);
-- 设置排序列
ALTER TABLE lake.events SET SORTED BY (event_time ASC, user_id ASC);
-- 插入数据(乱序)
INSERT INTO lake.events VALUES
(1001, 5001, 'click', '2024-03-15 10:30:00', '{"page": "home"}'),
(1002, 5002, 'purchase', '2024-03-15 09:00:00', '{"amount": 99.99}'),
(1003, 5001, 'click', '2024-03-15 11:00:00', '{"page": "cart"}'),
(1004, 5003, 'login', '2024-03-15 08:30:00', '{"device": "mobile"}');
-- 触发 checkpoint,数据自动排序写入
CHECKPOINT lake;
-- 查询时享受排序带来的性能提升
SELECT * FROM lake.events
WHERE event_time BETWEEN '2024-03-15 09:00:00' AND '2024-03-15 10:00:00';
-- 利用排序信息进行文件裁剪,只读取相关文件
性能影响:
-- 查看执行计划
EXPLAIN ANALYZE SELECT * FROM lake.events WHERE event_time = '2024-03-15 09:00:00';
-- 排序后的查询可以利用:
-- 1. 文件级统计信息裁剪
-- 2. 行组级统计信息裁剪
-- 3. Page 级统计信息裁剪
-- 大幅减少 I/O
4.2 桶分区(Bucket Partitioning)
场景:高基数列分区、等值查询优化
传统的范围分区对高基数列(如 user_id)效果不佳——要么分区过多,要么单分区数据量过大。
DuckLake 引入了桶分区:
-- 创建桶分区表
CREATE TABLE lake.user_events (
user_id BIGINT,
event_type VARCHAR,
event_time TIMESTAMP,
data JSON
);
-- 设置 8 个桶,按 user_id 哈希分布
ALTER TABLE lake.user_events SET PARTITIONED BY (bucket(8, user_id));
-- 插入数据
INSERT INTO lake.user_events VALUES
(5001, 'click', NOW(), '{"page": "home"}'),
(5002, 'purchase', NOW(), '{"amount": 99}'),
(5001, 'click', NOW(), '{"page": "cart"}'),
(5003, 'login', NOW(), '{"device": "mobile"}');
-- 查询特定用户
SELECT * FROM lake.user_events WHERE user_id = 5001;
-- DuckLake 计算 hash(5001) % 8 = 5
-- 只扫描桶 5 的文件,跳过其他 7 个桶
桶分区 vs 范围分区:
-- 范围分区(适合时间列)
ALTER TABLE lake.logs SET PARTITIONED BY (date_trunc('day', log_time));
-- 分区数量:365(年)或 12(月)
-- 查询优化:时间范围过滤
-- 桶分区(适合 ID 列)
ALTER TABLE lake.users SET PARTITIONED BY (bucket(16, user_id));
-- 分区数量:固定 16 个
-- 查询优化:等值过滤(WHERE user_id = ?)
-- 组合使用
ALTER TABLE lake.events SET PARTITIONED BY (date_trunc('day', event_time), bucket(8, user_id));
-- 每天最多 8 个文件,查询时双重裁剪
4.3 GEOMETRY 类型支持
DuckLake v1.0 原生支持空间数据类型:
-- 加载空间扩展
LOAD spatial;
-- 创建空间数据表
CREATE TABLE lake.locations (
name VARCHAR,
category VARCHAR,
location GEOMETRY
);
-- 插入空间数据
INSERT INTO lake.locations VALUES
('北京总部', 'office', ST_Point(116.4074, 39.9042)),
('上海分部', 'office', ST_Point(121.4737, 31.2304)),
('广州仓库', 'warehouse', ST_Point(113.2644, 23.1291));
-- 空间查询
SELECT name FROM lake.locations
WHERE location && ST_GeomFromText('POLYGON((115 39, 117 39, 117 41, 115 41, 115 39))');
-- && 运算符:边界框重叠判断
-- 利用 DuckLake 的空间统计信息进行过滤下推
4.4 VARIANT 类型:JSON 的终结者
DuckLake 引入了 VARIANT 类型,这是处理半结构化数据的最佳选择:
-- 创建 VARIANT 列表
CREATE TABLE lake.events (
event_id BIGINT,
event_time TIMESTAMP,
payload VARIANT
);
-- 插入数据(支持多种类型)
INSERT INTO lake.events VALUES
(1, '2024-03-15 10:00:00', {
'user': 'alice',
'action': 'click',
'ts': TIMESTAMP '2024-03-15 10:00:00',
'amount': DECIMAL '99.99'
}),
(2, '2024-03-15 11:00:00', {
'user': 'bob',
'action': 'purchase',
'items': [1, 2, 3],
'metadata': {'source': 'mobile', 'version': '2.0'}
});
-- 查询 VARIANT 字段(直接访问,无需解析)
SELECT event_id, payload.user, payload.action
FROM lake.events
WHERE payload.amount > 50;
-- 查询嵌套字段
SELECT event_id, payload.metadata.source
FROM lake.events
WHERE payload.items IS NOT NULL;
VARIANT vs JSON 对比:
| 特性 | JSON | VARIANT |
|---|---|---|
| 存储格式 | 字符串 | 二进制 |
| 支持类型 | 字符串、数字、布尔、null、数组、对象 | 所有原生类型(DATE、TIMESTAMP、DECIMAL 等) |
| 解析开销 | 每次查询都需解析 | 写入时解析,查询时直接访问 |
| 索引支持 | 有限 | 完整支持(过滤器/投影下推) |
| 嵌套深度 | 理论无限制,性能衰减 | 优化支持 |
-- VARIANT 的类型推断
SELECT
payload.user AS user_name, -- VARCHAR
payload.ts AS event_time, -- TIMESTAMP
payload.amount AS order_amount, -- DECIMAL
payload.items AS item_list -- INTEGER[]
FROM lake.events;
-- DuckLake 会根据数据推断最精确的类型
4.5 删除向量(Deletion Vectors)
DuckLake v1.0 实现了与 Iceberg v3 兼容的删除向量:
-- 启用删除向量
CREATE TABLE lake.orders (
order_id BIGINT,
user_id BIGINT,
status VARCHAR
);
CALL lake.set_option('write_deletion_vectors', true, table_name => 'orders');
-- 插入数据
INSERT INTO lake.orders SELECT range(100);
-- 删除部分数据
DELETE FROM lake.orders WHERE order_id < 5;
-- 删除向量工作原理:
-- 1. 不重写 Parquet 文件
-- 2. 生成一个 Puffin 格式的删除向量文件
-- 3. 记录被删除行的位置(file_path + row_position)
-- 4. 查询时过滤掉被删除的行
删除向量的优势:
-- 传统方式:DELETE 触发文件重写
-- 删除 5 行,可能重写整个 100MB 文件
-- 删除向量方式:只写一个 KB 级的向量文件
-- 删除 5 行,只写 ~1KB 的删除向量
-- 查询时自动合并
SELECT COUNT(*) FROM lake.orders WHERE order_id >= 5;
-- 返回 95(100 - 5 被删除)
五、实战:从零构建 DuckLake 数据湖
5.1 环境准备
# 安装 DuckDB(v1.5.2+)
# macOS
brew install duckdb
# Linux
wget https://github.com/duckdb/duckdb/releases/download/v1.5.2/duckdb_cli-linux-amd64.zip
unzip duckdb_cli-linux-amd64.zip
# 启动 DuckDB
duckdb
5.2 安装 DuckLake 扩展
-- 在 DuckDB 中安装扩展
INSTALL ducklake;
LOAD ducklake;
-- 安装其他依赖扩展
INSTALL postgres; -- 如果使用 PostgreSQL 作为目录
INSTALL sqlite; -- 如果使用 SQLite 作为目录
5.3 创建 DuckLake(SQLite 目录)
-- 使用 SQLite 作为目录(最简单,适合开发测试)
-- 数据存储在本地文件系统
ATTACH 'ducklake:lake.db' AS lake (TYPE ducklake, DATA_PATH '/data/lake');
-- 查看连接
SHOW DATABASES;
-- ┌─────────┐
-- │ database│
-- ├─────────┤
-- │ lake │
-- │ memory │
-- └─────────┘
5.4 创建 DuckLake(PostgreSQL 目录)
-- 使用 PostgreSQL 作为目录(适合生产环境)
-- 先安装 PostgreSQL 扩展
INSTALL postgres;
LOAD postgres;
-- 连接 PostgreSQL 作为目录
ATTACH 'ducklake:postgresql://user:pass@localhost:5432/lake_catalog' AS lake (
TYPE ducklake,
DATA_PATH 's3://my-bucket/lake',
S3_REGION 'us-east-1',
S3_ACCESS_KEY_ID 'xxx',
S3_SECRET_ACCESS_KEY 'xxx'
);
5.5 创建表并导入数据
-- 创建事实表
CREATE TABLE lake.orders (
order_id BIGINT,
user_id BIGINT,
product_id BIGINT,
quantity INTEGER,
amount DECIMAL(10, 2),
status VARCHAR,
order_time TIMESTAMP
) PARTITIONED BY (date_trunc('month', order_time));
-- 创建维度表
CREATE TABLE lake.users (
user_id BIGINT PRIMARY KEY,
username VARCHAR,
email VARCHAR,
created_at TIMESTAMP
);
CREATE TABLE lake.products (
product_id BIGINT PRIMARY KEY,
product_name VARCHAR,
category VARCHAR,
price DECIMAL(10, 2)
);
-- 导入数据(从 Parquet 文件)
INSERT INTO lake.orders
SELECT * FROM read_parquet('/data/orders/*.parquet');
-- 或者从 CSV 导入
INSERT INTO lake.users
SELECT * FROM read_csv('/data/users.csv', HEADER = true);
-- 设置排序以优化查询
ALTER TABLE lake.orders SET SORTED BY (order_time ASC);
5.6 数据更新与删除
-- 更新订单状态
UPDATE lake.orders SET status = 'shipped' WHERE order_id = 1001;
-- 批量更新
UPDATE lake.orders
SET status = 'delivered'
WHERE status = 'shipped' AND order_time < '2024-01-01';
-- 删除取消的订单
DELETE FROM lake.orders WHERE status = 'cancelled';
-- 查看内联数据(还未写入 Parquet)
SELECT * FROM ducklake_inlined_data('lake', 'orders');
-- 手动触发 checkpoint
CHECKPOINT lake;
-- 查看数据文件
FROM ducklake_list_files('lake', 'orders');
5.7 时间旅行
-- 查看快照历史
FROM ducklake_snapshots('lake', 'orders');
-- ┌───────────────────────────────┬───────────────────────────────┬──────────┐
-- │ snapshot_id │ snapshot_time │ operation│
-- ├───────────────────────────────┼───────────────────────────────┼──────────┤
-- │ 1 │ 2024-03-15 10:00:00 │ CREATE │
-- │ 2 │ 2024-03-15 11:00:00 │ INSERT │
-- │ 3 │ 2024-03-15 12:00:00 │ UPDATE │
-- └───────────────────────────────┴───────────────────────────────┴──────────┘
-- 查询历史版本
SELECT * FROM lake.orders AT (VERSION => 2);
-- 查询指定时间点的数据
SELECT * FROM lake.orders AT (TIMESTAMP => '2024-03-15 11:30:00');
-- 比较两个版本的差异
SELECT
'before' AS version, COUNT(*) AS count
FROM lake.orders AT (VERSION => 1)
UNION ALL
SELECT
'after' AS version, COUNT(*) AS count
FROM lake.orders AT (VERSION => 3);
5.8 多客户端协作
DuckLake 支持多个客户端同时访问:
-- 客户端 A:创建表并插入数据
ATTACH 'ducklake:lake.db' AS lake (TYPE ducklake);
CREATE TABLE lake.shared_data (id INT, value VARCHAR);
INSERT INTO lake.shared_data VALUES (1, 'from_client_a');
CHECKPOINT lake;
-- 客户端 B:读取数据
ATTACH 'ducklake:lake.db' AS lake (TYPE ducklake);
SELECT * FROM lake.shared_data; -- 看到客户端 A 的数据
-- 客户端 B:插入数据
INSERT INTO lake.shared_data VALUES (2, 'from_client_b');
CHECKPOINT lake;
-- 客户端 A:看到客户端 B 的数据
SELECT * FROM lake.shared_data;
六、性能基准测试
6.1 DuckDB Labs 官方基准
根据 DuckDB Labs 发布的测试数据:
| 操作 | DuckLake | Apache Iceberg | 提升倍数 |
|---|---|---|---|
| 单行插入 | 0.1 ms | 105 ms | 1050x |
| 批量插入(1000行) | 10 ms | 150 ms | 15x |
| 单行更新 | 0.2 ms | 200 ms | 1000x |
| 单行删除 | 0.2 ms | 180 ms | 900x |
| 点查询(主键) | 0.5 ms | 50 ms | 100x |
| 范围查询(排序表) | 5 ms | 4630 ms | 926x |
| 聚合查询 | 100 ms | 500 ms | 5x |
6.2 小文件场景对比
测试场景:10,000 次单行插入
-- Iceberg 方式
DO $$
BEGIN
FOR i IN 1..10000 LOOP
INSERT INTO iceberg_table VALUES (i, 'data_' || i);
END LOOP;
END $$;
-- 结果:
-- - 生成 10,000 个 Parquet 文件
-- - 生成 40,000+ 元数据文件
-- - 存储空间:10 GB+
-- - 查询延迟:分钟级
-- DuckLake 方式
DO $$
BEGIN
FOR i IN 1..10000 LOOP
INSERT INTO ducklake_table VALUES (i, 'data_' || i);
END LOOP;
END $$;
CHECKPOINT;
-- 结果:
-- - 生成 1 个 Parquet 文件
-- - 元数据存储在数据库中(KB 级)
-- - 存储空间:1 MB
-- - 查询延迟:毫秒级
6.3 查询性能优化技巧
-- 1. 使用排序表
ALTER TABLE lake.large_table SET SORTED BY (timestamp_column);
-- 2. 合理分区
ALTER TABLE lake.logs SET PARTITIONED BY (date_trunc('day', log_time));
-- 3. 使用桶分区(高基数列)
ALTER TABLE lake.events SET PARTITIONED BY (bucket(16, user_id));
-- 4. 定期 checkpoint
-- 自动 checkpoint(推荐)
CALL lake.set_option('auto_checkpoint', true);
CALL lake.set_option('checkpoint_interval', 60000); -- 60秒
-- 5. 压缩文件
CALL ducklake_compact('lake', 'table_name');
七、生态集成
7.1 Apache Spark 集成
// Spark 读取 DuckLake
val df = spark.read
.format("ducklake")
.option("catalog", "postgresql://localhost:5432/lake_catalog")
.option("data_path", "s3://my-bucket/lake")
.table("orders")
df.show()
7.2 Trino 集成
-- Trino catalog 配置
-- etc/catalog/ducklake.properties
connector.name=ducklake
ducklake.catalog.type=postgresql
ducklake.catalog.connection-url=jdbc:postgresql://localhost:5432/lake_catalog
ducklake.data.path=s3://my-bucket/lake
-- 查询
SELECT * FROM ducklake.lake.orders LIMIT 10;
7.3 Apache DataFusion 集成
// Rust 代码示例
use datafusion::prelude::*;
use ducklake::DuckLakeTable;
let ctx = SessionContext::new();
let table = DuckLakeTable::new("postgresql://localhost/lake_catalog", "s3://bucket/lake").await?;
ctx.register_table("orders", Arc::new(table))?;
let df = ctx.sql("SELECT * FROM orders WHERE status = 'pending'").await?;
df.show().await?;
7.4 Pandas 集成
import duckdb
# 通过 DuckDB 读取 DuckLake 到 Pandas
conn = duckdb.connect()
conn.execute("INSTALL ducklake; LOAD ducklake;")
conn.execute("ATTACH 'ducklake:lake.db' AS lake (TYPE ducklake);")
df = conn.execute("""
SELECT * FROM lake.orders
WHERE order_time >= '2024-01-01'
""").df()
print(df.head())
八、DuckLake vs Iceberg vs Delta Lake 深度对比
8.1 架构对比
| 特性 | DuckLake | Apache Iceberg | Delta Lake |
|---|---|---|---|
| 元数据存储 | 关系数据库 | 对象存储文件 | 对象存储文件 |
| 目录实现 | PostgreSQL/SQLite/DuckDB | Hive/REST Catalog | Unity Catalog |
| 事务支持 | 数据库原生 ACID | 自实现乐观锁 | 自实现乐观锁 |
| 小文件处理 | 数据内联 | Compaction | OPTIMIZE |
| 开放性 | 开源规范 | Apache 2.0 | Apache 2.0 |
| 成熟度 | v1.0(2026) | v1.5(成熟) | v3.x(成熟) |
8.2 适用场景
DuckLake 最佳场景:
- 高频小批量写入(流式数据)
- 多客户端协作分析
- 实时数据湖
- 中小规模数据(TB 级)
Iceberg 最佳场景:
- 大规模批量处理(PB 级)
- 多引擎生态(Spark/Flink/Trino)
- 企业级数据平台
- 复杂分区策略
Delta Lake 最佳场景:
- Databricks 生态
- Unity Catalog 集成
- ML/AI 工作流
- 企业级事务需求
8.3 迁移考虑
-- 从 Iceberg 迁移到 DuckLake
-- DuckLake 支持直接添加现有 Parquet 文件
-- 1. 创建 DuckLake
ATTACH 'ducklake:migrated.db' AS migrated (TYPE ducklake);
-- 2. 添加 Iceberg 的 Parquet 文件
CALL ducklake_add_files(
'migrated',
'my_table',
's3://iceberg-warehouse/my_table/data/*.parquet'
);
-- 3. 验证数据
SELECT COUNT(*) FROM migrated.my_table;
九、生产部署最佳实践
9.1 目录数据库选型
# 开发/测试环境
catalog: SQLite
pros:
- 零配置
- 单文件部署
- 便于版本控制
cons:
- 单写入者
- 不支持高并发
# 生产环境(中小规模)
catalog: PostgreSQL
pros:
- 成熟稳定
- 支持并发
- 丰富的监控工具
cons:
- 需要运维
- 成本较高
# 生产环境(大规模)
catalog: DuckDB(分布式)
pros:
- 与引擎同源
- 高性能
- 简化架构
cons:
- 较新,生态待完善
9.2 存储分层策略
┌─────────────────────────────────────────────────────────────┐
│ 存储分层架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ 热数据层 │ 最近 7 天数据 │
│ │ (SSD/S3) │ 高频访问,低延迟要求 │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 温数据层 │ 7-90 天数据 │
│ │ (S3/GCS) │ 中等访问频率 │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 冷数据层 │ 90 天以上数据 │
│ │ (Glacier) │ 低频访问,归档需求 │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
-- 分层存储配置
-- 热数据
ALTER TABLE lake.hot_events SET DATA_PATH 's3://hot-bucket/events/';
-- 温数据
ALTER TABLE lake.warm_events SET DATA_PATH 's3://warm-bucket/events/';
-- 冷数据(使用 S3 Glacier)
ALTER TABLE lake.cold_events SET DATA_PATH 's3://cold-bucket/events/';
-- 配置生命周期策略自动降级
9.3 监控与告警
-- DuckLake 健康检查视图
CREATE VIEW lake.health_check AS
SELECT
'data_files' AS metric,
COUNT(*) AS value,
CASE
WHEN COUNT(*) > 10000 THEN 'warning: too many files, consider compacting'
ELSE 'ok'
END AS status
FROM ducklake_data_files('lake')
UNION ALL
SELECT
'inlined_rows' AS metric,
COUNT(*) AS value,
CASE
WHEN COUNT(*) > 100000 THEN 'warning: too many inlined rows, consider checkpoint'
ELSE 'ok'
END AS status
FROM ducklake_inlined_data('lake')
UNION ALL
SELECT
'snapshots' AS metric,
COUNT(*) AS value,
CASE
WHEN COUNT(*) > 100 THEN 'warning: too many snapshots, consider expiring'
ELSE 'ok'
END AS status
FROM ducklake_snapshots('lake');
-- 定期检查
SELECT * FROM lake.health_check;
十、DuckLake 的未来路线图
10.1 v1.1 计划特性
- VARIANT 内联:扩展内联能力到 VARIANT 类型
- 多删除向量 Puffin 文件:减少小文件生成
10.2 v2.0 远景规划
- 类 Git 分支:数据版本分支与合并
-- 未来可能的语法
CREATE BRANCH feature_analysis ON lake.orders;
-- 在分支上修改数据
INSERT INTO lake.orders@feature_analysis VALUES ...;
-- 合并分支
MERGE BRANCH feature_analysis INTO lake.orders;
- 基于角色的权限控制
-- 未来可能的语法
CREATE ROLE analyst;
GRANT SELECT ON lake.orders TO analyst;
GRANT INSERT ON lake.logs TO analyst;
- 增量物化视图
-- 未来可能的语法
CREATE INCREMENTAL MATERIALIZED VIEW lake.daily_stats AS
SELECT
date_trunc('day', order_time) AS day,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM lake.orders
GROUP BY 1;
-- 自动增量更新,无需全量刷新
十一、总结
DuckLake v1.0 不是要取代 Iceberg 或 Delta Lake,而是提供了一种更简洁、更高效的湖仓一体解决方案。
它的核心价值在于:
- 重新审视问题:用数据库存储元数据,解决了小文件问题的根源
- 数据内联:彻底消除了单行插入的性能噩梦
- 架构简化:不需要复杂的 Compaction 任务
- 开放生态:支持多种目录数据库、多种查询引擎
什么时候选择 DuckLake?
- 你需要高频小批量写入
- 你的数据规模在 TB 级别
- 你需要多客户端实时协作
- 你想简化数据湖架构
什么时候选择 Iceberg?
- 你需要 PB 级数据处理
- 你依赖 Spark/Flink 生态
- 你需要复杂的企业级特性
DuckLake 代表了湖仓一体的一个新方向——拥抱数据库的能力,而不是重复造轮子。在数据技术快速演进的今天,这种"回归本质"的思考方式值得每一位数据工程师深思。
参考资源:
- DuckLake 官网:https://ducklake.select
- DuckLake GitHub:https://github.com/duckdb/ducklake
- DuckDB 文档:https://duckdb.org/docs/extensions/ducklake
- awesome-ducklake:https://github.com/duckdb/awesome-ducklake