DuckDB 深度实战:当分析引擎塞进进程里——从向量化执行到湖仓一体(2026 深度长文)
2026 年 6 月,DuckDB 同时发布了 1.5.4(Variegata) 与 1.4.5 LTS(Andium) 两个版本,并正式把「client-server(Quack)」能力推到前台。一个当年被戏称为「分析界的 SQLite」的小项目,如今已经长成了能直接读 Parquet、查 Iceberg、连 Postgres、做向量检索、还能当湖仓查询引擎的「进程内数据操作系统」。本文不堆术语,从一次真实的数据分析痛点出发,把 DuckDB 的执行引擎、扩展体系和性能开关一次讲透,并配齐可运行代码。
一、背景:我们为什么一直在「搬运数据」?
任何一个做过数据分析的工程师都有过这种经历:
- 数据在 S3 / HDFS / 某个 MySQL 从库里;
- 你
pandas.read_csv或read_parquet把它拉到本地内存; - 做了一堆
groupby、merge; - 发现内存爆了,于是加
chunksize、改dtype、换polars; - 最后把结果再写回数据库或另一份文件。
整套流程里,真正产生价值的是第 3 步的聚合与关联,但 80% 的精力花在了第 2、4、5 步的「搬运」上。更讽刺的是:数据本来就在那儿,我们却非要把它「挪」到计算所在的地方。
SQLite 解决了「进程内 OLTP」的问题——一个文件就是一个数据库,零部署。但 SQLite 是行存 + 单线程 + 面向事务的,用来跑「扫描 10 亿行算个 SUM」这种分析查询,会慢到你怀疑人生。
ClickHouse / Doris / StarRocks 解决了「分析快」的问题,但它们是独立的服务进程,意味着你要起集群、管账号、建表、导数据——为了一个一次性的分析脚本,代价太大。
DuckDB 的赌注正是中间的空白地带:能不能像 SQLite 一样「开箱即用、嵌入进程」,却像 ClickHouse 一样「列式、向量化、为分析而生」? 答案是肯定的,而且它做得比想象中更彻底。
二、核心概念:什么是 in-process OLAP
2.1 三个关键词
- in-process(进程内):DuckDB 不是一个服务,而是一个被链接进你程序的库。Python 里
import duckdb之后,引擎就跑在你的解释器进程里,没有 socket、没有序列化、没有「客户端-服务端」那一层拷贝。 - OLAP(联机分析处理):它的存储与执行是为「少写多读、大扫描、重聚合」优化的,和 OLTP(高并发小事务)是两套完全不同的工程取向。
- 向量化列存:这是它快的真正原因(下一节展开),一句话概括——一次处理一整列的一批数据,而不是一行一行地解释执行。
2.2 它和谁不是对手
| 维度 | SQLite | Pandas | DuckDB | ClickHouse |
|---|---|---|---|---|
| 定位 | 进程内 OLTP | 内存数据框 | 进程内 OLAP | 分布式 OLAP 服务 |
| 存储 | 行存 B-tree | 内存列(但加载要全读) | 列存 + 直读 Parquet | 列存 |
| 执行 | 单行解释 | 单线程(GIL) | 向量化 + 多线程 | 向量化 + 分布式 |
| 部署 | 零 | 零 | 零 | 需集群 |
| 适合 | 嵌入式事务 | 小数据探索 | 单机/嵌入式分析 | PB 级服务 |
一句话:DuckDB 不打算取代 ClickHouse(它不想管分布式),也不打算取代 Pandas(它在内存里和 Pandas 是搭档)。它取代的是「为了做个分析而不得不搭一套数据管道」这件事本身。
2.3 设计哲学:数据不动,计算动
DuckDB 的核心口号是 "Run analytics where your data lives"(在数据所在的地方做分析)。它的意思不是「把数据挪到引擎」,而是「让引擎直接走到数据面前」——不管是本地 Parquet、S3 上的 Iceberg、还是远程的 Postgres 表,DuckDB 都能把查询「下推」过去,只把结果拿回来。
三、架构分析:DuckDB 为什么快
3.1 Vector / Chunk 执行模型
传统解释器执行 SQL 时,是一行一行地 _next()。DuckDB 则把数据按列切成一个个 Vector(默认 2048 行一组的列式批),整个算子(filter / join / aggregate)一次性吃进去一整批、吐出一整批。
这带来三个收益:
- 循环内无解释开销:一批 2048 行在一个紧凑循环里算完,CPU 分支预测和指令缓存友好;
- SIMD 友好:列存 + 定长类型天然适合向量指令;
- 缓存友好:同一列的数据连续存放,扫描时几乎没有指针跳转。
伪代码对比:
// 行式(慢):每行一次函数调用 + 随机内存访问
for row in table:
if row.age > 30: result.append(row)
// 列式向量化(快):一整块连续内存,一条紧凑循环
for i in 0..2048:
mask[i] = ages[i] > 30 // 整块比较,SIMD 可加速
select(rows, mask)
3.2 并行执行与 spill-to-disk
DuckDB 会自动把扫描和聚合并行到多个线程(默认等于 CPU 核数,可用 PRAGMA threads 调)。关键在于:它不是「内存不够就崩」,而是会 spill(溢写)到磁盘——当 Hash 表或排序缓冲超过 memory_limit 时,DuckDB 把中间状态写到临时文件,等需要时再读回来。这就是为什么它能用 4GB 内存「撬动」远超内存的数据集。
3.3 扩展即内核:everything is an extension
DuckDB 最有远见的设计是:很多你以为是「内置功能」的东西,其实都是扩展。Postgres 连接器、SQLite 连接器、Iceberg、Delta Lake、Spatial 空间计算、httpfs(读 S3/HTTP)、fts(全文检索)、vss(向量检索)……这些都以扩展形式存在,按需 INSTALL / LOAD。
这意味着内核保持精简,生态却能无限生长。对使用者来说,扩展是「声明式」的:你只要说要用,DuckDB 就去拉取并加载,无需运维。
3.4 2026 年的两个关键进展
- Client-Server(Quack):DuckDB 一直强调「进程内」,但 2026 年它补上了 client-server 协议(代号 Quack),让多个 client 能连到同一个 DuckDB 实例,兼顾了「嵌入式轻量」和「多人共享」。
- DuckLake:DuckDB 团队提出的开源湖仓格式(MIT 许可),把「元数据文件 + 开放数据格式(Parquet)+ 一个 catalog」打包成一个极简湖仓,进一步坐实「进程内湖仓」的故事。
四、代码实战
下面所有例子都不依赖任何外部服务,装好 duckdb 即可跑。
4.1 安装与最小可运行示例
pip install duckdb
import duckdb
# 关系型 API:返回的是 Relation,惰性执行
rel = duckdb.sql("SELECT 42 AS answer, 'duckdb' AS engine")
print(rel.fetchall()) # [(42, 'duckdb')]
print(rel.df()) # 直接变成 pandas DataFrame
# 也可以连一个持久化数据库文件(像 SQLite 一样)
con = duckdb.connect("analytics.duckdb")
con.sql("CREATE TABLE IF NOT EXISTS events(ts TIMESTAMP, uid INTEGER, kind VARCHAR)")
con.sql("INSERT INTO events VALUES (now(), 1, 'click'), (now(), 2, 'view')")
con.sql("SELECT kind, count(*) FROM events GROUP BY kind").show()
注意:duckdb.sql(...) 是模块级的「默认连接」,适合脚本;生产代码建议显式 connect() 拿一个 Connection 对象,避免多连接互相干扰。
4.2 直接查询文件与云端数据(无 ETL)
这是 DuckDB 最「爽」的能力——文件即表,不用先导入。
-- 直接查本地 Parquet,自动推断 schema
SELECT station_name, count(*) AS num_services
FROM 'train_services.parquet'
GROUP BY ALL
ORDER BY num_services DESC
LIMIT 5;
-- 直接查远程 HTTPS 上的 CSV(DuckDB 自动探测格式与类型)
CREATE TABLE stations AS
FROM 'https://blobs.duckdb.org/stations.csv';
-- 直接查 S3 上的 Parquet:用 httpfs 扩展 + 凭证
INSTALL httpfs; LOAD httpfs;
SET s3_region = 'us-east-1';
-- 若用访问密钥:
-- SET s3_access_key_id='...'; SET s3_secret_access_key='...';
SELECT order_id, sum(amount) AS gmv
FROM 's3://my-bucket/orders/*.parquet'
WHERE order_date >= DATE '2026-01-01'
GROUP BY order_id
ORDER BY gmv DESC
LIMIT 100;
*.parquet 这种通配 + 分区裁剪非常关键:DuckDB 会先读文件元信息,只拉包含相关分区的文件,再在列级别做投影裁剪(只读取 order_id、amount、order_date 三列)。数据「不下车」就能完成聚合。
4.3 与 Pandas 零拷贝互操作(Arrow 桥)
Pandas 和 DuckDB 之间通过 Apache Arrow 共享内存,避免「DataFrame → CSV 字符串 → 再解析」这种灾难性拷贝。
import pandas as pd
import duckdb
df_in = pd.DataFrame({
"station": ["Delft", "Delft", "Gouda", "Gouda"],
"day": ["Mon", "Tue", "Mon", "Tue"],
"num": [22, 20, 27, 25],
})
# 直接以 DataFrame 变量名当表名查询(DuckDB 自动扫描本地命名空间)
df_out = duckdb.sql("""
SELECT station, sum(num) AS total
FROM df_in
GROUP BY station
""").to_df() # 结果再零拷贝回 pandas
print(df_out)
经验法则:数据加载(read_parquet)交给 DuckDB,复杂聚合/关联交给 DuckDB 的 SQL,最后一步才 .df() 回到 Pandas 画图。这样 Pandas 永远只持有「小而干净」的结果,而不是「大而脏」的原始数据。
4.4 复杂分析:窗口函数 + 多表 JOIN
-- 计算每个用户当天内的点击排名与累计值(窗口函数实战)
SELECT
uid,
ts::DATE AS day,
kind,
row_number() OVER (
PARTITION BY uid, ts::DATE
ORDER BY ts
) AS seq,
sum(1) OVER (
PARTITION BY uid, ts::DATE
ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_cnt
FROM events
WHERE ts >= DATE '2026-07-01'
QUALIFY seq <= 3; -- QUALIFY:对窗口函数结果再过滤,省一层子查询
QUALIFY 是 DuckDB 对标准 SQL 的实用扩展:窗口函数算出来之后直接过滤,不用再包一层 SELECT ... WHERE seq <= 3。
4.5 数据库直连:把 Postgres/MySQL 当外表查
-- 把远程 Postgres 的表当本地表 JOIN
INSTALL postgres; LOAD postgres;
SELECT u.name, count(e.id) AS events
FROM postgres_scan('db.host', 5432, 'appdb', 'readonly', 'secret', 'users') u
JOIN events e ON e.uid = u.id
GROUP BY u.name
ORDER BY events DESC
LIMIT 20;
-- MySQL 同理
INSTALL mysql; LOAD mysql;
SELECT * FROM mysql_scan('db.host', 3306, 'appdb', 'readonly', 'secret', 'orders')
LIMIT 10;
postgres_scan 会把过滤条件下推到 Postgres(谓词下推),DuckDB 只拉回需要的行,而不是整张表。这对「跨库做一次性分析」是救命级能力——再也不用写导出脚本了。
4.6 全文检索(fts)与向量检索(vss)
-- 全文检索
INSTALL fts; LOAD fts;
CREATE TABLE docs(id INTEGER, body VARCHAR);
INSERT INTO docs VALUES
(1, 'DuckDB is an in-process analytical database'),
(2, 'ClickHouse is a columnar OLAP database'),
(3, 'SQLite is a lightweight embedded database');
PRAGMA create_fts_index('docs', 'id', 'body');
SELECT id, body FROM docs WHERE docs MATCH 'analytical';
-- 返回 id=1
-- 向量检索(语义相似度 / RAG 召回)
INSTALL vss; LOAD vss;
CREATE TABLE items(id INTEGER, emb FLOAT[3]);
INSERT INTO items VALUES
(1, [0.1, 0.2, 0.3]),
(2, [0.9, 0.8, 0.7]),
(3, [0.12, 0.21, 0.33]);
CREATE INDEX items_hnsw USING HNSW (emb); -- 建 HNSW 近似索引
SELECT id, array_distance(emb, [0.1, 0.2, 0.3]) AS dist
FROM items
ORDER BY dist
LIMIT 1;
-- 返回 id=1(最接近查询向量的那条)
这两段代码的意义在于:DuckDB 已经把「全文检索」和「向量检索」收编进同一个 SQL 引擎。对一个 RAG / 语义搜索场景,你不必为了向量再起一个专用数据库,直接在 DuckDB 里 fts + vss 双路召回,再用 SQL 做融合排序。
提示:扩展的具体 API 会随版本微调(如 vss 的 HNSW 在某些版本需要额外设置底层库路径),请以你所用版本的官方文档为准。
4.7 自定义函数 UDF / UDTF
import duckdb
con = duckdb.connect()
# 标量函数:输入 BIGINT,返回 BIGINT
def plus_one(x):
return x + 1
con.create_function("plus_one", plus_one, [duckdb.typing.BIGINT], duckdb.typing.BIGINT)
print(con.sql("SELECT sum(plus_one(i)) FROM range(10) t(i)").fetchone())
# (55,) —— 0..9 各加 1 后求和
# 表函数(UDTF):返回一个表
def squares(n):
import pandas as pd
return pd.DataFrame({"x": range(n), "x2": [i * i for i in range(n)]})
con.create_function("squares", squares, [duckdb.typing.BIGINT], None,
type="arrow") # 返回 Arrow Table
print(con.sql("SELECT * FROM squares(5)").fetchall())
# [(0,0),(1,1),(2,4),(3,9),(4,16)]
range(10) t(i) 这种「生成序列」语法在分析里极好用——替代 VALUES 堆数据或临时建表。
4.8 湖仓一体:DuckLake / Iceberg 直接查
-- 直读 Apache Iceberg 表(无需 Spark)
INSTALL iceberg; LOAD iceberg;
SELECT * FROM iceberg_scan('s3://warehouse/db/orders')
WHERE ts >= TIMESTAMP '2026-07-01'
LIMIT 100;
-- DuckLake:一个元数据文件 + 开放数据格式的极简湖仓
INSTALL ducklake; LOAD ducklake;
ATTACH 'ducklake:my_lake.ducklake' AS lake (DATA_PATH 's3://my-bucket/lake/');
SELECT dt, sum(gmv) FROM lake.orders GROUP BY dt ORDER BY dt;
DuckLake 的妙处是:它把「湖仓」降维成一个能 git 忽略、能随手删、能塞进对象存储的小文件,不需要 Hadoop、不需要独立 catalog 服务。对小团队来说,这是比「上一套 Iceberg + Trino」现实得多的选择。
4.9 新能力:Client-Server(Quack)
# 服务端(在另一台机器 / 同一台后台常驻)
# quack -p 3456 analytics.duckdb
# 客户端:用 quack:// 协议连接,用法和本地一模一样
import duckdb
con = duckdb.connect("quack://10.0.0.5:3456/analytics")
print(con.sql("SELECT count(*) FROM events").fetchone())
Quack 让「嵌入式」和「共享」不再二选一:脚本先单进程跑通,业务要多人共用时,挂一个 Quack 服务即可,SQL 一行不用改。
五、性能优化实战
DuckDB 开箱即快,但线上跑大查询时,这几个开关和习惯能决定是 3 秒还是 3 分钟。
5.1 调线程与内存上限
PRAGMA threads = 4; -- 并行度,默认 = CPU 核数
PRAGMA memory_limit = '4GB'; -- 超过则 spill 到磁盘,别让它吃光整机内存
PRAGMA enable_progress_bar = true;
在容器里跑尤其要设 memory_limit——否则 DuckDB 默认可用到「机器全部内存」,把同机其他服务挤爆。
5.2 列投影裁剪 + 分区/文件裁剪
- 只 SELECT 需要的列:列存下,少读一列就少一次磁盘扫描;
- 分区字段进 WHERE:
order_date >= ...这种过滤能让 DuckDB 跳过整批不相关的 Parquet 文件; - 用通配而非单文件循环:
s3://bucket/dt=*/events.parquet比「for 每个日期单独查」快得多,因为裁剪在引擎内一次性完成。
5.3 别让 Pandas 当「中间仓库」
反模式:
df = pd.read_parquet("s3://.../big.parquet") # 几十 GB 全进内存
df2 = df[df.amount > 100] # 内存里过滤
result = df2.groupby("uid").agg(...)
正确模式:
result = duckdb.sql("""
SELECT uid, count(*) FROM 's3://.../big.parquet'
WHERE amount > 100 GROUP BY uid
""").to_df() # 只有聚合后的小结果进 Pandas
让过滤和聚合发生在 DuckDB(甚至在远端对象存储的读取路径上),Pandas 只接结果。
5.4 物化中间结果(CTAS)
WITH 公用表表达式每次都会被重新计算。如果某个子查询被反复引用,用 CREATE TABLE ... AS(CTAS)物化它:
CREATE TABLE cleaned AS
SELECT * FROM 's3://.../raw.parquet'
WHERE quality = 'ok' AND ts >= DATE '2026-07-01';
-- 后续多次查询直接走 cleaned,不必反复扫原始大文件
SELECT uid, count(*) FROM cleaned GROUP BY uid;
5.5 看执行计划
EXPLAIN SELECT uid, count(*) FROM 'big.parquet' GROUP BY uid;
-- 或
DESCRIBE SELECT ...;
重点看计划里有没有 SEQ_SCAN 全扫了你本可以裁剪的文件,以及聚合算子前是否成功下推了过滤条件。
六、总结与展望
何时用 DuckDB
- 本地 / 笔记本 / Jupyter 里的探索性分析;
- 单机 TB 级 Parquet / CSV / JSON 聚合;
- 跨库(Postgres/MySQL)一次性分析,不想搭管道;
- RAG / 语义搜索里「全文 + 向量」双路召回;
- 轻量湖仓(DuckLake / Iceberg 直读);
- 嵌入式分析(Streamlit / dbt / CLI 工具内置查询)。
何时不用
- 高并发 OLTP(成千上万小事务)——那是 SQLite / Postgres 的活;
- 真正 PB 级、需要横向扩展的常驻服务——那是 ClickHouse / Doris 的活;
- 多写多改、强一致事务——DuckDB 默认是分析优化,写入不是它的主战场。
趋势判断
2026 年的 DuckDB 已经不再只是「SQLite 的分析版」。它在做一件更野的事:把分析能力下沉到每一个进程,让「查询数据」像「调用一个函数」一样廉价。Quack 补上了共享,DuckLake 补上了湖仓,fts/vss 补上了检索——一个进程内的、面向 AI 时代数据负载的「数据操作系统」雏形已经清晰。
对于工程师来说,最实在的建议只有一句:下次你想 pd.read_parquet 再 groupby 之前,先试试把那条 SQL 直接丢给 DuckDB。 你会发现,大部分「数据工程」的苦活,其实只是一个没被用对的查询下推。
参考版本:DuckDB 1.5.4(Variegata)/ 1.4.5 LTS(Andium),2026-06 发布。扩展 API(fts / vss / iceberg / ducklake)以你所使用版本的官方文档为准。