DuckDB 深度实战:把分析型数据库装进进程里——向量化执行引擎、Quack 客户端协议与 1.5.x 全解析
如果 SQLite 是「进程内 OLTP 的代名词」,那 DuckDB 想做的,就是「进程内 OLAP 的代名词」。它不跟你抢数据库服务的位置,它直接住进你的 Python 进程、你的浏览器、你的笔记本终端里,然后在一行 SQL 里把 100GB 的 Parquet 算完。本文从执行引擎原理、存储架构、代码实战到性能调优,把 DuckDB 1.5.x 掰开揉碎讲清楚。
一、背景:为什么我们需要一个「会分析的 SQLite」
先问一个朴素的问题:当你手头有一堆 CSV / Parquet / JSON,想做点聚合分析时,你通常会怎么做?
- 用 Pandas:
df.groupby(...).agg(...)。简单、直观,但数据是一次性读进内存的,几个 G 还能扛,几十 G 就 OOM 了;而且 Python 的 GIL 让很多计算本质上还是单核在跑。 - 用 SQLite:你可能会想,SQLite 不是「进程内数据库」吗?但 SQLite 是为**事务处理(OLTP)**设计的——行式存储、单写者、面向点查和短事务。你让它
SUM(amount)扫一张一亿行的表,它会老老实实把每一行从磁盘捞出来,慢得让人怀疑人生。 - 起一个 ClickHouse / Postgres / Spark:能力够了,但代价是「部署成本」。你得有服务、有连接、有运维。很多时候你只是想快速看一眼数据,不值得为它起一个集群。
DuckDB 的定位,正好卡在这三者之间的空白地带:进程内(in-process)、列式(columnar)、向量化(vectorized)的分析型数据库。它的设计口号是 "Analytics for Everyone",官网上一句话很传神——"Run analytics where your data lives"(在你的数据所在之处跑分析),而不是把数据搬到一个远端的数据库服务里。
它现在到哪了
截至本文写作时(2026 年 7 月),DuckDB 的最新稳定线是 1.5.x:
- DuckDB 1.5.4(代号 Variegata),发布于 2026-06-17,是 1.5 系列的最新补丁版;
- DuckDB 1.4.5 LTS(代号 Andium),同样发布于 2026-06-17,作为长期支持(LTS)版本,适合生产环境锁版本;
- 更往前,1.5.3 被称为「不是普通补丁版」(Not an Ordinary Patch Release),带来了 DuckDB-Iceberg 的重要增强;
- 2026-05 连续放出 Quack:DuckDB 客户端-服务端协议,以及 Lance Lakehouse 格式的支持。
这意味着 DuckDB 已经不满足于「单机嵌入式」了,它正在向「Lakehouse 查询引擎」和「轻量客户端-服务端」两个方向同时扩张。这也是本文重点讲的一块新东西。
二、核心概念:向量化执行引擎到底快在哪
DuckDB 快,不是玄学,核心在于它把传统数据库执行模型换了一个底层范式。要理解它,得先理解它的「对手」——火山模型(Volcano Model)。
1. 火山模型(Iterator / Volcano)的问题
经典关系型数据库(以及早期很多引擎)用的是火山模型 / 迭代器模型:执行计划是一棵算子(Operator)树,每个算子实现 next() 方法,调用子算子的 next() 拿到「一行」,处理后再返回「一行」给父算子。
HashJoin.next()
└─ Scan.next() → 返回 1 行
└─ Scan.next() → 返回 1 行
└─ ...
这个模型优雅、好实现,但有三个致命短板:
- 逐行(row-at-a-time)处理:每次
next()只搬一行,CPU 分支预测几乎永远猜不中,流水线被打断。 - 大量虚函数调用:每层算子都是一次虚函数分派(vtable lookup),在现代 CPU 上,函数调用本身的开销比实际计算还大。
- 缓存不友好:一行数据里往往混杂着多个列,字段类型不同、宽度不同,CPU 缓存行被塞满无关字段,cache miss 居高不下。
2. 向量化(Vectorized / Column-at-a-time)
DuckDB 用的是向量化执行:算子之间不传「一行」,而是传一个 Data Chunk(数据块)——一次一批 2048 行(这个常量叫 STANDARD_VECTOR_SIZE)。每个 Data Chunk 里,每一列是一个连续的、同类型的数组(Vector)。
HashJoin.next()
└─ Scan.next() → 返回 2048 行(列式存放:col_a[2048], col_b[2048]...)
└─ ...
这一改动带来质的区别:
- 批处理摊薄了函数调用开销:一次
next()处理 2048 行,虚函数调用次数直接降到 1/2048。 - CPU 向量指令(SIMD)吃得满:同一列 2048 个同类型值连续排列,编译器可以轻松生成 SIMD 指令(比如一次比较 4/8/16 个整数),cache 命中率也高。
- 列式对压缩极友好:同一列数据类型一致、局部性极强,RLE、字典编码、位压缩能压得很狠,磁盘 IO 和内存占用同时下降。
3. 延迟物化(Late Materialization)
列存数据库里有个经典取舍:是「先把整行拼出来再过滤」,还是「先用投影列算完,最后才去取其他列」?DuckDB 采用延迟物化——在过滤、聚合阶段只操作需要的列,等到真正要输出完整行时才去拼装其它列。这能大幅减少无谓的内存带宽消耗,尤其是 SELECT * 之外的绝大多数分析查询。
4. Morsel-driven 并行(任务窃取式多核并行)
单机多核怎么利用?DuckDB 用 Morsel-driven parallelism:把数据切成更小的 morsel(默认 256 行左右的小块),由一个**调度器(Scheduler)**把 morsel 作为「任务」分发给多个执行线程;线程空闲时就去「偷」别的线程的任务(work stealing)。
关键点在于:并行单位是「数据块」而不是「算子」,所以一个 Pipeline 里的多个算子可以流水线式地在多核上同时推进,而不是「扫描阶段全跑完 → 再聚合阶段全跑完」。你设 SET threads=8;,DuckDB 就能把一次扫描均匀铺到 8 个核上。
5. NULL 的表达方式:Validity Bitmap
列存里怎么表示「这一行这个字段是 NULL」?DuckDB 不为每个值多存一个布尔,而是给每一列配一张 Validity Bitmap(有效位图):第 i 位为 1 表示有效,为 0 表示 NULL。这样 NULL 判断变成一次位运算,而且 Bitmap 本身可以被压缩,进一步省空间。
一句话总结快的原因:列存 + 2048 行批处理 + SIMD + 延迟物化 + morsel 并行 + 位图 NULL,几样叠在一起,同样的数据、同样的 SQL,DuckDB 比逐行的 SQLite 快上几十到上百倍是常态。
三、架构分析:DuckDB 是怎么组成的
把 DuckDB 拆开,大致是这几层:
┌─────────────────────────────────────────────┐
│ 客户端接口:CLI / Python / Rust / Node / JDBC / WASM │
├─────────────────────────────────────────────┤
│ 解析(Parser) → 绑定(Binder) → 优化器(Optimizer) │
├─────────────────────────────────────────────┤
│ 执行引擎:Pipeline / Operator / Scheduler(并行) │
├─────────────────────────────────────────────┤
│ 存储层:列式 on-disk、压缩、zone map 统计信息 │
├─────────────────────────────────────────────┤
│ 扩展层:parquet / json / httpfs / iceberg / lance ... │
└─────────────────────────────────────────────┘
1. 存储层:列式落盘 + 智能压缩 + Zone Map
DuckDB 把数据按列存放在 .duckdb 数据库文件里(内存模式则是 :memory:)。每一列独立压缩,压缩算法按数据类型自适应选择:
- 常量编码:整列只有一个值 → 只存一次;
- 字典编码(Dictionary):低基数字符串 → 用整数 ID 代替原串;
- 游程编码(RLE):连续重复值 → 存「值 + 次数」;
- 位压缩(Bitpacking):小范围整数 → 按实际需要的位数存储。
更有意思的是统计信息(Statistics / Zone Map):写入时,每个数据块都会记录该块的 min、max、null_count 等。查询时,如果 WHERE amount > 100000,优化器直接拿过滤条件和各块的 min/max 比对——那些 max < 100000 的块整块跳过,根本不读。这就是「Zone Map 剪枝」,是列存分析库快的一大隐形功臣。
2. 优化器:基于统计信息的代价优化
DuckDB 的优化器会做谓词下推(Predicate Pushdown)、列剪枝(Projection Pushdown)、连接顺序重排(Join Order)、公共子表达式消除等。它依赖前面说的统计信息来做基于代价的决策。比如你写了 SELECT a FROM t WHERE b > 10,优化器会:
- 列剪枝:只读取列
a和b,别的列根本不碰(延迟物化的上游); - 谓词下推:把
b > 10尽量推到扫描阶段,配合 Zone Map 跳过无关块。
3. 客户端接口全家桶
DuckDB 最舒服的一点:它几乎在你用得到的每种环境里都有原生客户端:
- CLI:
duckdb命令,进交互式 SQL 终端; - Python:
duckdb包,能直接用、也能和 Pandas/Arrow 零拷贝互转; - Rust:
duckdb-rs; - Node.js / JavaScript:含 WASM 版本,能直接在浏览器里跑 SQL 查本地文件;
- Java:JDBC 驱动;
- 还有 Go、C++ 等绑定。
4. 扩展机制:装插件而不是造轮子
DuckDB 把很多能力做成扩展(Extension),随用随装。常见内置可装的有:
parquet、json:直接读写这些格式;httpfs:从http(s)/ S3 / GCS 读数据;iceberg、lance:直接查 Lakehouse 表;spatial:地理空间;fts:全文检索;postgres/sqlite:把外部数据库ATTACH进来当外表用。
而且从较新版本起,DuckDB 支持扩展自动加载(autoload):你写 SELECT * FROM 'x.parquet',它发现需要 parquet 扩展,会自动去装。当然生产环境建议显式 INSTALL + LOAD 并锁版本。
5. 新特性:Quack 客户端-服务端协议(beta)
这是 1.5 时代最值得关注的变化之一。传统上 DuckDB 是嵌入式的——你的进程里有一个 DuckDB 实例,别的进程连不上。但现实里你经常想要「一个共享的分析实例,多个客户端连上来」。
Quack 就是为此设计的 DuckDB 二进制客户端-服务端协议(2026-05 公布)。它的意思是:你可以启动一个 DuckDB 服务端,多个客户端(CLI、Python、其他语言)通过 Quack 协议连上去,共享同一份数据和计算。这对「嵌入式数据库」来说是一次范式扩张——DuckDB 既能当「进程里的引擎」,也能当「轻量服务端」。
注意:Quack 目前处于 beta 阶段,连接方式、版本兼容(客户端与服务端最好同版本)以官方文档为准。下面的示例是为了讲清思路,落地前请核对对应版本的语法。
# 启动一个 DuckDB 服务(示意,beta 阶段请以官方文档为准)
duckdb --server 0.0.0.0:5432
客户端侧,则可以把远端服务 ATTACH 成本地可见的库来查询。这让 DuckDB 在「本地探索」和「小团队共享查询」之间有了平滑过渡。
6. 新特性:Lakehouse 直查(Iceberg / Lance)
另一个大方向是 DuckDB 正在成为** Lakehouse 的轻量查询入口**:
- Iceberg:1.5.3 大幅增强了 DuckDB-Iceberg 能力,可以用
iceberg_scan(...)直接读 Apache Iceberg 表(包括一些移动/重写过的路径); - Lance:2026-05 起支持 Lance 格式数据集,适合多模态 / 向量类数据。
也就是说,你的数据哪怕躺在 S3 上的 Iceberg / Lance / Parquet 里,也可以不启 Spark、不启 Trino,直接用 DuckDB 一个 SQL 扫出来。
四、代码实战
光讲原理不过瘾,下面全部是可运行的真实代码。
0. 安装
# Python
pip install duckdb
# CLI(macOS)
brew install duckdb
# 或各平台官方二进制
1. 一行 Python 读 Parquet 并聚合
最经典的「DuckDB 时刻」:不用 Pandas 读全量,直接对 Parquet 文件跑 SQL。
import duckdb
con = duckdb.connect() # 默认内存模式
# 直接把 Parquet 当表查;DuckDB 按需流式读取,不会一次性把文件塞进内存
sql = """
SELECT
user_id,
COUNT(*) AS events,
SUM(amount) AS total_amount,
AVG(duration_ms) AS avg_dur
FROM 'events.parquet'
WHERE event_date BETWEEN DATE '2026-01-01' AND DATE '2026-06-30'
GROUP BY user_id
ORDER BY total_amount DESC
LIMIT 10
"""
top_users = con.execute(sql).fetchall()
for row in top_users:
print(row)
注意这里的关键价值:events.parquet 哪怕有 100GB,只要你的聚合结果不大,DuckDB 也能在有限内存里算完——它边读边聚合、边利用 Zone Map 跳过无关块,而不是「先全读进来」。
2. 直接读 CSV / JSON / 多文件
# CSV:自动推断 schema、类型
con.execute("""
CREATE TABLE orders AS
SELECT * FROM read_csv_auto('orders/*.csv', header=true)
""")
# JSON(支持嵌套,自动展开)
con.execute("""
INSERT INTO orders
SELECT * FROM read_json_auto('events/*.json')
""")
# 多文件通配(glob):一次性扫一整个目录的 Parquet
con.execute("""
CREATE TABLE events AS
SELECT * FROM 'events/2026-*.parquet'
""")
read_csv_auto / read_json_auto 会自动探测列类型、分隔符、是否有表头,省去你手写 schema 的麻烦。
3. 窗口函数与复杂分析
-- 计算每个用户的消费排名、累计占比、同比环比(窗口函数)
SELECT
user_id,
month,
total_amount,
RANK() OVER (PARTITION BY month ORDER BY total_amount DESC) AS month_rank,
SUM(total_amount) OVER (
PARTITION BY user_id ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
total_amount - LAG(total_amount) OVER (
PARTITION BY user_id ORDER BY month
) AS mom_delta
FROM monthly_user_amounts
ORDER BY user_id, month;
4. 跨库 JOIN:把 SQLite / Postgres 挂进来
这是 DuckDB 很「魔法」的能力——它不只是查文件,还能把别的数据库 ATTACH 成外表,直接跨库 JOIN。
-- 把业务库的 SQLite 挂进来
ATTACH 'app.db' AS app (TYPE sqlite);
-- 把数仓的 Postgres 挂进来(需 postgres 扩展)
ATTACH 'dbname=dwh user=analyst host=localhost' AS dwh (TYPE postgres);
-- 现在我们能在一条 SQL 里,把本地 Parquet、SQLite、Postgres 三处数据联起来
SELECT
o.id AS order_id,
u.name AS user_name,
dwh.tier AS user_tier
FROM app.orders o
JOIN app.users u ON o.user_id = u.id
JOIN dwh.users dwh ON dwh.user_id = u.id
WHERE o.amount > 1000;
这种「联邦查询」对做数据分析的人来说简直救星:以前要导数据、写 ETL,现在一条 SQL 跨三源。
5. 直接查 S3 上的 Parquet(httpfs)
import duckdb
con = duckdb.connect()
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("SET s3_region = 'us-east-1';")
# 若用静态凭证(更推荐用 IAM / 环境变量,避免硬编码)
con.execute("SET s3_access_key_id = 'YOUR_KEY';")
con.execute("SET s3_secret_access_key = 'YOUR_SECRET';")
# 直接从 S3 扫 Parquet,配合分区路径还能做文件级剪枝
rows = con.execute("""
SELECT region, SUM(revenue) AS rev
FROM 's3://my-bucket/sales/region=*/dt=2026-07-*.parquet'
GROUP BY region
""").fetchall()
print(rows)
6. 与 Pandas / Arrow 零拷贝互转
import duckdb, pandas as pd
# DuckDB 结果 → Pandas
df = con.execute("SELECT * FROM events LIMIT 1000").df()
# Pandas → DuckDB(注册为视图,无需复制数据)
con.register("py_df", df)
con.execute("SELECT COUNT(*) FROM py_df").fetchone()
# DuckDB 结果 → Arrow(分析生态互通)
tbl = con.execute("SELECT * FROM events").arrow()
print(tbl.schema)
df() 给 Pandas,arrow() 给 Arrow——两者都是「就地转换引用」,而不是笨重的逐行序列化,所以和 Python 数据科学生态(Pandas、Polars、PyArrow)衔接非常顺。
7. 导出列式 Parquet(COPY)
分析完,把结果存成高效的列式 Parquet,比留 CSV 强太多:
-- 把查询结果直接写成 Parquet(列式、可压缩、可被任何引擎读取)
COPY (
SELECT user_id, SUM(amount) AS total
FROM 'events.parquet'
GROUP BY user_id
) TO 'user_totals.parquet' (FORMAT PARQUET, CODEC ZSTD);
-- 或者按 Hive 分区写出,方便后续用分区列做剪枝
COPY monthly_summary
TO 's3://bucket/summary/'
(FORMAT PARQUET, PARTITION_BY (year, month), CODEC ZSTD);
PARTITION_BY 写出的目录结构(summary/year=2026/month=07/...)让后续查询在 WHERE year=2026 AND month=7 时直接跳过其它分区文件,这是大数据查询提速的基本功。
8. 用 Quack 起一个共享服务(beta,示意)
# 服务端:启动 DuckDB 服务
duckdb --server 127.0.0.1:5432
# 客户端 A(Python):连接远端服务做查询
python3 -c "
import duckdb
con = duckdb.connect()
con.execute(\"ATTACH 'server:127.0.0.1:5432' AS shared (TYPE duckdb_server)\")
print(con.execute('SELECT COUNT(*) FROM shared.main.events').fetchone())
"
再次提醒:Quack 处于 beta,上面的连接串是示意写法,实际参数(端口、认证、版本匹配)请参考你所用的 DuckDB 版本的官方文档。
五、性能优化:把 DuckDB 跑满
DuckDB 默认就已经很快,但想「跑满」,有几条实战经验:
1. 为什么它比 Pandas 快(可复现对比)
import duckdb, pandas as pd, time
# ---- Pandas 路线:先把整个文件读进内存 ----
t0 = time.time()
df = pd.read_parquet('events.parquet')
res_pandas = df.groupby('user_id')['amount'].sum().nlargest(10)
print("pandas 耗时:", round(time.time() - t0, 3), "s")
# ---- DuckDB 路线:按需流式,向量化执行 ----
t0 = time.time()
res_duck = duckdb.sql("""
SELECT user_id, SUM(amount) AS total
FROM 'events.parquet'
GROUP BY user_id
ORDER BY total DESC
LIMIT 10
""").df()
print("duckdb 耗时:", round(time.time() - t0, 3), "s")
同样的聚合,DuckDB 通常明显更快,且在数据远大于内存时不会 OOM——因为它根本不要求「整表驻留内存」。
2. 控制并行度与内存
-- 设定使用的线程数(默认会用满所有核)
SET threads = 4;
-- 设定内存上限,避免把机器拖垮(DuckDB 会在超限时落盘 spill)
SET memory_limit = '4GB';
-- 临时文件落盘位置(spill 用)
SET temp_directory = '/tmp/duckdb_spool';
3. 让 Zone Map 和分区剪枝生效
- 按时间/类别做 Hive 分区存 Parquet(
dt=2026-07-01/),查询时WHERE dt='2026-07-01'直接跳过其它目录; - 避免对分区/过滤列做函数包裹,例如不要写
WHERE DATE(event_time) = '2026-07-01',改成WHERE event_time >= '2026-07-01' AND event_time < '2026-07-02',这样 Zone Map 才能用上; - 把高频过滤列放在前面、单独成列,别塞进一个 JSON 大字段里——列存对「结构化列」的剪枝远强于对「字段内 JSON」的解析。
4. 投影剪枝:只选要的列
-- 好:只读两列,I/O 和内存都省
SELECT user_id, amount FROM events WHERE amount > 100;
-- 差:SELECT * 会把所有列都从磁盘捞出来
SELECT * FROM events WHERE amount > 100;
分析查询几乎永远不该 SELECT *,这是列存库最基础的修养。
5. 用 Parquet 而不是 CSV 当数据源
CSV 是文本、无统计信息、无压缩、无类型——DuckDB 读它要做解析、推断类型,还没法做 Zone Map。能转成 Parquet(ZSTD 压缩)就转,查询速度和存储体积会同时改善一个量级。
6. 预计算与物化
对反复跑的聚合,与其每次现场算,不如:
-- 建一张汇总表,定期(如每天)刷新
CREATE TABLE daily_user_total AS
SELECT user_id, event_date, SUM(amount) AS total
FROM 'events.parquet'
GROUP BY user_id, event_date;
-- 之后查某天 Top10 只需扫这张小表
SELECT user_id, total FROM daily_user_total
WHERE event_date = DATE '2026-07-01'
ORDER BY total DESC LIMIT 10;
7. 几个常见坑
- 字符串不变更小:DuckDB 的字典编码对低基数字符串很友好,但如果你把 UUID、长 URL 当维度列频繁聚合,代价会比整数维度高,能用整数 ID 就别用长字符串;
- 不要每查一次就重连建库:复用同一个
con连接,复用已注册的视图; - 服务端模式仍在 beta:上生产前务必确认 Quack 在你目标版本的成熟度,不要盲信 beta 的稳定性承诺。
六、总结与展望
把上面串起来,DuckDB 的「性格」其实非常清晰:
它是什么:
- 进程内、列式、向量化的分析型数据库;
- 不替代你的主数据库,而是做「数据探索 + 轻量 ETL + 联邦查询」那一层;
- 1.5.x 已经能直接吃 Parquet / CSV / JSON / S3 / Iceberg / Lance,还能 ATTACH SQLite / Postgres 做跨库 JOIN。
它为什么快:
- 2048 行一批的向量化执行 + SIMD;
- 列存 + 自适应压缩 + Zone Map 剪枝;
- morsel-driven 多核并行 + 延迟物化 + 位图 NULL。
它正在变成什么:
- 通过 Quack 协议走向轻量客户端-服务端,从「嵌入式」跨向「可共享」;
- 通过 Iceberg / Lance 支持成为 Lakehouse 的「本地入口」,让数据分析师不必起重型引擎就能查湖仓;
- 配合 WASM,甚至能在浏览器里直接分析本地文件,催生「local-first analytics(本地优先分析)」的新工作流。
什么时候该用它 / 不该用它:
- ✅ 单机/笔记本上的数据探索、 ad-hoc 分析、把多个数据源(文件 + SQLite + Postgres)联起来、写脚本做轻量 ETL、Lakehouse 的即席查询;
- ❌ 超高并发的多租户服务端(那是 ClickHouse / 专业数仓的活);需要强事务 ACID 的高并发写入(那是 Postgres / MySQL 的活);超大规模分布式计算(那是 Spark / Trino 的活)。
一句话收尾:DuckDB 不跟你抢「数据库服务」的位子,它直接住进你的进程里,把「分析」这件本来要起服务才能干的事,变成一行 SQL 的事。 在 1.5.x 这一代,它又顺手把 Lakehouse 和轻量服务端这两扇门也推开了——对写代码的人来说,这大概就是「手边最趁手的那个工具」该有的样子。