DuckDB 深度解剖:把 OLAP 数据库塞进一个进程,它凭什么比你的数仓还快
你有没有过这种时刻:为了跑一个几百 MB CSV 的聚合分析,先起了个 Spark 集群,等了 40 秒 JVM 冷启动,最后发现
pandas.read_csv直接 OOM。而同样的活儿,DuckDB 一行SELECT在你的笔记本上 300 毫秒就干完了,内存还没爆。这篇文章,我们从工程师视角把 DuckDB 从里到外拆一遍——不是教你怎么写SELECT *,而是搞清楚它凭什么快,以及在什么场景下它会真正改变你的技术选型。
一、背景:为什么会有 DuckDB 这种"怪东西"
过去二十年,数据库世界基本是二元对立的:
- OLTP(事务型):MySQL、PostgreSQL、SQLite,行存,为高并发的点查、增删改优化。
- OLAP(分析型):ClickHouse、Snowflake、BigQuery、Spark,列存,为大规模聚合扫描优化,通常是分布式集群。
这两类系统有一个共同的隐含假设:数据库是一个独立的服务端进程,你的应用通过网络(或至少是本地 socket)连过去。哪怕是"单机版"的 ClickHouse,也是个常驻服务。
但 SQLite 打破了 OLTP 那一侧的假设——它是 in-process(进程内嵌入式) 的,没有服务端,没有网络,数据库就是一个函数库链接进你的程序,一个 .db 文件就是全部。SQLite 因此成了这个星球上部署最广的数据库(每部手机、每个浏览器里都有)。
DuckDB 干的事情,就是在 OLAP 那一侧复刻 SQLite 的成功。它是荷兰 CWI(那个搞出 MonetDB、Vectorwise 的传奇数据库研究组)在 2019 年 SIGMOD 上以一篇 Demo 论文《DuckDB: an Embeddable Analytical Database》正式推出的。一句话定位:
DuckDB = OLAP 版的 SQLite:进程内嵌入、零依赖、单文件、MIT 协议开源,但底层是列式存储 + 向量化执行引擎。
为什么这个定位在 2020 年代突然成立?三个硬件与数据趋势:
- 单机内存爆炸式增长:服务器内存动辄 512GB~2TB,加上 NVMe SSD,单机处理几十 TB 数据不再是幻想。大量"大数据"其实是"中数据",根本用不着集群。
- 数据科学工作流的痛点:数据科学家在 Python/R 里用 pandas/dplyr,一旦数据超过内存就崩,而为此专门搭一套 Spark 又太重。中间存在巨大的能力真空。
- 列存 + 向量化技术成熟:把 MonetDB/Vectorwise 这些学术级引擎的精华,浓缩进一个可嵌入的库,工程上终于可行。
DuckDB 精准地填进了这个真空。到 2024 年 6 月它发布了 1.0 稳定版,此后 1.x 系列快速迭代,GitHub 星标增长曲线堪称现象级。
二、核心设计哲学:三个"反直觉"的选择
理解 DuckDB,先理解它的三个核心取舍,这三点决定了它的性能天花板。
2.1 嵌入式(In-Process):消灭网络与序列化
传统数据库客户端每次查询要经历:应用 → 序列化请求 → 网络 → 数据库进程 → 执行 → 序列化结果 → 网络 → 反序列化。对分析型负载,返回结果动辄百万行,这个序列化/反序列化的开销经常比查询本身还大。
DuckDB 和你的应用跑在同一个进程、同一个地址空间。这意味着:
- 查询结果可以**零拷贝(zero-copy)**地直接暴露给应用。DuckDB 的 Python 接口可以把结果直接映射成 pandas DataFrame、Polars DataFrame 或 Arrow Table,中间没有一次多余的内存复制。
- 反过来,DuckDB 能直接读你内存里已有的 DataFrame——你甚至不需要"导入"数据。
来看这个让第一次接触的人震惊的例子:
import duckdb
import pandas as pd
df = pd.read_parquet("orders.parquet") # 已经在内存里的 DataFrame
# 直接对 Python 变量 df 跑 SQL!不需要 CREATE TABLE,不需要 INSERT
result = duckdb.sql("""
SELECT region, SUM(amount) AS total
FROM df
WHERE order_date >= '2026-01-01'
GROUP BY region
ORDER BY total DESC
""").df() # .df() 零拷贝转回 pandas
这里的魔法叫 Replacement Scan(替换扫描):DuckDB 解析 SQL 时发现 df 不是数据库里的表,就去调用者的作用域里找同名的 Python 对象,如果是 DataFrame/Arrow 就直接把它当作数据源接进查询计划。数据全程没有离开过它原本的内存位置。
2.2 列式存储(Columnar):为扫描而生
OLAP 查询的典型形态是"扫描很多行,但只碰少数几列,然后做聚合"。行存数据库要把整行读进来,哪怕你只要 2 列;列存则只读你要的列。
DuckDB 内部无论是内存中的数据表示还是磁盘上的持久化格式,都是列式的。这带来两个直接收益:
- I/O 大幅减少:只读需要的列(projection pushdown)。
- 压缩率极高:同一列的数据类型一致、值域相近,压缩算法能发挥到极致。DuckDB 用了一套针对性的轻量压缩:字典编码(Dictionary)、游程编码(RLE)、位打包(Bit-packing)、FOR(Frame of Reference)、针对字符串的 FSST、针对浮点数的 ALP/Chimp 等。这些压缩不是简单的 gzip,而是可以直接在压缩态上做部分计算的编码。
2.3 向量化执行(Vectorized Execution):既不是逐行也不是全列
这是 DuckDB 性能的核心,也是最值得细讲的部分。
传统数据库有两种执行范式:
- Volcano / 火山模型(逐行):每个算子实现
next()返回一行,一行行往上拉。代码简单,但每处理一行就要一次虚函数调用,CPU 分支预测和缓存命中率极差。SQLite、老式 MySQL 都是这个路子——对 OLTP 够用,对 OLAP 是灾难。 - Materialization / 物化模型(全列):MonetDB 早期的做法,一个算子一次处理一整列。CPU 效率高,但中间结果要完全物化,一列上亿行全塞内存,内存压力爆炸。
DuckDB 走的是 Vectorized Execution(向量化,介于两者之间),源自 Vectorwise 的思想:算子之间传递的不是一行,也不是一整列,而是一个固定大小的数据块(Vector),默认一次 2048 个值(STANDARD_VECTOR_SIZE)。
为什么 2048 这个数字是关键?因为一个 vector(几 KB 数据)刚好能装进 CPU 的 L1/L2 缓存。于是:
- 每次虚函数调用摊薄到 2048 行上,函数调用开销可以忽略;
- 内层循环是对连续内存的紧凑遍历,编译器能自动做 SIMD 向量化(一条指令处理多个数据);
- 中间结果只有一个 vector 那么大,内存占用可控。
用一个极简化的伪代码感受它和逐行模型的差别:
// 火山模型(逐行)——每行一次虚调用,缓存不友好
while (Tuple* row = child->Next()) { // 虚函数调用
if (row->a > 100) emit(row); // 分支预测频繁失败
}
// 向量化模型——一次处理一整个 vector
DataChunk chunk;
child->GetChunk(chunk); // 一次虚调用拿 2048 行
auto& col = chunk.data[0]; // 连续内存
for (idx_t i = 0; i < chunk.size(); i++) { // 编译器可自动 SIMD
selection[count] = i;
count += (col[i] > 100); // 无分支写法
}
DuckDB 还用了一套精巧的 Unified Vector Format:一个逻辑上的 vector 在物理上可以是多种形态——Flat(普通数组)、Constant(整列同一个值,只存一份)、Dictionary(字典压缩)、Sequence(等差序列,如 1,2,3... 只存起点和步长)。算子代码通过统一接口访问,但底层能对特殊形态做超级优化。比如对一个 Constant Vector 做过滤,根本不用遍历 2048 次,判断一次就够了。
三、执行引擎:Push-Based 流水线与 Morsel 并行
如果说向量化解决了"单核跑得快",那 DuckDB 的执行调度就是解决"多核怎么跑满"。这里有两个关键设计。
3.1 从 Pull 到 Push:推送式执行
早期 DuckDB 也用火山式的 Pull(拉取) 模型——根算子调 next(),一层层往下拉数据。但 Pull 模型有几个老大难问题:Union 这类多路输入难处理、full outer join 难实现、算子间要反复物化、负载不均衡时难调度。
DuckDB 后来重构成了 Push-Based(推送式) 执行:数据从最底层的数据源(Source)主动往上推,经过一系列算子(Operator),最终推进汇聚点(Sink)。整个查询被切分成若干条 Pipeline(流水线)。
一条 pipeline 的经典结构:
Source(扫描表/Parquet)
→ Operator(Filter 过滤)
→ Operator(Projection 投影)
→ Sink(Hash Aggregate 聚合 / Hash Join 构建哈希表)
关键规则:只有 Sink 是"阻塞"的,中间的 Operator 都是流式无状态的。比如 Hash Join 的构建侧(build side)是一个 Sink——必须把整个哈希表建完,探测侧(probe side)才能开始。所以一个带 Join 的查询会被拆成"先跑完建哈希表的 pipeline,再跑探测的 pipeline"。
3.2 Morsel-Driven Parallelism:细粒度、NUMA 感知的并行
这是 DuckDB 从 Leis 等人 2014 年那篇经典论文《Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework》搬来的思想,也是它多核扩展性的核心。
传统并行做法是"按数据分区,每个线程包一个分区"。问题是:数据分布往往不均匀,有的分区大有的小,导致某些线程早早干完在摸鱼,某些线程累死,整体被最慢的拖住(straggler 问题)。
Morsel 模型的做法完全不同:把输入数据切成大量小块(morsel,小碎块,比如每块 10 万行),所有 morsel 扔进一个共享的任务池。工作线程干完手头这块,就主动去池子里抢下一块(work-stealing 式)。这样天然做到负载均衡——快的线程自然多干几块,没有线程会闲着。
并行度的关键抽象在 DuckDB 源码里体现为:
- 每个 Source 通过
MaxThreads()决定能拆出多少并行任务(比如一个大 Parquet 文件按 row group 切)。 - 线程竞争只发生在 Sink 端。中间的 Filter、Projection、Join Probe 都是无状态的,各线程各干各的,完全不需要加锁。只有汇聚结果的 Sink(如聚合、Join 构建)需要处理多线程同步。
- DuckDB 为此设计了
GlobalSinkState(全局唯一,存最终汇聚结果)和LocalSinkState(每线程私有,存局部部分结果)。线程先在自己的 LocalState 里累加,最后再合并进 GlobalState——这就是经典的"局部聚合 + 全局合并",把锁竞争压到最低。
这套设计的结果是:DuckDB 在多核机器上的扩展性接近线性。一台 16 核笔记本上,它能把所有核心吃满去跑一个 GROUP BY,而这在传统嵌入式数据库里是不可想象的。
四、存储引擎:单文件、列存、MVCC 全都要
DuckDB 的持久化格式(那个 .duckdb 文件)本身就是一件工程艺术品。
4.1 Row Group + 列式分块
DuckDB 把表水平切成 Row Group,每个 Row Group 约 122880 行(120K,即 60 个 vector)。每个 Row Group 内部再按列存储,每列独立压缩。这个设计融合了行存和列存的优点:
- 列内压缩和向量化扫描:读取时只碰需要的列。
- Row Group 级别的元数据(min/max 统计):查询时可以做 Zone Map / 数据跳过——如果一个 Row Group 的某列 max 值都小于你的过滤条件下界,整块直接跳过不读。
- 批量更新友好:更新以 Row Group 为单位管理,支持高效的批量 append。
4.2 单文件里的 MVCC 事务
很多人以为分析型数据库不需要好的事务支持,DuckDB 偏不。它在单个文件内实现了完整的 ACID + MVCC(多版本并发控制):
- 你可以在一个事务里做复杂的多表更新,失败自动回滚。
- 读不阻塞写,写不阻塞读(快照隔离)。
- 支持
BEGIN/COMMIT/ROLLBACK。
这让它不仅能当分析引擎,还能当一个靠谱的本地数据仓库长期存数据。
4.3 超越内存:Larger-than-Memory 处理
这是 1.x 系列一个杀手级能力。很多人担心"嵌入式=只能处理放得进内存的数据",DuckDB 的回答是不。它的核心算子——Hash Join、Hash Aggregate、Sort——都实现了 out-of-core(外存溢写) 版本:当内存不够时,自动把中间数据溢写到磁盘临时文件,用类似外部归并排序的方式分批处理。
配置也简单:
-- 限制内存使用,超过就溢写到磁盘
SET memory_limit = '4GB';
SET temp_directory = '/tmp/duckdb_spill';
SET threads = 8;
于是你能在一台 8GB 内存的机器上,对一个 100GB 的 Parquet 数据集跑 GROUP BY——慢一点,但不会崩。这是 pandas 永远做不到的。
五、真正的杀手锏:直接查文件,不用导入
前面讲的都是引擎内功,但 DuckDB 在实际工作流里最爽的一点,是它可以直接对文件跑 SQL,完全跳过"建表-导入"这一步。
5.1 直接查 Parquet / CSV / JSON
-- 直接对本地 Parquet 文件查询,DuckDB 自动推断 schema
SELECT category, AVG(price)
FROM 'data/products.parquet'
GROUP BY category;
-- 用 glob 通配符一次扫一批文件(比如按天分区的日志)
SELECT COUNT(*)
FROM 'logs/2026-*/events_*.parquet'
WHERE status = 500;
-- CSV 也一样,自动嗅探分隔符、类型、表头
SELECT * FROM 'huge_export.csv' LIMIT 10;
这里 DuckDB 会做 projection pushdown(只读用到的列) 和 predicate pushdown(利用 Parquet 的行组统计跳过不匹配的块)。查一个几十 GB 的 Parquet,如果你只要两列且带过滤,它可能只真正读了几百 MB。
5.2 直接查云端对象存储(httpfs 扩展)
INSTALL httpfs;
LOAD httpfs;
-- 配置 S3 凭证
SET s3_region = 'us-east-1';
SET s3_access_key_id = '...';
SET s3_secret_access_key = '...';
-- 直接查 S3 上的 Parquet,只下载需要的字节(HTTP Range 请求)
SELECT user_id, SUM(revenue)
FROM 's3://my-bucket/events/2026/*.parquet'
GROUP BY user_id
HAVING SUM(revenue) > 1000;
DuckDB 用 HTTP Range 请求做部分读取——它先读 Parquet 文件尾部的元数据,判断哪些行组可能命中,再只下载那些行组的相关列。你不需要把整个文件从 S3 拉下来。这让 DuckDB 成了一个极其轻量的"数据湖查询引擎",配合它对 Iceberg、Delta Lake 的原生扩展支持,可以直接查开放表格式。
5.3 跨数据源联邦查询
真正的骚操作是把不同来源的数据 JOIN 在一起:
-- 一条 SQL 里同时查:本地 Parquet + 远程 PostgreSQL + CSV
ATTACH 'postgres://user:pw@host/db' AS pg (TYPE postgres);
SELECT o.order_id, o.amount, u.name, p.city
FROM 'orders.parquet' o
JOIN pg.users u ON o.user_id = u.id -- 来自 PostgreSQL
JOIN 'regions.csv' p ON u.region_id = p.id -- 来自 CSV
WHERE o.amount > 500;
DuckDB 通过一系列扩展(postgres、mysql、sqlite scanner)能把外部数据库当成表来查,还会尽量把过滤和投影下推到源库执行。这让它成了一个理想的联邦查询/ETL 中枢——数据不用先搬到一个地方就能联合分析。
六、代码实战:三个真实场景
理论讲够了,上三个我在实际项目里高频用到的场景。
场景一:把一坨 CSV 洗成干净的 Parquet(ETL)
数据工程里最常见的活儿:把杂乱的 CSV 转成压缩良好的 Parquet。传统上你要写一堆 pandas 分块读取代码防 OOM。DuckDB 一条 SQL 搞定,还自动多核并行、自动溢写:
import duckdb
con = duckdb.connect()
con.execute("SET memory_limit='6GB'; SET threads=8;")
con.execute("""
COPY (
SELECT
CAST(user_id AS BIGINT) AS user_id,
strptime(event_time, '%Y-%m-%d %H:%M:%S') AS event_time,
lower(trim(event_type)) AS event_type,
CAST(NULLIF(amount, '') AS DECIMAL(12,2)) AS amount
FROM read_csv_auto('raw/*.csv', ignore_errors=true)
WHERE user_id IS NOT NULL
)
TO 'clean/events.parquet'
(FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 122880);
""")
这段代码能处理远超内存的 CSV 集合,输出 zstd 压缩的 Parquet,全程吃满 8 核。同样的逻辑用 pandas 写,代码量翻三倍且随时可能 OOM。
场景二:给 Web 应用做嵌入式实时分析
假设你有个 SaaS 后台,要给用户展示"过去 30 天的使用趋势"。传统方案是查生产库(拖慢 OLTP)或搭 ClickHouse(运维成本)。DuckDB 可以作为一个嵌入的分析层:
import duckdb
# 应用启动时建立一个持久化连接(单文件数据库)
con = duckdb.connect("analytics.duckdb")
def daily_usage(tenant_id: int):
return con.execute("""
SELECT
date_trunc('day', event_time) AS day,
COUNT(*) AS events,
COUNT(DISTINCT user_id) AS active_users
FROM events
WHERE tenant_id = ?
AND event_time >= now() - INTERVAL 30 DAY
GROUP BY 1
ORDER BY 1
""", [tenant_id]).df() # 直接返回 DataFrame 给前端序列化
注意用了参数化查询(? 占位符)防注入。这个查询在百万级数据上通常是毫秒级,且完全不碰你的 PostgreSQL 主库。
⚠️ 并发注意:DuckDB 是为 OLAP 优化的,单进程内它支持多线程并发读,但写并发和高频点查不是它的强项。上面这种"一个应用进程内的分析连接"是它的甜区;如果你需要多个进程同时写同一个文件,那不是 DuckDB 的设计目标——它的哲学是"一个写者"。
场景三:DuckDB-Wasm——把数据库塞进浏览器
DuckDB 编译成了 WebAssembly,可以完全在浏览器里运行,不需要任何后端。这催生了一批纯前端的数据分析工具:用户拖一个 100MB 的 CSV 进网页,所有 SQL 查询在浏览器本地执行,数据一个字节都不上传服务器。
import * as duckdb from '@duckdb/duckdb-wasm';
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(mainModule, pthreadWorker);
const conn = await db.connect();
// 直接查浏览器里 fetch 来的 Parquet(走 HTTP Range,按需下载)
await conn.query(`
SELECT country, COUNT(*) AS n
FROM 'https://cdn.example.com/data.parquet'
GROUP BY country ORDER BY n DESC LIMIT 10
`);
这对做数据可视化、BI Dashboard、隐私敏感场景(数据不出浏览器)是降维打击。
七、性能优化:让 DuckDB 再快一截的实战要点
DuckDB 默认已经很快,但生产环境有几个杠杆值得拉:
优先用 Parquet 而非 CSV:CSV 每次查都要重新解析文本、推断类型。Parquet 是列存 + 带统计元数据,能利用行组跳过。一次性把 CSV 转 Parquet,后续所有查询提速数倍。
善用 Row Group 统计做数据跳过:写 Parquet 时按你常用的过滤列排序再落盘,能让相近的值聚在同一 Row Group,min/max 区间更紧凑,跳过效率更高。
COPY (SELECT * FROM events ORDER BY event_date, tenant_id)
TO 'events_sorted.parquet' (FORMAT parquet);
只 SELECT 你要的列:
SELECT *在列存里是反模式,它会强制读所有列。明确列名让 projection pushdown 生效。合理设置
threads和memory_limit:默认 threads = CPU 核数。在共享机器上适当调低避免抢占;memory_limit设为物理内存的 60-80%,给溢写留缓冲。用
EXPLAIN ANALYZE看真实执行计划:
EXPLAIN ANALYZE
SELECT region, SUM(amount) FROM 'big.parquet' GROUP BY region;
它会告诉你每个算子花了多少时间、处理了多少行、有没有触发溢写。看到 TOTAL_ROWS 远大于最终结果说明过滤没下推,看到磁盘溢写说明该调大内存了。
持久化数据库比每次读文件快:如果同一份数据要反复查,把它
CREATE TABLE ... AS SELECT进.duckdb文件,用上 DuckDB 自己的压缩存储和索引,比每次重新解析 Parquet 更快。利用 Zone Map,避免在高基数列上做无谓 DISTINCT:
COUNT(DISTINCT high_cardinality_col)是内存杀手,能用approx_count_distinct(HyperLogLog)就用近似值。
八、边界与思考:DuckDB 不是银弹
作为一个务实的工程师,必须清楚它的边界,否则用错场景会很难受:
- 它不是高并发 OLTP 数据库。单文件、单写者模型,不适合大量并发事务写入。要点查、要高 QPS 写入,老老实实用 PostgreSQL。
- 它不是分布式系统。单机能顶住 TB 级已经很强,但 PB 级、需要跨机器 shuffle 的超大规模,还是 Spark / Trino / ClickHouse 集群的地盘。(云服务 MotherDuck 试图把 DuckDB 引擎搬上云做混合执行,但那是另一个话题。)
- 多进程写同一文件会冲突。它的并发模型是"进程内多线程读 + 单写者",不要让多个进程同时写一个
.duckdb。 - 超大结果集回传要小心内存。虽然引擎能溢写,但如果你
.df()把一亿行拉回 pandas,内存照样爆——瓶颈转移到了应用侧。用流式游标(fetch_record_batch)分批取。
想清楚这些,DuckDB 的甜区就非常清晰了:单机/单节点的交互式分析、数据科学工作流、ETL 预处理、嵌入式 BI、数据湖轻量查询、CI 里的数据测试。在这些场景里,它往往能用 1% 的复杂度达到集群方案 90% 的效果。
九、总结:一次数据基础设施的"祛魅"
DuckDB 最大的价值,也许不在于它跑得多快(虽然确实很快),而在于它消解了"做分析必须搭一套重型基础设施"的思维定式。
过去我们下意识觉得:分析 = 集群 = 运维 = 复杂度。DuckDB 用一个 pip install duckdb(或者一个 .wasm 文件)告诉你:大多数时候,你的笔记本 + 一个进程内的库,就足以处理你 95% 的分析需求。它把 MonetDB/Vectorwise 二十年的学术成果——向量化执行、Morsel 并行、轻量压缩、列式存储——工程化地浓缩进了一个零依赖的库。
对程序员来说,这意味着一种全新的、极其轻盈的工作方式:数据不用搬家、环境不用配集群、结果零拷贝地流回你的代码。当你下次又要为一个几 GB 的数据分析任务去起 Spark 之前,不妨先问一句:这活儿,DuckDB 一行 SQL 是不是就干完了?
大概率,是的。