DuckDB 深度拆解:当分析型数据库学会「嵌入式」——列式向量化引擎与 Morsel 驱动并行如何重写 OLAP 心智模型
引言:数据库世界的「供给侧革命」
如果你是一个后端工程师,你大概率经历过这样的场景:
线上 MySQL 跑了半年,orders 表两千万行,每到月底跑一次销售报表,SELECT region, SUM(amount) FROM orders GROUP BY region 这样的查询愣是跑了几十秒。DBA 说加索引,可 GROUP BY 的列组合千变万化,索引怎么加?最后你搭了条 ETL 管道把数据同步到 ClickHouse,多维护一套集群,成本翻倍,就为了跑个聚合查询。
2026 年的今天,这个问题有了一个让人拍大腿的答案:让「分析引擎」自己变成 MySQL 的一张表。
2026 年 7 月,Percona 发布了一个实验性项目 ducksdb-mysql-engine —— 改造 MySQL 9.7,让你可以这样建表:
CREATE TABLE sales (id INT PRIMARY KEY, region INT, amount DECIMAL(12,2)) ENGINE=DuckDB;
所有分析查询自动下推到嵌入的 DuckDB 引擎,TPC-H SF10 跑完 22 条查询只需要 15 秒,而 InnoDB 跑了 1317 秒 且 6 条查询直接超时。
这不是某个新数据库的 PR 稿。这是 DuckDB —— 一个「分析界的 SQLite」—— 正在从边缘走向中心的故事。
本文将从架构原理、执行引擎、生态系统、性能对比四个维度,深度拆解 DuckDB 为什么是 2026 年最值得每个后端工程师认真学习的数据库技术。
一、起源:为什么世界需要另一个数据库?
1.1 问题空间:OLTP 与 OLAP 的鸿沟
传统关系型数据库(MySQL、PostgreSQL)是行式存储的。写入一行数据时,所有字段连续地写在一个数据页上。这对点查询(SELECT * FROM users WHERE id = 42)非常友好——一次 I/O 就能拿到一整行。
但对分析查询,行式存储简直是灾难:
SELECT region, AVG(amount), COUNT(*)
FROM sales
WHERE date BETWEEN '2026-01-01' AND '2026-06-30'
GROUP BY region
ORDER BY AVG(amount) DESC;
这个查询只需要三列数据(region、amount、date),但行式存储会把每一行的所有字段都读进内存。如果你有 50 列,意味着你读了 94% 的无效数据。缓存污染、I/O 放大、内存带宽浪费,每一层都在亏。
1.2 已有方案为什么不够好?
| 方案 | 优点 | 缺点 |
|---|---|---|
| OLTP 数据库(MySQL/ PG) | 运维成熟,生态好 | 分析查询慢 100-1000 倍 |
| MPP 数仓(ClickHouse/ Greenplum) | 分析极快 | 需要独立集群,运维成本高 |
| 数据湖(Spark/ Trino) | 存算分离,弹性 | 延迟高,部署复杂 |
| ETL + 列存 | 灵活 | 数据管道维护成本,双份存储 |
DuckDB 的定位完美卡在缝隙中:嵌入式、零配置、列式存储、向量化执行。
它的核心洞察是:现代单机服务器的内存已经到了 TB 级别,SSD 到了几十 TB。绝大多数中小规模的分析工作负载(TB 级以下)根本不需要分布式。 一台机器的资源完全够用,缺的只是一个好用的本地分析引擎。
DuckDB 就是那个引擎。
1.3 从 MonetDB 到 DuckDB:学术血脉
DuckDB 诞生于荷兰 CWI(Centrum Wiskunde & Informatica)数据库研究组。这个组的历史作品包括 MonetDB 和 Vectorwise——分别是列式存储和向量化执行的先驱。DuckDB 的两位核心作者 Mark Raasveldt 和 Hannes Mühleisen 是 CWI 的研究员,他们的出发点很朴素:
为什么数据分析工具(R、Python pandas)要和数据库系统分离?不能有一个嵌入式的、SQL 驱动的分析引擎吗?
2019 年 SIGMOD,DuckDB 的 Demo 论文首次亮相。到今天,DuckDB GitHub Stars 已突破 30k,v1.5.4(Variegata)于 2026 年 6 月发布,v2.0 将在 2026 年秋季推出。从一个学术原型到生产可用的嵌入式 OLAP 数据库,DuckDB 只用了不到 7 年。
二、架构深度拆解:教科书级的模块化设计
DuckDB 的架构有多「教科书」?如果你读过 CMU 15445 的课程,你会发现 DuckDB 的模块划分几乎完美映射了数据库理论教材的章节结构。
┌─────────────────────────────────────────────┐
│ SQL Query │
├─────────────────────────────────────────────┤
│ SQL Parser (libpg_query) │
├─────────────────────────────────────────────┤
│ Logical Planner │
│ (Binder → Plan Generator → Logical Node) │
├─────────────────────────────────────────────┤
│ Optimizer │
│ (Filter Pushdown / Projection Pushdown / │
│ Common Subexpr Elimination / Join Order) │
├─────────────────────────────────────────────┤
│ Physical Planner │
│ (Cost Model → Physical Operator Selection) │
├─────────────────────────────────────────────┤
│ Execution Engine │
│ (Push-based Pipeline / Morsel-driven / │
│ Vectorized Processing / SIMD) │
├─────────────────────────────────────────────┤
│ Storage Layer │
│ (Columnar Layout / Compression / Checkpoint)│
└─────────────────────────────────────────────┘
2.1 SQL Parser:站在巨人肩膀上
DuckDB 没有从头写 SQL Parser,而是复用了 PostgreSQL 的 libpg_query。这意味着:
- DuckDB 的 SQL 语法和 PostgreSQL 高度兼容
- WITH 查询、窗口函数、CTE、子查询全部原生支持
pg_catalog风格的系统表查询
-- DuckDB 里这些 PostgreSQL 语法全都能用
SELECT region,
SUM(amount) AS total,
RANK() OVER (ORDER BY SUM(amount) DESC) AS rank
FROM sales
GROUP BY region
HAVING SUM(amount) > 1000;
2.2 Logical Planner:SQL → 逻辑计划
Parser 把 SQL 转成抽象语法树后,Logical Planner 接手做两件事:
- Binder:解析所有引用的 schema 对象(表、视图、列),验证类型,解析函数重载
- Plan Generator:生成逻辑计划节点树
比如 SELECT a, b FROM t WHERE c > 10 会生成:
LogicalProjection (a, b)
└── LogicalFilter (c > 10)
└── LogicalGet (t)
这里的关键是:逻辑计划不关心数据怎么读,它只表达「要什么」。
2.3 Optimizer:8 个优化器的流水线
DuckDB 的优化器非常有意思,它像工厂流水线一样把 8 个独立的优化器串联起来:
| 优化器 | 作用 |
|---|---|
| Expression Rewriter | 常量折叠、表达式简化。1 + 2 * 3 → 7 |
| Filter Pushdown | 把过滤条件下推到 scan 层,减少读取量 |
| Projection Pushdown | 只读取查询需要的列,不读全行 |
| Common Subexpr Elimination | 消除重复计算,比如 WHERE f(x) > 0 AND f(x) < 10 只算一次 f(x) |
| Join Order Optimizer | 基于基数估计重新排列 JOIN 顺序 |
| Column Binding | 消除多余的列绑定 |
| Statistics Propagation | 传播统计信息,辅助后续优化 |
| Simplify | 最终简化:消除无用投影、合并过滤条件 |
Filter Pushdown 实战效果:
假设你有 10 亿行 Parquet 文件,执行:
SELECT region, SUM(amount)
FROM 'sales.parquet'
WHERE date >= '2026-01-01' AND date < '2026-02-01'
GROUP BY region;
如果没有 Filter Pushdown,DuckDB 需要扫描整个 Parquet 文件的所有行组(Row Group)。有了 Pushdown,DuckDB 会:
- 读取 Parquet 的统计信息(min/max 索引)
- 跳过所有不包含 2026-01 范围内数据的 Row Group
- 在命中的 Row Group 中,只读取 region 和 amount 两列(Projection Pushdown)
这个过程叫做 统计信息裁剪 + 谓词下推,通常能跳过 80-95% 的数据。
2.4 Physical Planner:逻辑 → 物理
优化后的逻辑计划会被 Physical Planner 转换成物理算子。和逻辑计划的不同之处在于:物理算子知道怎么执行。
比如逻辑上的 LogicalAggregate 可能被转换为:
PhysicalHashAggregate:数据量大时用 Hash 聚合PhysicalPerfectHashAggregate:GROUP BY key 是连续的整数时用完美哈希(更快)PhysicalStreamingAggregate:数据已排序时用流式聚合(无 Hash 开销)
选择哪个物理算子取决于成本估算,但目前 DuckDB 的成本模型相对简单——它更多依赖启发式规则而非完整的 CBO(基于成本的优化器)。
三、执行引擎:当「向量化」遇上「Morsel 驱动」
这是 DuckDB 最核心、最精彩的部分。如果说 SQLite 是「一行一行地处理」,ClickHouse 是「一批一批地处理」,那 DuckDB 就是「一批一批地处理 + 流水线并行」。
3.1 向量化执行:别碰 CPU 缓存
传统数据库的 Volcano 模型是一行一行处理的:
// 每调用一次 Next() 返回一行
while (row = child.Next()) {
if (row.age > 18) result.Add(row);
}
这种模式的问题在于:CPU 的指令流水线被频繁打断。每次返回一行,就要做一次虚函数调用、一次类型分发、一次条件判断。CPU 的分支预测器几乎一直在猜错。
DuckDB 采用向量化执行:每次处理一批数据(默认 2048 行),以列式向量为单位操作。
// DuckDB 的向量化:一次处理 2048 行的 age 列
Vector age_vector = child.GetVector(2048);
for (idx_t i = 0; i < 2048; i++) {
result[i] = age_vector[i] > 18;
}
// 用一个 selection vector 筛选出满足条件的行索引
这意味着:
- 数据局部性好:2048 个 int32 连续排布在内存中 = 8KB,正好塞进 L1 Cache
- 循环体积小:热循环可以保持在 CPU 的微操作缓存(μop cache)中
- 分支预测友好:一次模式化的批量操作,相比一行一行分发,分支预测错误率大幅下降
- SIMD 向量化:编译器可以自动向量化这种规整的循环
实际的性能差异:
DuckDB 团队在论文中给出的数据:对于 SELECT SUM(v) FROM table,Volcano 模型的吞吐约为 0.5 GB/s(按数据从存储到处理的总带宽),而 DuckDB 的向量化执行可以达到 5-10 GB/s(受内存带宽限制)。
3.2 Morsel 驱动并行:现代 CPU 的 NUMA 答案
2014 年,德国慕尼黑工业大学(就是搞 Hyper 数据库那个组)发表了一篇重要的论文:《Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework for the Many-Core Age》。
论文的核心洞察是:
现代服务器 CPU 有几十个核,但内存访问不是均匀的(NUMA)。如果你用一个全局线程池、共享一个任务队列,线程在 NUMA 节点间迁移会导致大量的缓存未命中和内存延迟。
DuckDB 是第一个(也是目前少有的)完整实现 Morsel 驱动并行的开源数据库。
Morsel 驱动的核心机制:
- Morsel(一口):把全表数据切成小的数据块,默认约 10 万行。每个 Morsel 是一个独立的并行单元。
- Pipeline:DuckDB 把执行计划拆成多个 Pipeline,每个 Pipeline 内部是顺序执行,Pipeline 之间通过物化(Materialization)衔接。
- Task Scheduler:全局调度器维护一个任务队列,每个后台线程持续从队列中领取任务——一个任务对应一个 Pipeline + 一个 Morsel。
- Pipelining:上游 Pipeline 处理完一个 Morsel 后,如果在 Pipeline 之间有数据依赖,就通过物化(写入内存中的中间结果)过渡。如果没有依赖(比如 hash join 的 build 阶段和 probe 阶段),DuckDB 可以做到 double buffering——一个线程在写,另一个线程在读,流水线全速运行。
Morsel 驱动 vs 传统模型:
| 维度 | 传统(PostgreSQL) | DuckDB |
|---|---|---|
| 并行粒度 | 查询级(一个查询一个进程) | Morsel 级(10 万行一个任务) |
| 任务调度 | OS 调度 | 用户态调度器,NUMA 感知 |
| 数据传递 | 共享内存 / 磁盘临时文件 | 管道内直接传递向量 |
| 负载均衡 | 差(数据倾斜时部分进程空闲) | 好(调度器动态分配 Morsel) |
实际效果: 在 TPC-H SF100(约 6 亿行)的测试中,DuckDB 的四核并行可以做到约 3.5x 加速,八核约 5.5x 加速。线性度虽然不是完美的 8x,但在 8 核以下保持很高的效率。
3.3 排序:重写了两次
排序是分析查询中最频繁的算子之一(ORDER BY、GROUP BY、窗口函数的 PARTITION BY)。DuckDB 的排序模块在 1.4 到 1.5 的迭代期间重写了两次。
第一版是经典的 外部归并排序:把数据分片排序写到临时文件,再归并。
第二版(v1.4)引入了 并行排序:多个线程各自排序自己的 Morsel,然后并行归并。
第三版(v1.5)做了 排序优化大改——在排序的每一个阶段都通过 Morsel 驱动来并行化,并引入了 提前截断 优化:
-- 如果只需要 TOP 10
SELECT * FROM massive_table ORDER BY score DESC LIMIT 10;
DuckDB 不会全量排序。它会先用一个堆(heap)维护 top-10,如果发现排序键有界(比如分数都在 0-100 之间),还能用桶排序进一步加速。
四、零依赖的超级能力:直接查文件
DuckDB 最「反直觉」的设计之一是:它可以直接查询 Parquet、CSV、JSON 文件,不需要导入。
4.1 直接查询 Parquet
-- 直接查询 Parquet 文件,零配置
SELECT region,
COUNT(*) AS orders,
ROUND(AVG(amount), 2) AS avg_amount
FROM 'sales_2026.parquet'
WHERE amount > 100
GROUP BY region
ORDER BY orders DESC;
这里发生了什么?
- DuckDB 打开 Parquet 文件,读取文件尾部的元数据(schema、Row Group 统计信息)
- 根据 WHERE 条件中的
amount > 100,用 Parquet 的 min/max 统计信息跳过不包含匹配数据的 Row Group - 对命中的 Row Group,只反序列化
region、amount两列(Projection Pushdown) - 在列数据上执行向量化的聚合
性能数据: 在 M2 MacBook Air 上,DuckDB 对一个 5GB 的 Parquet 文件做 SELECT COUNT(*) 只需要 0.03 秒——因为它只需要读文件尾部的元数据。
4.2 零拷贝的 Parquet 访问
很多 DuckDB 的使用者没有意识到:DuckDB 对 Parquet 的访问是零拷贝的。它利用了操作系统的 mmap 系统调用,把 Parquet 文件直接映射到进程地址空间,避免了 read() 系统调用的用户态/内核态数据复制。
import duckdb
# 这段 Python 代码背后,DuckDB 做的是 mmap 映射
# 而不是逐行逐列地复制数据
result = duckdb.sql("""
SELECT date_trunc('month', o_orderdate) AS month,
COUNT(DISTINCT o_custkey) AS customers,
ROUND(SUM(o_totalprice), 2) AS revenue
FROM 'orders.parquet'
WHERE o_orderdate BETWEEN '2025-01-01' AND '2025-12-31'
GROUP BY month
ORDER BY month
""")
print(result)
4.3 跨文件 JOIN:数据湖 In-Place 查询
DuckDB 最强大的能力之一:多个不同格式的文件之间做 JOIN。
-- 订单表是 Parquet,客户表是 CSV,直接 JOIN
SELECT c.c_name,
SUM(o.o_totalprice) AS total_spent
FROM 'orders.parquet' o
JOIN 'customers.csv' c ON o.o_custkey = c.c_custkey
WHERE o.o_orderdate >= '2026-01-01'
GROUP BY c.c_name
ORDER BY total_spent DESC
LIMIT 10;
这条查询完全不需要导入数据。DuckDB 会在内存中构建哈希表来做 Hash Join,整个过程对用户完全透明。
五、2026 年的 DuckDB 生态:已不是单一的数据库
5.1 DuckDB 1.5.x 的新特性
当前最新版本 DuckDB 1.5.4(Variegata)于 2026 年 6 月 17 日发布。1.5.x 系列相比 1.4 LTS 引入了多项重大改进:
异步 I/O:对 Parquet 的读取引入了异步 I/O 预读机制。当 DuckDB 发现你在做一个全表扫描时,它会通过一个后台线程提前读取下一个 Row Group,让 I/O 和 CPU 计算重叠。实测在 NVMe SSD 上,大表扫描的吞吐可以再提升 20-30%。
Async I/O 实现原理(伪代码):
// 传统同步读取
for each row_group in parquet_file:
data = read(row_group) // 等待 I/O
result = process(data) // 计算
// DuckDB 异步预读
buffer_a = read_async(row_group_0) // 立即返回 future
buffer_b = read_async(row_group_1)
for each row_group in parquet_file:
data = await buffer_ready(row_group_i) // 等待完成
write_async(row_group_{i+2}, preload) // 预读下一个
result = process(data)
加密支持:v1.5 引入了数据静态加密(Data-at-Rest Encryption),支持 AES-256-GCM 加密整个数据库文件。
Variant 类型:原生支持半结构化的 VARIANT 类型(类似 PostgreSQL 的 JSONB),可以对 JSON/JSONL 数据做类似 MongoDB 的嵌套查询,同时享受列式存储的压缩效率。
CLI 暗夜模式:-dark-mode 和 -light-mode 参数,对终端用户友好。
5.2 DuckLake:SQL 即湖仓格式
2026 年 4 月,DuckDB 团队发布了 DuckLake v1.0——一个基于 SQL 的湖仓格式(Lakehouse Format)。它的核心思想很激进:
为什么不直接把 DuckDB 的持久化文件本身作为一个开放的湖仓格式?
DuckLake 的规格极其简单:
- 数据存储为标准的 Parquet 文件
- 元数据存储在 DuckDB 系统表中
- 通过 SQL 来管理分区、快照和事务
这意味着任何支持读取 Parquet 的工具都可以消费 DuckLake 的数据。DuckLake 还支持 ACID 事务和多版本并发控制。
duckdb -c "
-- 创建一个 DuckLake
ATTACH 'my_lake' AS my_lake (TYPE ducklake);
USE my_lake;
-- 创建表并写入
CREATE TABLE events (id INT, event_type VARCHAR, ts TIMESTAMP);
INSERT INTO events VALUES (1, 'click', '2026-06-01'), (2, 'view', '2026-06-02');
-- DuckLake 自动管理文件布局
-- 底层存储为 Parquet 文件
"
5.3 Iceberg + Delta Lake + Lance 集成
DuckDB 没有闭门造车创建自己的格式。它提供了对业界主流湖仓格式的一流支持:
- Apache Iceberg:通过
iceberg扩展支持完整的读/写,包括 v2 格式的 partition evolution 和 row-level delete。2026 年 5 月的 v1.5.3 进一步增加了 Iceberg 的 Time Travel 和 Snapshot 管理。 - Delta Lake:通过
delta扩展支持读写,包括 Unity Catalog 集成和 Change Data Feed。 - Lance:通过
lance扩展支持这个新兴的列式格式。
-- DuckDB 读取 Iceberg 表
INSTALL iceberg;
LOAD iceberg;
SELECT * FROM iceberg_scan('s3://my-bucket/iceberg_table', allow_moved_paths=true);
-- DuckDB 读取 Delta Lake
INSTALL delta;
LOAD delta;
SELECT * FROM delta_scan('s3://my-bucket/delta_table');
-- 甚至可以把 Iceberg 表导出为 DuckDB 本地表加速查询
CREATE TABLE local_snapshot AS
SELECT * FROM iceberg_scan('s3://my-bucket/iceberg_table');
5.4 Quack 协议:客户端-服务器模式
长期以来,DuckDB 只能是嵌入式——这也是它「SQLite for analytics」绰号的由来。但 2026 年 5 月发布的 Quack 协议 改变了这一点。
Quack 是一个轻量级的客户端-服务器协议,允许远程客户端通过 TCP 连接查询 DuckDB。设计上:
- 使用 Apache Arrow Flight 作为数据传输层(零拷贝序列化)
- 支持 TLS 加密
- 支持简单的认证机制
这意味着你可以把 DuckDB 作为一个轻量级的分析服务来用,而不需要部署 ClickHouse 集群。当然,Quack 不是为高并发 OLTP 设计的——它面向的是 BI 工具、Notebook 和 CI/CD 管道中的分析查询。
# 启动 DuckDB 服务器
duckdb my_database.duckdb -serve -p 45123
# 另一个终端查询
python3 -c "
import duckdb
# 通过 Quack 协议连接远程 DuckDB
conn = duckdb.connect('remote://myserver:45123/my_database.duckdb')
print(conn.sql('SELECT COUNT(*) FROM large_table').fetchone())
"
5.5 MotherDuck:云端 DuckDB
MotherDuck 是 DuckDB 的商业化云端版本——把 DuckDB 的嵌入式理念扩展到云端。你可以:
- 在本地用 DuckDB 处理数据
- 把结果
sync到 MotherDuck 的云端 - 在云端共享给团队
这种混合架构的思路很新颖:不需要在「本地快但数据孤岛」和「云端共享但延迟高」之间做选择——你可以同时拥有两者。
import duckdb
# 连接 MotherDuck(云端)和本地数据库
conn = duckdb.connect('md:my_org/my_db')
conn.sql("""
-- 云端表 JOIN 本地文件
SELECT a.*, b.revenue
FROM cloud_table a
JOIN read_parquet('/local/data.parquet') b
ON a.id = b.id
""")
六、MySQL+DuckDB:Percona 实验的工程剖析
回到开头的话题:Percona 的 ducksdb-mysql-engine 到底是怎么工作的?
6.1 架构设计
MySQL 原生的存储引擎接口(handlerton)不支持把整个 SELECT 查询下推给外部引擎。MariaDB 有 select_handler 接口,但 MySQL 没有。
Percona 团队的解决方案很巧妙:
补丁 1:pushdown_select 钩子 — 在 handlerton 中新增了一个
pushdown_select回调。这个钩子在JOIN::optimize()阶段结束时触发。补丁 2:完整查询渲染 — 检查当前 JOIN 中的所有基表是否都属于
ENGINE=DuckDB。如果是,引擎尝试把整个优化后的 JOIN 结构渲染成 DuckDB SQL。补丁 3:回退路径 — 如果 DuckDB 无法处理某个查询(比如用到了 DuckDB 不支持的数据类型或函数),
pushdown_select返回 false,MySQL 走回正常的行处理路径。
// 简化的 pushdown_select 逻辑
bool DuckDBStorageEngine::pushdown_select(JOIN* join) {
// 1. 检查所有表是否都是 DuckDB 引擎
for (auto table : join->tables) {
if (table->engine != DUCKDB_ENGINE) return false;
}
// 2. 尝试渲染为 DuckDB SQL
string duckdb_sql = render_as_duckdb_sql(join);
if (duckdb_sql.empty()) return false;
// 3. 设置覆盖执行器
join->override_executor = [duckdb_sql]() {
DuckDBResult result = duckdb_query(duckdb_sql);
stage_into_temp_table(result);
};
return true;
}
6.2 LOAD DATA INFILE 优化
关于导入性能的数字值得单独提一下:600M 行数据,DuckDB 引擎 20 分钟,MySQL 行插入 80 分钟。
这是因为标准的 MySQL INSERT ... VALUES 要走 write_row 接口——对每一行,MySQL 的 handler 层都要做一次上下文切换、一次行格式转换、一次 B+ 树维护。
DuckDB 引擎直接把 LOAD DATA INFILE 翻译成了 DuckDB 的 COPY 命令——一次批量导入,列式写入,零逐行开销。
6.3 边界情况处理
这个项目不是生产级的,Percona 团队很坦诚地列出了一堆坑:
- 所有的 DuckDB 表共享同一个 DuckDB 实例(不是每个表独立的)
- 不支持跨引擎 JOIN(DuckDB 表 JOIN InnoDB 表)
- DuckDB 引擎的表不支持外键约束
- 不支持 ALTER TABLE
- 不支持全文索引
但即使如此,TPC-H SF10 的数据已经足够让人震撼:
| 查询 | InnoDB | MySQL+DuckDB | 提升 |
|---|---|---|---|
| Q1 (聚合扫描) | >180s | 1.77s | 100x+ |
| Q5 (多表 JOIN) | 127.6s | 0.53s | 240x |
| Q7 (复杂 JOIN) | 145.0s | 0.45s | 322x |
| Q9 (多阶段聚合) | >180s | 1.52s | 118x+ |
| Q18 (大表 JOIN) | 101.7s | 1.35s | 75x |
| Q19 (过滤聚合) | 120.4s | 0.15s | 800x |
不是 DuckDB 太快,是 InnoDB 在分析查询上实在太慢了。
七、实战:10 个 DuckDB 日常使用场景
7.1 替代 pandas 做大数据分析
当你的 CSV 超过 1GB,pandas 开始 OOM(内存溢出)时:
import duckdb
import pandas as pd
# 反直觉:用 DuckDB 处理 10GB CSV,比 pandas 快 20 倍且不爆内存
result = duckdb.sql("""
SELECT
department,
AVG(salary) AS avg_salary,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary,
COUNT(*) AS employee_count
FROM read_csv_auto('employees_large.csv')
WHERE salary > 30000
GROUP BY department
HAVING employee_count > 50
ORDER BY avg_salary DESC
""")
# 结果可以无缝转回 pandas DataFrame
df = result.df() # 此刻数据已经很小了
print(df)
7.2 ETL 管道中的瑞士军刀
import duckdb
conn = duckdb.connect('etl_pipeline.duckdb')
# 阶段 1:从 S3 读取 Parquet 做数据清洗
conn.execute("""
CREATE TABLE cleaned_orders AS
SELECT
o_orderkey,
o_custkey,
CAST(o_totalprice AS DECIMAL(12,2)) AS revenue,
o_orderdate,
STRPTIME(o_orderdate, '%Y-%m-%d') AS order_date_parsed
FROM read_parquet('s3://my-bucket/orders/*.parquet')
WHERE o_totalprice IS NOT NULL
AND o_orderdate >= '2025-01-01'
""")
# 阶段 2:计算月度 KPI
result = conn.execute("""
SELECT
DATE_TRUNC('month', order_date_parsed) AS month,
COUNT(DISTINCT o_custkey) AS active_customers,
SUM(revenue) AS total_revenue,
SUM(revenue) / COUNT(DISTINCT o_custkey) AS revenue_per_customer
FROM cleaned_orders
GROUP BY month
ORDER BY month
""").fetchall()
print(result)
7.3 热加载 JSON 日志
-- 直接分析 JSON Lines 日志,不需要导入
SELECT
json_extract_string(log, '$.level') AS level,
json_extract_string(log, '$.service') AS service,
COUNT(*) AS count,
ROUND(AVG(json_extract(log, '$.duration_ms')::FLOAT), 2) AS avg_duration
FROM read_json_auto('logs/*.jsonl',
format='jsonlines',
ignore_errors=true)
WHERE json_extract_string(log, '$.timestamp') >= '2026-07-01'
GROUP BY level, service
ORDER BY count DESC;
7.4 多数据源 JOIN(传统数据库做不到的)
-- MySQL 中的用户表 JOIN S3 上的 Parquet 行为日志
-- 需要在 MySQL 中但这里展示 DuckDB 的能力
SELECT
u.name,
u.email,
COUNT(b.event_id) AS event_count,
COUNT(DISTINCT b.event_type) AS unique_actions
FROM read_parquet('s3://analytics/behavior/*.parquet') b
JOIN read_csv_auto('users_export_2026.csv') u
ON b.user_id = u.id
WHERE b.event_date >= '2026-06-01'
GROUP BY u.name, u.email
HAVING event_count > 100
ORDER BY event_count DESC;
7.5 窗口函数进阶:运行总量与移动平均
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY month
)
SELECT
month,
revenue,
SUM(revenue) OVER (ORDER BY month) AS cumulative_revenue,
AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3m,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY month)) /
NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0) * 100,
2) AS mom_growth_pct,
RANK() OVER (ORDER BY revenue DESC) AS rank
FROM monthly_sales
ORDER BY month;
7.6 时间序列分析
-- 生成连续的日期序列,补齐缺失的天数
WITH date_series AS (
SELECT UNNEST(generate_series(
DATE '2025-01-01',
DATE '2025-12-31',
INTERVAL 1 DAY
)) AS date
),
daily_metrics AS (
SELECT
CAST(timestamp AS DATE) AS date,
COUNT(*) AS events,
COUNT(DISTINCT user_id) AS users
FROM events
GROUP BY CAST(timestamp AS DATE)
)
SELECT
ds.date,
COALESCE(dm.events, 0) AS events,
COALESCE(dm.users, 0) AS users,
AVG(dm.events) OVER (ORDER BY ds.date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS events_ma7
FROM date_series ds
LEFT JOIN daily_metrics dm ON ds.date = dm.date
ORDER BY ds.date;
7.7 模糊搜索与文本分析
-- DuckDB 内置了多种字符串相似度函数
SELECT
title,
description
FROM read_csv_auto('products.csv')
WHERE
-- 杰卡德相似度:适合较短文本
jaccard_similarity(LOWER(title), 'programming python guide') > 0.3
OR
-- Levenshtein 距离:适合单词级别的模糊匹配
levenshtein(LOWER(SUBSTRING(title, 1, 10)), 'programming') < 2
ORDER BY
jaccard_similarity(LOWER(title), 'programming python guide') DESC
LIMIT 10;
7.8 空间数据分析
DuckDB 通过 spatial 扩展支持完整的 GIS 空间查询:
INSTALL spatial;
LOAD spatial;
-- 查找某个多边形区域内的所有 POI
SELECT
p.name,
p.category,
ST_Distance(p.geom, ref.centroid) AS distance_meters
FROM read_parquet('pois.parquet') p,
(SELECT ST_Centroid(ST_GeomFromText('POLYGON((...))')) AS centroid) ref
WHERE ST_Within(p.geom, ST_GeomFromText('POLYGON((...))'))
ORDER BY distance_meters;
7.9 递归 CTE:有向图遍历
-- 用递归 CTE 做有向图的层级遍历
WITH RECURSIVE dependency_graph AS (
-- 基础情况:找到所有顶级依赖(没有其他包依赖它们)
SELECT
package_id,
name,
0 AS level,
ARRAY[package_id] AS path
FROM packages
WHERE package_id NOT IN (SELECT DISTINCT dependency_id FROM dependencies)
UNION ALL
-- 递归情况
SELECT
p.package_id,
p.name,
dg.level + 1,
dg.path || p.package_id
FROM packages p
JOIN dependencies d ON p.package_id = d.package_id
JOIN dependency_graph dg ON d.dependency_id = dg.package_id
WHERE NOT EXISTS (
-- 防止环
SELECT 1 FROM UNNEST(dg.path) AS t(id) WHERE t.id = p.package_id
)
)
SELECT level, name, COUNT(*) OVER (PARTITION BY level) AS nodes_at_level
FROM dependency_graph
ORDER BY level, name;
7.10 统计分析与假设检验
-- 计算两个群体(A/B 测试)的统计指标
WITH ab_data AS (
SELECT
variant,
conversion_flag::INT AS converted
FROM read_parquet('ab_test_results.parquet')
),
stats AS (
SELECT
variant,
COUNT(*) AS n,
SUM(converted) AS successes,
AVG(converted) AS conversion_rate,
-- 标准误
STDDEV(converted) / SQRT(COUNT(*)) AS standard_error
FROM ab_data
GROUP BY variant
)
SELECT
a.variant AS control,
b.variant AS treatment,
a.conversion_rate AS control_rate,
b.conversion_rate AS treatment_rate,
(b.conversion_rate - a.conversion_rate) AS lift,
(b.conversion_rate - a.conversion_rate) / a.conversion_rate * 100 AS lift_pct,
SQRT(
POWER(a.standard_error, 2) + POWER(b.standard_error, 2)
) AS pooled_se,
(b.conversion_rate - a.conversion_rate) /
SQRT(POWER(a.standard_error, 2) + POWER(b.standard_error, 2)) AS z_score
FROM stats a CROSS JOIN stats b
WHERE a.variant = 'control' AND b.variant = 'treatment';
八、性能优化:7 个工程师必知的 DuckDB 调优技巧
8.1 利用 Parquet 分区裁剪
-- ❌ 慢:加载整个文件再过滤
SELECT * FROM read_parquet('sales/*.parquet')
WHERE region = 'APAC';
-- ✅ 快:通过 glob 模式缩小扫描范围
SELECT * FROM read_parquet('sales/region=APAC/*.parquet');
DuckDB 的 glob 模式匹配是文件系统级别的,开销远小于读取 Parquet 元数据。如果你的数据已经按分区存储,优先用 glob 而不是 WHERE 过滤。
8.2 善用压缩编码选择
DuckDB 支持多种压缩编码,选择合适的编码可以大幅提升查询性能:
| 压缩方式 | 适用场景 | 压缩比 |
|---|---|---|
| Plain | 不需要压缩的列 | 1:1 |
| Bitpacking | 值范围小的整数列(如布尔、枚举) | 2:1 ~ 8:1 |
| RLE | 重复值多的列(如分区键、状态码) | 10:1 ~ 100:1 |
| Delta | 单调递增的列(时间戳、自增 ID) | 3:1 ~ 10:1 |
| Dictionary | 基数低的字符串列 | 5:1 ~ 50:1 |
| FSST | 文本列 | 2:1 ~ 4:1 |
-- 建表时指定压缩方式
CREATE TABLE optimized_table (
id INTEGER,
created_at TIMESTAMP,
status VARCHAR,
payload VARCHAR
);
-- DuckDB 会为 status(低基数)自动选择 RLE 或 Dictionary 压缩
-- 为 created_at(递增时间戳)自动选择 Delta 压缩
8.3 使用 PRAGMA 进行运行时调优
-- 查看当前的 DuckDB 配置
PRAGMA version;
-- 调整内存限制(默认是物理内存的 80%)
PRAGMA memory_limit='8GB';
-- 调整线程数(默认是 CPU 核数)
PRAGMA threads=8;
-- 临时表目录(磁盘溢出时使用)
PRAGMA temp_directory='/tmp/duckdb_temp';
-- 禁用或启用并行
PRAGMA enable_profiling;
PRAGMA disable_profiling;
-- 启用查询分析
EXPLAIN ANALYZE SELECT COUNT(*) FROM large_table;
8.4 避免不必要的类型转换
-- ❌ 慢:字符串比较比数值比较贵
SELECT * FROM 'data.parquet'
WHERE CAST(customer_id AS VARCHAR) = '12345';
-- ✅ 快:直接用原始类型比较
SELECT * FROM 'data.parquet'
WHERE customer_id = 12345;
8.5 用 CREATE TABLE AS 缓存中间结果
-- 高效的 ETL 模式:
-- 1. 第一次查询写入临时表
CREATE TEMP TABLE filtered_data AS
SELECT * FROM read_parquet('s3://bucket/*.parquet')
WHERE date BETWEEN '2026-01-01' AND '2026-06-30';
-- 2. 后续多次分析都在临时表上操作
SELECT region, SUM(amount) FROM filtered_data GROUP BY region;
SELECT category, COUNT(*) FROM filtered_data GROUP BY category;
8.6 利用 EXPLAIN ANALYZE 识别瓶颈
EXPLAIN ANALYZE
SELECT c.c_name,
SUM(o.o_totalprice) AS total
FROM read_parquet('customer.parquet') c
JOIN read_parquet('orders.parquet') o
ON c.c_custkey = o.o_custkey
GROUP BY c.c_name
ORDER BY total DESC
LIMIT 100;
输出会显示每个算子的耗时、行数和内存使用,帮助你定位是扫描慢、JOIN 慢还是聚合慢。
8.7 小心 SELECT *
-- ❌ 慢:读 50 列但只用 3 列
SELECT * FROM wide_table WHERE id < 1000;
-- ✅ 快:只读需要的列
SELECT id, name, status FROM wide_table WHERE id < 1000;
DuckDB 的列式存储在只读少量列时优势巨大。读 3 列比读 50 列快 10 倍以上。
九、DuckDB vs 竞品:一张表看懂
| 特性 | DuckDB | SQLite | PostgreSQL | ClickHouse | Polars |
|---|---|---|---|---|---|
| 类别 | 嵌入式 OLAP | 嵌入式 OLTP | 客户端-服务器 OLTP | 分布式 OLAP | DataFrame 库 |
| 存储方式 | 列式 | 行式 | 行式 | 列式 | 无(内存) |
| 安装复杂度 | pip install | 零 | 需服务端 | 需集群 | pip install |
| 直接查文件 | ✅ Parquet/CSV/JSON | ❌ | ❌(需 FDW) | ✅ | ✅(需额外代码) |
| SQL 语法 | PG 兼容 | 自成一派 | PG | 类 SQL | 方法链/少量 SQL |
| 并行查询 | ✅ Morsel 驱动 | ❌ | ✅ 查询级 | ✅ 分布式 | ✅ LazyFrame |
| 多表 JOIN | ✅ 向量化 Hash Join | 勉强可用 | ✅ 优化器成熟 | ✅ 分布式 | ❌(功能弱) |
| 处理 100GB+ | ✅ 磁盘溢出 | ❌ 内存上限 | ✅ | ✅ | ❌ 易 OOM |
| 事务 (ACID) | ✅ | ✅ | ✅ | ✅ | ❌ |
| 扩展生态 | 插件系统 | 少 | 丰富 | 中等 | 丰富 |
| 适用场景 | 快速分析 / ETL / 嵌入式 | 移动端 / 轻量存储 | 核心应用数据库 | 实时数仓 | 数据科学探索 |
十、工程哲学:DuckDB 教给我们的三件事
10.1 做减法比做加法更难
DuckDB 最让我敬佩的设计选择不是它有什么,而是它拒绝了什么:不支持高并发写入、不支持分布式、不支持 MVCC 的 snapshot 隔离。这些「缺失」正是它能够保持简单、零配置、高性能的原因。
SQLite 的成功已经证明了「做减法」的商业价值——它是世界上部署最广泛的数据库,因为它只需要一个文件。DuckDB 在分析领域复制了这个哲学。
10.2 嵌入式 ≠ 弱小
用户对「嵌入式数据库」的常见误解是:嵌入式的 = 玩具级的 = 只能处理小数据。
DuckDB 证明了这个认知是错的。它的向量化执行引擎、Morsel 驱动并行、查询优化器,每一样都是学术界和工业界最前沿的技术。嵌入式只是部署形态,不是能力上限。
10.3 兼容性是护城河
DuckDB 对 PostgreSQL SQL 语法的高度兼容不是偶然的——这是一个深思熟虑的策略。用户不需要学习新的查询语言,现有的 BI 工具(Tableau、Grafana、Metabase)可以通过 PostgreSQL 协议直接连接 DuckDB。
pip install duckdb 之后,你就有了一台零配置的分析数据库。这种低摩擦的入门体验,是 DuckDB 快速获得 30k+ GitHub Stars 的关键。
展望:DuckDB 的未来与启示
2026 年下半年的 DuckDB 路线图中,最值得关注的几个方向:
v2.0 大版本(2026 年秋季):预计会包含更复杂的查询优化器(基于成本的 CBO)、更好的并发写入支持,以及性能的大幅提升。
Quack 协议的成熟:如果 Quack 能稳定地支持多并发查询,DuckDB 将从一个纯粹的嵌入式数据库转变为「嵌入式 + 轻量服务端」的双模式产品。
AI/ML 集成:DuckDB 的扩展系统已经支持了 PyTorch 模型的推理,未来可能会进一步集成嵌入式模型训练和特征工程。
湖仓生态的深化:DuckLake + Iceberg + Delta 的三层湖仓策略,让 DuckDB 成为数据工程师本地开发和数据科学家原型验证的首选工具。
对于后端工程师来说,2026 年学习 DuckDB 不是一个「要不要」的问题,而是一个「什么时候开始」的问题。每当你遇到这样的场景:
- 一个 CSV/Parquet 文件需要快速分析
- MySQL 中的分析查询跑不动
- 搭 ETL 管道太重,只想跑一次 SQL
- 本地开发需要操作生产数据的一小部分快照
试试 pip install duckdb。你可能会和我一样,在第一次跑出结果后感叹:为什么没有人早点告诉我这个?
文章编号:DDB-2026-07
选题来源:DuckDB v1.5.4 发布 + Percona MySQL+DuckDB 存储引擎
技术参考:DuckDB 官方博客、Percona 工程博客、CMU 15445 课程