编程 DuckDB 深度拆解:当分析型数据库学会「嵌入式」——列式向量化引擎与 Morsel 驱动并行如何重写 OLAP 心智模型

2026-07-15 19:43:28 +0800 CST views 7

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 接手做两件事:

  1. Binder:解析所有引用的 schema 对象(表、视图、列),验证类型,解析函数重载
  2. 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 * 37
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 会:

  1. 读取 Parquet 的统计信息(min/max 索引)
  2. 跳过所有不包含 2026-01 范围内数据的 Row Group
  3. 在命中的 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 驱动的核心机制:

  1. Morsel(一口):把全表数据切成小的数据块,默认约 10 万行。每个 Morsel 是一个独立的并行单元。
  2. Pipeline:DuckDB 把执行计划拆成多个 Pipeline,每个 Pipeline 内部是顺序执行,Pipeline 之间通过物化(Materialization)衔接。
  3. Task Scheduler:全局调度器维护一个任务队列,每个后台线程持续从队列中领取任务——一个任务对应一个 Pipeline + 一个 Morsel。
  4. 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;

这里发生了什么?

  1. DuckDB 打开 Parquet 文件,读取文件尾部的元数据(schema、Row Group 统计信息)
  2. 根据 WHERE 条件中的 amount > 100,用 Parquet 的 min/max 统计信息跳过不包含匹配数据的 Row Group
  3. 对命中的 Row Group,只反序列化 regionamount 两列(Projection Pushdown)
  4. 在列数据上执行向量化的聚合

性能数据: 在 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 的嵌入式理念扩展到云端。你可以:

  1. 在本地用 DuckDB 处理数据
  2. 把结果 sync 到 MotherDuck 的云端
  3. 在云端共享给团队

这种混合架构的思路很新颖:不需要在「本地快但数据孤岛」和「云端共享但延迟高」之间做选择——你可以同时拥有两者。

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. 补丁 1:pushdown_select 钩子 — 在 handlerton 中新增了一个 pushdown_select 回调。这个钩子在 JOIN::optimize() 阶段结束时触发。

  2. 补丁 2:完整查询渲染 — 检查当前 JOIN 中的所有基表是否都属于 ENGINE=DuckDB。如果是,引擎尝试把整个优化后的 JOIN 结构渲染成 DuckDB SQL。

  3. 补丁 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 的数据已经足够让人震撼:

查询InnoDBMySQL+DuckDB提升
Q1 (聚合扫描)>180s1.77s100x+
Q5 (多表 JOIN)127.6s0.53s240x
Q7 (复杂 JOIN)145.0s0.45s322x
Q9 (多阶段聚合)>180s1.52s118x+
Q18 (大表 JOIN)101.7s1.35s75x
Q19 (过滤聚合)120.4s0.15s800x

不是 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 竞品:一张表看懂

特性DuckDBSQLitePostgreSQLClickHousePolars
类别嵌入式 OLAP嵌入式 OLTP客户端-服务器 OLTP分布式 OLAPDataFrame 库
存储方式列式行式行式列式无(内存)
安装复杂度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 路线图中,最值得关注的几个方向:

  1. v2.0 大版本(2026 年秋季):预计会包含更复杂的查询优化器(基于成本的 CBO)、更好的并发写入支持,以及性能的大幅提升。

  2. Quack 协议的成熟:如果 Quack 能稳定地支持多并发查询,DuckDB 将从一个纯粹的嵌入式数据库转变为「嵌入式 + 轻量服务端」的双模式产品。

  3. AI/ML 集成:DuckDB 的扩展系统已经支持了 PyTorch 模型的推理,未来可能会进一步集成嵌入式模型训练和特征工程。

  4. 湖仓生态的深化: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 课程

推荐文章

PHP 如何输出带微秒的时间
2024-11-18 01:58:41 +0800 CST
php微信文章推广管理系统
2024-11-19 00:50:36 +0800 CST
SpaceX 600亿美元收购Cursor(中篇)
2026-06-22 03:30:23 +0800 CST
Manticore Search:高性能的搜索引擎
2024-11-19 03:43:32 +0800 CST
java MySQL如何获取唯一订单编号?
2024-11-18 18:51:44 +0800 CST
程序员茄子在线接单