DuckDB 深度拆解:从嵌入式 OLAP 到异步 I/O 与 DuckLake——一个「不该存在」的数据库如何改写分析引擎的游戏规则
引言:为什么 DuckDB 不该存在
数据库世界有一条不成文的规矩:OLTP 用嵌入式(SQLite),OLAP 用分布式(ClickHouse、Presto、Spark)。嵌入式意味着单机、单进程、轻量;分布式意味着多节点、高吞吐、弹性伸缩。这两条路线在过去二十年泾渭分明。
然后 DuckDB 出现了。
2019 年,CWI(荷兰国家数学与计算机科学研究所)的研究者们发布了一个看似违背常识的项目:一个嵌入式的、列式的、面向分析型查询的数据库引擎。它跑在你的笔记本上,不需要服务器,不需要集群,甚至不需要网络——但它的查询性能可以匹敌很多分布式系统。
到 2026 年,DuckDB 已经成为数据工程领域增长最快的开源项目之一。它不只是一个「更好的 SQLite for analytics」,而是在重新定义嵌入式数据库的边界:
- Quack 协议:从纯嵌入式走向 C/S 架构
- 异步 I/O:v2.0 即将引入的异步读取管线,TPC-H 查询提速 3 倍
- DuckLake:用 SQL 构建的湖仓一体格式,挑战 Iceberg 和 Delta Lake
本文将从第一性原理拆解 DuckDB 的架构设计、核心创新和工程取舍,带你理解为什么这个「不该存在」的数据库正在改变整个数据生态。
一、架构总览:嵌入式 OLAP 的第一性原理
1.1 为什么需要嵌入式 OLAP
传统 OLAP 系统(ClickHouse、Druid、Apache Pinot)的架构是这样的:
客户端 → 网络 → 查询协调器 → 多个存储节点 → 磁盘/对象存储
这条链路的每一个环节都引入了延迟和复杂性。对于数据科学家在 Jupyter Notebook 里做交互式探索、对于应用开发者需要在代码里嵌入 SQL 分析、对于小型团队不想运维集群——这些场景下,传统 OLAP 的架构代价太高了。
DuckDB 的第一性原理设计是:把整个分析引擎塞进一个进程,通过低级 API 直接调用,绕过所有网络开销。
// DuckDB 的嵌入式调用方式——没有服务器,没有协议
#include "duckdb.hpp"
int main() {
duckdb::DuckDB db(nullptr); // 内存数据库
duckdb::Connection con(db);
// 直接执行 SQL,零网络开销
con.Query("CREATE TABLE lineitem AS SELECT * FROM read_parquet('data/lineitem.parquet')");
auto result = con.Query("SELECT l_returnflag, SUM(l_quantity) FROM lineitem GROUP BY 1");
// 结果就在内存里,直接迭代
for (auto& chunk : *result) {
for (idx_t i = 0; i < chunk.size(); i++) {
// 处理每一行...
}
}
}
这种设计的核心优势是:数据在进程内流转,没有序列化/反序列化开销,没有 TCP 往返延迟。对于交互式查询,这个差距是数量级的。
1.2 列式存储:为什么分析型查询必须列存
行存 vs 列存的选择不是技术偏好,而是工作负载决定的:
| 特性 | 行存(PostgreSQL/MySQL) | 列存(DuckDB/ClickHouse) |
|---|---|---|
| 单行读取 | ✅ 极快 | ❌ 需要重组多列 |
| 范围扫描 | ⚠️ 读取整行,浪费 | ✅ 只读目标列 |
| 聚合分析 | ❌ 全表扫描 | ✅ 列级向量化扫描 |
| 压缩比 | ⚠️ 同列类型混合 | ✅ 同类型连续存储,压缩 5-10x |
| 写入性能 | ✅ 单行插入快 | ❌ 批量写入才高效 |
DuckDB 的列存设计有一个关键选择:它不像 ClickHouse 那样使用稀疏索引(Mark),而是使用 Row Group 作为基本分区单元。每个 Row Group 包含固定数量的行(通常 122,880 行),存储为一个独立的 chunk。
// DuckDB 的列式数据组织
// 一个 Row Group 的内存布局
struct ColumnChunk {
vector<UnifiedVectorFormat> data; // 每列的压缩数据
validity_mask validity; // NULL 位图
Statistics statistics; // min/max/count 统计信息
CompressionType compression; // 压缩算法选择
};
// Row Group 是 DuckDB 的调度单位
// 一个查询会被拆分为多个 Row Group 级别的 Job
// 每个 Job 可以独立地在不同线程上执行
1.3 向量化执行引擎:一次处理一批,不是一行
DuckDB 的执行引擎是向量化的(Vectorized),这和 PostgreSQL 的火山模型(Iterator Model)有本质区别:
火山模型(PostgreSQL):
每次调用 next() 返回一行 → 大量虚函数调用开销
CPU 分支预测频繁失败 → 缓存命中率低
向量化模型(DuckDB):
每次调用 next() 返回一批行(Vector,通常 2048 行)
→ 批量处理减少函数调用
→ SIMD 友好,编译器可以向量化
→ 缓存预取更有效
DuckDB 的执行流水线(Pipeline)由多个 Operator 组成,每个 Operator 处理一个 Vector(一批行)。关键的 Operator 包括:
- TableScan:从列存中读取 Row Group,解压缩,输出 Vector
- Filter:基于谓词过滤,使用 Selection Vector 实现行级过滤而不移动数据
- Projection:列投影,选择需要的列
- HashJoin:哈希连接,构建端和探测端分离
- GroupByHash:哈希聚合,支持 perfect hash 优化
- Window:窗口函数,支持 partition/order 分区
// DuckDB 的向量化执行示例
// 一个简单的 SELECT id, SUM(amount) FROM orders GROUP BY id
// 执行计划:
// Pipeline 1: TableScan → Filter → Projection → HashGroupBy
// Pipeline 2: (Pipeline 1 的结果) → ResultCollector
// TableScan Operator 输出 Vector
// 每个 Vector 包含 2048 行的 id 列数据
PhysicalTableScan → 输出 Vector<uint32_t> ids(2048)
// HashGroupBy Operator
// 使用 unordered_map<uint32_t, AggregateState> 做分组
// 每处理一个 Vector,更新一次聚合状态
// 避免了逐行调用 AggregateFunction::Update()
这种设计的核心收益是:批量处理减少了分支预测失败和虚函数调用的开销,同时让 CPU 的 SIMD 单元和缓存预取机制发挥作用。在实际基准测试中,DuckDB 的向量化引擎比逐行执行的引擎快 5-10 倍。
二、Quack 协议:当嵌入式数据库决定「出山」
2.1 为什么需要 C/S 架构
DuckDB 的嵌入式架构有一个天然限制:多进程无法并发写同一个数据库文件。这是因为 DuckDB 在内存中维护了大量状态(Buffer Manager、WAL、Transaction Manager),多进程同时写入会导致状态不一致。
社区对此的反应很有趣——各种 workaround 涌现:
- Arrow Flight SQL:通过 Apache Arrow 的 Flight 协议暴露 DuckDB 服务
- MotherDuck:自研 C/S 协议,把 DuckDB 放在云端
- pg_duckdb:在 PostgreSQL 里跑 DuckDB(所谓的「EleDucken」)
- 各种自定义 RPC 方案
这些 workaround 说明了一件事:用户需要多进程并发访问 DuckDB 的能力。
2.2 Quack 的设计哲学
2026 年 5 月,DuckDB 团队正式发布了 Quack 协议——一个全新的、基于 HTTP 的 C/S 协议。
Quack 的设计哲学可以总结为三点:
1. 基于 HTTP,而不是自创协议
# Quack 服务端——启动后监听 HTTP
CALL quack_serve('quack:localhost', token = 'super_secret');
# Quack 客户端——通过 HTTP 连接
ATTACH 'quack:localhost' AS remote;
FROM remote.hello;
为什么选 HTTP?因为 HTTP 生态已经非常成熟:
- 负载均衡(nginx、HAProxy、云 LB)天然支持
- 认证/授权(OAuth、JWT)有标准方案
- 防火墙和安全策略对 HTTP 友好
- DuckDB-Wasm 可以原生使用 Quack(浏览器里直接连远程 DuckDB)
2. 最小往返次数
传统数据库协议(如 PostgreSQL 的 extended query protocol)需要多次往返:Parse → Bind → Execute → Fetch。Quack 的设计目标是:一个查询只需要一次请求-响应往返。
// Quack 的请求-响应模型
// 客户端发送:查询 + 参数
// 服务端返回:结果集(流式传输)
// 序列化使用 application/duckdb MIME type
// 这是 DuckDB 内部的 WAL 序列化格式,已经过多年优化
3. 安全优先
Quack 默认绑定 localhost,启动时生成随机认证 token。它不默认启用 SSL——因为 localhost 通信不需要。如果要对外暴露,建议用 nginx 做反向代理并终结 SSL。
# 生产部署推荐:nginx 反向代理
# nginx 配置
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/db.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/db.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8361; # DuckDB Quack 默认端口
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
2.3 性能:百万行秒传
Quack 的性能基准非常亮眼。在官方测试中,百万行数据可以在几秒内通过 socket 传输。这得益于:
- DuckDB 内部的列式序列化格式天然适合批量传输
- 避免了行存协议(如 PostgreSQL text format)的序列化开销
- 支持并行 fetch:客户端可以从多个线程并行拉取结果集
# Python 客户端示例
import duckdb
# 本地 DuckDB 连接远程 Quack 服务
con = duckdb.connect()
con.sql("ATTACH 'quack:ec2-xxx.compute.amazonaws.com' AS remote (TOKEN 'xxx')")
# 查询远程数据——完全透明
result = con.sql("""
SELECT region, SUM(sales) as total_sales
FROM remote.analytics.events
WHERE date >= '2026-01-01'
GROUP BY region
ORDER BY total_sales DESC
""")
# 结果已经在本地内存中
df = result.fetchdf()
三、异步 I/O:v2.0 的杀手锏
3.1 问题:同步 I/O 在远程存储上的瓶颈
DuckDB 最初的设计假设是数据在本地 SSD 上。在这个场景下,同步 I/O 没有问题——SSD 延迟低(~100μs)、带宽高(~3GB/s),一个线程发起读请求后很快就能拿到数据。
但随着 DuckDB 被用于查询远程存储(S3、GCS)上的数据,同步 I/O 的问题暴露了:
同步 I/O 的时间线:
线程 1: [发送请求] ----等待网络---- [收到数据] [解码] [计算]
↑ 线程空闲时间 ↑
可能几百毫秒到几秒
远程存储的延迟通常在 10-100ms(同区域),这意味着线程在等待 I/O 时有大量空闲时间。64 个 vCPU 的机器上,如果每个线程都在等 I/O,CPU 利用率可能不到 5%。
3.2 异步 I/O 的架构设计
DuckDB v2.0 引入了异步 I/O 管线,核心设计是两个独立的线程池:
┌─────────────────────────────────────────────┐
│ DuckDB 异步 I/O 架构 │
├─────────────────────────────────────────────┤
│ │
│ REGULAR Pool (Worker Threads) │
│ ├── 默认:1 per CPU core │
│ ├── 执行实际工作:解码、Join、聚合 │
│ └── 空闲时可以执行 I/O 任务 │
│ │
│ ASYNC Pool (I/O Threads) │
│ ├── 默认:4 × CPU cores (max 256) │
│ ├── 执行阻塞式 I/O:HTTP 请求 │
│ └── CPU 利用率低,但数量多以隐藏延迟 │
│ │
└─────────────────────────────────────────────┘
**Read-Ahead Queue(预读队列)**是异步 I/O 的核心:
// Read-Ahead Queue 的工作流程
//
// 1. Worker 线程请求新的扫描任务
// 2. 检查队列是否有足够的预读任务
// 3. 如果不够,创建新的 Job(一个 Row Group)和 Fetch Task
// 4. Fetch Task 被调度到 ASYNC Pool
// 5. Job 被加入 Read-Ahead Queue,等待 I/O 完成
// 6. Worker 取出最老的 Job
// - I/O 完成 → 开始解码
// - I/O 未完成 → Park,去执行其他 Pipeline 任务
// 7. 最后一个 Fetch Task 完成时,唤醒 Parked 的 Scan Task
class ReadAheadQueue {
// 队列中同时存在的 Job 数量由 read_ahead_depth 控制
// 默认:-1(无限,由内存管理器控制预算)
// 正整数 N:最多 N 个 Job 预读
// 0:关闭预读,每个 Job 只为自己的数据发起 I/O
std::deque<ReadAheadJob> queue;
MemoryManager& memory_manager; // 与 Join/Sort 算子共享内存预算
// 关键:预读和内存管理器协同
// 当 HashJoin 使用大量内存时,队列会被压缩到 1 个 Job
// 当内存压力释放,队列自动填满
};
3.3 内存治理:预读不能 OOM
预读的核心风险是:如果解码速度慢于网络传输速度,预读的数据会在内存中堆积,最终导致 OOM。
DuckDB 的解决方案是与临时内存管理器(Temporary Memory Manager)协同:
内存分配策略:
┌─────────────────────────────────────┐
│ 总可用内存 = buffer_pool_size │
│ │
│ ├── HashJoin 构建端: 40% │
│ ├── Sort 缓冲区: 30% │
│ ├── Window 函数: 10% │
│ └── Read-Ahead Queue: 20% ← 可配置│
│ │
│ 当某个 Operator 需要更多内存时, │
│ 会向 MemoryManager 请求, │
│ Manager 会从其他 Pool 回收 │
└─────────────────────────────────────┘
这意味着:当一个大型 HashJoin 正在运行时,预读队列的深度会被自动压缩到接近 0——扫描行为退化为同步模式。当 HashJoin 完成释放内存后,预读队列自动恢复深度。
-- 配置异步 I/O 参数
SET enable_progressive_spilling = true; -- 溢出到磁盘
SET memory_limit = '8GB'; -- 总内存限制
SET read_ahead_depth = -1; -- 默认:由内存管理器控制
SET read_ahead_depth = 64; -- 或者:固定 64 个 Job 预读
SET read_ahead_depth = 0; -- 关闭预读(调试用)
3.4 Benchmark:TPC-H Q6 实测
官方在 EC2 r7i.16xlarge(64 vCPU, 512GB RAM)上测试了 TPC-H Q6(SF100,lineitem 表 6 亿行),数据存放在 S3:
| 版本 | 执行时间 | 加速比 |
|---|---|---|
| DuckDB v1.5.5(同步) | 8.230 s | 1.0x |
| DuckDB v2.0-dev(异步,内存管理) | 2.844 s | 2.9x |
| DuckDB v2.0-dev(异步,手动调优) | 2.227 s | 3.7x |
关键观察:
- 同步 I/O 时,网络吞吐量稳定在 ~5 Gbit/s(25 Gbit/s 的 20%)
- 异步 I/O 时,网络吞吐量接近 25 Gbit/s(接近网络上限)
- 手动调优(
async_threads=48, http_retries=8, http_retry_wait_ms=50)进一步降低了吞吐量波动
网络吞吐量对比:
v1.5.5 (同步): ████████░░░░░░░░░░░░░░░░ ~5 Gbit/s
v2.0 (异步): ████████████████████████░ ~23 Gbit/s
v2.0 (调优): █████████████████████████ ~25 Gbit/s (接近线速)
3.5 异步 I/O 的工程细节
DuckDB 的异步 I/O 实现有几个值得注意的工程细节:
1. Job 的粒度
对于 Parquet 文件,一个 Job = 一个 Row Group(通常 ~122,880 行)。一个 Row Group 可能被拆分为多个 Fetch Task,取决于查询的 Projection Pushdown:
// Parquet Fetch Task 的拆分逻辑
// 假设查询 SELECT a, c FROM parquet_file WHERE b > 100
// Row Group 包含列 a, b, c, d, e
//
// 只需要读取 a, c 两列(Projection Pushdown)
// 如果 a, c 在文件中的字节范围相邻,可以合并为一个 Fetch Task
// 否则拆分为两个 Fetch Task
vector<FetchTask> create_fetch_tasks(RowGroup& rg,
vector<column_t>& projected_columns) {
vector<FetchTask> tasks;
for (auto& col_range : merge_adjacent_ranges(rg, projected_columns)) {
tasks.push_back(FetchTask{
.url = rg.file_path,
.offset = col_range.start_byte,
.length = col_range.end_byte - col_range.start_byte
});
}
return tasks;
}
2. CSV 的特殊处理
CSV 没有 Parquet 那样的列级元信息,所以 Fetch Task 的粒度不同:
- 一个 Job = 一个扫描边界(固定字节范围)
- Fetch Task 加载起始 buffer,以及可能需要的后续 buffer(处理跨 buffer 的行边界)
3. Fetch Task 的并发执行
同一个 Job 的多个 Fetch Task 可以并发执行(没有特定的线程分配保证)。所有 Fetch Task 共享一个 countdown,最后一个完成的 Task 会将 Job 标记为 I/O 完成。
四、DuckLake:用 SQL 构建的湖仓一体格式
4.1 湖仓一体的元数据困境
Iceberg 和 Delta Lake 是当前最流行的湖仓一体格式。它们的核心设计是把元数据存储为文件(JSON/Parquet),散布在对象存储中:
Iceberg 元数据结构:
s3://bucket/
├── metadata/
│ ├── v1.metadata.json # 快照元数据
│ ├── v2.metadata.json
│ ├── v1.manifest-list.json # 清单列表
│ ├── manifest-0001.avro # 清单文件
│ └── ...
├── data/
│ ├── part-00000.parquet
│ └── ...
└── ...
这种设计的问题是:元数据操作变成了文件操作。列出所有快照需要扫描 metadata 目录;更新表 schema 需要写新的 metadata.json;做时间旅行查询需要递归解析 manifest 链。
4.2 DuckLake 的核心创新:元数据即 SQL
DuckLake 的核心洞察是:元数据应该存储在数据库里,而不是文件里。
-- DuckLake 的元数据存储在 SQL 数据库中
-- 支持 SQLite、PostgreSQL、DuckDB 作为 Catalog
-- 创建一个 DuckLake 表
ATTACH 'ducklake:postgresql://user:pass@host/catalog' AS lake;
CREATE TABLE lake.events (
id INT PRIMARY KEY,
event_type VARCHAR,
ts TIMESTAMP
);
-- 元数据操作变成 SQL 操作:
-- 列出所有表 → SELECT * FROM information_schema.tables
-- 查看表 schema → SELECT * FROM information_schema.columns WHERE table_name = 'events'
-- 查看快照历史 → SELECT * FROM ducklake_snapshots('lake')
-- 时间旅行 → SELECT * FROM lake.events FOR SYSTEM_TIME AS OF TIMESTAMP '2026-01-01'
这种设计带来了几个关键优势:
1. 元数据操作极快
Iceberg 列出所有快照:
→ ListObjects(metadata/) → 可能有数千个文件 → 排序 → 解析 JSON
DuckLake 列出所有快照:
→ SELECT * FROM ducklake_snapshots → 单条 SQL,毫秒级
2. 事务由 Catalog 数据库保证
Iceberg 写入流程:
1. 写 data 文件
2. 写 manifest 文件
3. 写 metadata.json
4. 原子性靠 object storage 的 conditional put 保证
DuckLake 写入流程:
1. 写 data 文件
2. 在 Catalog 数据库中执行 INSERT/UPDATE(事务由 DB 保证)
→ 天然支持 ACID
3. Data Inlining:小文件问题的终极解决方案
Lakehouse 的经典痛点是「小文件问题」——每次 INSERT 都会产生新文件,导致 metadata 膨胀和查询性能下降。
DuckLake 的解决方案是 Data Inlining:小的写操作(INSERT、UPDATE、DELETE)直接在 Catalog 数据库中执行,不产生新文件:
-- Data Inlining 默认开启,阈值 10 行
CREATE TABLE lake.t (id INT, status VARCHAR);
INSERT INTO lake.t VALUES (1, 'en route'), (2, 'shipped');
DELETE FROM lake.t WHERE id = 1;
UPDATE lake.t SET status = 'delivered' WHERE id = 2;
-- 此时没有产生任何新文件!
FROM ducklake_list_files('lake', 't'); -- 返回空
-- CHECKPOINT 时才刷到对象存储
CHECKPOINT;
FROM ducklake_list_files('lake', 't'); -- 现在有文件了
这意味着:对于高频率的小写入场景(如遥测数据采集),DuckLake 不会产生文件碎片。写入性能接近传统数据库,而查询性能不受影响。
4.3 DuckLake vs Iceberg vs Delta Lake
| 特性 | DuckLake | Iceberg | Delta Lake |
|---|---|---|---|
| 元数据存储 | SQL 数据库 | 文件(JSON/Avro) | 文件(JSON) |
| ACID 事务 | Catalog DB 保证 | 乐观锁 + conditional put | 乐观锁 + WAL |
| 小文件问题 | Data Inlining | 需要 Compaction | 需要 Compaction |
| 时间旅行 | SQL 查询 | 解析 manifest 链 | 解析 JSON 日志 |
| 生态系统 | DuckDB/Spark/DataFusion/Trino | Spark/Trino/Flink | Spark/Flink/Trino |
| 删除向量 | ✅(Puffin 文件) | ✅(v3 规范) | ❌ |
| 变体类型 | ✅ | ✅(v3 规范) | ❌ |
五、DuckDB 的查询优化器:被低估的核心
5.1 基于规则的优化(RBO)
DuckDB 的查询优化器借鉴了 PostgreSQL 的 PostgreSQL Query Optimizer(毕竟 parser 就是从 PostgreSQL fork 的),但做了大量改进:
谓词下推(Predicate Pushdown):
-- 原始查询
SELECT * FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.amount > 100;
-- 优化后:Filter 被下推到 TableScan
-- orders 的 Filter(amount > 100) 在 Scan 阶段就执行
PhysicalJoin(HashJoin)
├── PhysicalFilter(amount > 100) ← 下推
│ └── PhysicalTableScan(orders)
└── PhysicalTableScan(customers)
谓词合并(Filter Merge):
-- 多个 Filter 可以合并为一个
WHERE a > 1 AND b < 10 AND c = 'x'
→ PhysicalFilter(a > 1 AND b < 10 AND c = 'x')
5.2 基于代价的优化(CBO)
DuckDB 使用基于代价的优化器来选择 Join 算法和 Join 顺序:
// Join 算法选择
enum class JoinType {
HASH_JOIN, // 哈希连接:适合大表 JOIN
NESTED_LOOP, // 嵌套循环:适合小表
INDEX_JOIN, // 索引连接:有索引时
PIECEWISE_MERGE // 分段合并:已排序数据
};
// 代价估算基于:
// 1. 表的行数(来自 Statistics)
// 2. 列的基数(NDV - Number of Distinct Values)
// 3. 数据的排序状态
// 4. 内存可用量(影响 HashJoin 是否需要溢出)
5.3 Join 顺序优化
多表 Join 的顺序选择是 NP-hard 问题。DuckDB 使用动态规划(DP)来搜索最优的 Join 顺序:
-- 三表 Join
SELECT * FROM a
JOIN b ON a.id = b.a_id
JOIN c ON b.id = c.b_id
WHERE a.x > 100 AND c.y < 50;
-- 可能的 Join 顺序:
-- 1. (a ⋈ b) ⋈ c
-- 2. (a ⋈ c) ⋈ b ← 可能更优,如果 c.y < 50 过滤掉大部分 c
-- 3. (b ⋈ c) ⋈ a
-- DuckDB 的优化器会:
-- 1. 计算每个表的 Filter 后行数(基于统计信息)
-- 2. 评估每种 Join 顺序的代价
-- 3. 选择代价最低的顺序
六、与竞品的深度对比
6.1 DuckDB vs SQLite
两者都是嵌入式数据库,但设计目标完全不同:
SQLite: OLTP 嵌入式 → 行存 → 事务优化 → 并发读/写(WAL)
DuckDB: OLAP 嵌入式 → 列存 → 分析优化 → 并发读/单写
关键差异:
| 维度 | SQLite | DuckDB |
|---|---|---|
| 存储格式 | B-Tree(行存) | 列存 + Row Group |
| 查询模型 | 逐行迭代 | 向量化批量处理 |
| 并发写 | WAL 支持 | 不支持多进程写 |
| 适用场景 | 应用嵌入式存储 | 数据分析引擎 |
| 压缩比 | 低(同类型混合) | 高(同类型连续) |
6.2 DuckDB vs ClickHouse
两者都是列式分析引擎,但架构完全不同:
ClickHouse: 分布式 OLAP → 自研存储引擎 → MergeTree → 多节点
DuckDB: 嵌入式 OLAP → 标准 SQL → 列存 → 单节点
| 维度 | ClickHouse | DuckDB |
|---|---|---|
| 部署模式 | 分布式集群 | 嵌入式/C/S |
| SQL 方言 | 自有方言 | 标准 SQL |
| 写入 | 异步批量写入 | 支持 INSERT/UPDATE/DELETE |
| 事务 | 不支持 | 支持完整 ACID |
| 生态 | 自有生态 | 兼容 PostgreSQL/Parquet/Iceberg |
| 运维复杂度 | 高 | 低(单二进制文件) |
6.3 DuckDB vs Polars
Polars 是 Rust 编写的 DataFrame 库,和 DuckDB 在数据分析场景有重叠:
Polars: DataFrame API → Lazy Evaluation → Rust 原生性能
DuckDB: SQL API → 向量化执行 → C++ 性能
关键差异:
- API:Polars 用 DataFrame API(
df.filter().select().group_by()),DuckDB 用 SQL - 生态:DuckDB 可以直接读取 Parquet/CSV/JSON/Iceberg/Delta Lake,Polars 也需要
- 多语言:DuckDB 有 Python/R/Java/Wasm 绑定,Polars 主要 Rust/Python
- C/S:DuckDB 有 Quack 协议,Polars 没有
选择建议:如果你习惯 DataFrame API,用 Polars;如果你需要 SQL 或需要 C/S 架构,用 DuckDB。两者不互斥——DuckDB 可以读取 Polars 输出的 DataFrame。
七、生产实践与踩坑清单
7.1 性能调优清单
-- 1. 设置内存限制(重要!不设置会用满所有内存)
SET memory_limit = '16GB';
-- 2. 调整并发线程数
SET threads = 8; -- 默认是 CPU 核心数
-- 3. 启用并行扫描
SET enable_progressive_spilling = true;
-- 4. 配置异步 I/O(v2.0+)
SET read_ahead_depth = -1; -- 内存管理器控制
SET async_threads = 32; -- I/O 线程数
-- 5. 使用 temporary_directory 避免 OOM
SET temporary_directory = '/tmp/duckdb_spill';
-- 6. Parquet 文件优化
-- 每个文件的 Row Group 大小建议 100K-200K 行
-- 压缩算法用 zstd(默认)
7.2 常见踩坑
坑 1:多进程写冲突
# DuckDB 不支持多进程同时写入同一个 .duckdb 文件
# 会报错:Could not set lock on file
# 解决方案:使用 Quack 协议的 C/S 模式
坑 2:内存溢出
-- 查询大表时可能 OOM
-- 解决方案:
SET memory_limit = '8GB';
SET temporary_directory = '/tmp/duckdb_spill';
SET enable_progressive_spilling = true;
坑 3:Parquet 文件过小
# 如果 Parquet 文件太小(<1MB),扫描开销大于收益
# 建议:多个小文件合并为大文件
import duckdb
con = duckdb.connect()
con.sql("""
COPY (SELECT * FROM read_parquet('small_files/*.parquet'))
TO 'merged.parquet' (FORMAT PARQUET, ROW_GROUP_SIZE 200000)
""")
坑 4:类型不兼容
-- DuckDB 的类型检查比 PostgreSQL 更严格
-- 例如:VARCHAR 和 INTEGER 不能隐式转换
SELECT * FROM t WHERE id = '123' -- 可能报错
SELECT * FROM t WHERE id = 123 -- 正确
7.3 DuckDB 适用场景
✅ 非常适合:
- 数据科学探索(Jupyter Notebook)
- ETL 管道中的转换步骤
- 小团队不想运维数据库集群
- 应用内嵌入 SQL 分析能力
- 湖仓一体查询引擎(DuckLake + S3)
⚠️ 需要考虑:
- 高并发写入(>100 TPS)→ 用 PostgreSQL
- 超大规模数据(>1PB)→ 用 ClickHouse/Spark
- 实时流处理 → 用 Flink/Kafka Streams
八、总结与展望
DuckDB 的成功不是偶然。它的设计抓住了一个被忽视的需求:很多团队不需要分布式系统,他们只需要一个在笔记本上就能跑的高性能分析引擎。
2026 年,DuckDB 的三大创新正在改变游戏:
- Quack 协议:让嵌入式数据库走出单进程限制,支持 C/S 架构
- 异步 I/O:让远程查询的性能不再是瓶颈
- DuckLake:用 SQL 重新定义湖仓一体的元数据管理
展望 v2.0,DuckDB 的路线图还包括:
- 更多格式的异步 I/O 支持(Native format、JSON)
- Iceberg V3 完整兼容
- 更好的多租户支持
一个「不该存在」的数据库,正在证明一个道理:好的架构设计比「正确的技术路线」更重要。
参考资源
- DuckDB 官方博客:https://duckdb.org/news
- Quack 协议文档:https://duckdb.org/docs/current/quack/overview.html
- DuckLake 规范:https://ducklake.select/docs/stable/specification/introduction.html
- 异步 I/O 深度拆解:https://duckdb.org/2026/07/31/asynchronous-io.html
- TPC-H 基准测试:https://duckdb.org/docs/current/core_extensions/tpch