编程 DuckDB 深度拆解:当「进程内」分析数据库把 Pandas + PostgreSQL 的活儿一口气干了——从列式存储、向量化执行到零拷贝 Arrow 的全链路实战

2026-08-19 02:43:06 +0800 CST views 6

DuckDB 深度拆解:当「进程内」分析数据库把 Pandas + PostgreSQL 的活儿一口气干了——从列式存储、向量化执行到零拷贝 Arrow 的全链路实战

2026 年 7 月,DuckDB 1.5.5 发布;同一周官方连发两篇硬核博客:《Asynchronous I/O in DuckDB: Work, Thread, Work》与 DuckLake 湖仓格式的落地。一个号称「分析界的 SQLite」的项目,正在悄悄把本地分析、ETL、特征工程甚至湖仓的活儿全揽到自己身上。本文不堆参数、不喊口号,从存储格式、执行引擎、异步 I/O 模型一路拆到可运行代码与生产调优清单。

一、背景介绍:为什么我们需要一个「进程内 OLAP」

每个写过数据脚本的人都踩过同一个坑:

  • 想对 2 GB 的 CSV 做个 GROUP BY,Pandas 直接把内存干爆;
  • 想查一份存在对象存储上的 Parquet,得先起一个 Spark / Trino 集群,或者把数据导进 PostgreSQL 才能用 SQL;
  • 一个再简单不过的「统计一下昨天新增用户数」的需求,链路是:导出 → 落库 → 写 SQL → 取数 → 回 Python 画图。

问题出在哪?OLTP 数据库(SQLite、PostgreSQL、MySQL)是为「点查 + 事务」设计的,行式存储、面向单行的增删改;而分析(OLAP)要的是「扫一大片、算聚合」,列式存储 + 向量化执行才是对的路。 但传统 OLAP(ClickHouse、Doris、BigQuery)都是「服务器」,要部署、要运维、要为那一次临时查询付出一整套基础设施成本。

DuckDB 想做的事很明确:把 OLAP 引擎塞进程里。你 import duckdb 的那一刻,一个完整的列式向量化分析引擎就在你的 Python / Node / Rust 进程内启动了——不需要服务器、不需要配置、不需要 DBA。它直接 SELECT 一个 Parquet 文件、一个 S3 上的目录、甚至一个 Pandas DataFrame,而且速度经常比「先落 PostgreSQL 再查」快一个数量级。

这就是为什么它的 slogan 是 "SQLite for Analytics",但更准确的说法是:它把「数据分析师手里的 Pandas」和「工程师手里的 PostgreSQL」的活儿,一口气干了。

本文基于 DuckDB 1.5.5(2026-07-22 GA),结合官方近期的异步 I/O 与 DuckLake 博客,做一次全链路拆解。

二、核心概念:先厘清三个被混淆的词

在拆架构之前,必须先统一语言,否则后面全乱。

2.1 进程内(In-Process)≠ 嵌入式(Embedded)

SQLite 是「嵌入式」——它跑在调用者的进程里,但核心是为并发事务和点查优化。DuckDB 同样是进程内,但优化目标是分析查询吞吐。两者共享「无服务器」的形态,但内部几乎是两个物种:

维度SQLiteDuckDB
存储模型行式(Row Store)列式(Column Store)
执行模型逐行 Volcano向量化(批量 2048 行)
最佳场景OLTP、点查、事务OLAP、扫描、聚合、JOIN
并发写库级锁,支持多写者轮转单写者 + 多读者(MVCC)
典型数据量MB ~ 几 GB内存放不下也能 spill 到磁盘,TB 级可扫

2.2 列式存储到底省了什么

假设一张表有 10 列、1 亿行,你想算 SUM(amount)

  • 行式存储:必须把 10 列 × 1 亿行的全部字节从磁盘读进内存,再逐行挑出 amount
  • 列式存储:磁盘上 amount 这一列是连续存放的,只读这一列,其他 9 列连碰都不碰。I/O 直接砍掉 90%。

更妙的是,列式数据天然「同质」——同一列类型相同、数值范围相近,压缩率极高(字典编码、RLE、位打包、Delta),而且压缩后往往能直接在被压缩字节上做运算(所谓 light-weight compression),连解压都省了。

2.3 向量化执行:从「一次搬一行」到「一次搬一车」

传统执行引擎(Volcano 模型)是「拉模型」:上层算子每次向下层要 一行,处理完再要下一行。问题:

  1. 每行一次虚函数调用,CPU 分支预测疯狂 miss;
  2. 一次只处理一个值,SIMD 指令(一条指令并行算 8/16 个浮点)完全用不上;
  3. 缓存行的利用率极低。

DuckDB 改成向量化(Vectorized / Batch)执行:算子之间传递的是「向量」——一个固定大小(默认 2048 行)的列块。一个 SUM 算子一次拿到 2048 个 amount,用一个紧凑循环(甚至编译器自动向量化成 SIMD)一次性累加。这把「每行一次函数调用」变成了「每 2048 行一次」,CPU 吞吐直接起飞。

三、架构分析:DuckDB 是怎么把快做进骨头的

下面这张图是 DuckDB 的逻辑分层(自上而下):

┌─────────────────────────────────────────────┐
│   SQL Parser / Binder / Optimizer (基于 Caveat 的代价优化)  │
├─────────────────────────────────────────────┤
│   Pipeline 执行引擎(向量化 + Morsel-Driven 并行)        │
│     ├─ Scan 算子(Parquet / CSV / Arrow / 表)          │
│     ├─ Filter / Project / Aggregate / HashJoin        │
│     └─ 异步 I/O 调度:「Work, Thread, Work」            │
├─────────────────────────────────────────────┤
│   存储层(列式 Data Block、轻量压缩、Zone Map 统计)     │
├─────────────────────────────────────────────┤
│   Catalog(表/视图/宏元数据)+ Transaction(MVCC)      │
├─────────────────────────────────────────────┤
│   Extensions(httpfs / parquet / json / icu / fts ...)│
└─────────────────────────────────────────────┘

3.1 存储层:列式块 + 轻量压缩 + Zone Map

DuckDB 把表按行切成 Row Group(默认约 12 万行),每个 Row Group 内每列存成一个 Column Chunk。每个 Column Chunk 用一种轻量压缩:

  • Constant:整列一个值(比如分区列常量为 '2026-08'),直接存一个值;
  • Dictionary:去重率高时存字典 + 整数编码;
  • Run-Length:连续相同值压成 (值, 次数)
  • BitPacking / Delta:对整数序列做差再位打包,时间戳、自增 ID 收益巨大;
  • Uncompressed:实在压不动就原样。

关键设计:压缩是「轻量」的,解码一条只要几次位运算,且很多算子直接在压缩态上算。比如算 COUNT(*),只要扫各块的统计信息,根本不用读数据;算 MIN/MAX,Zone Map(每个块记录 min/max)能直接跳过整块不满足谓词的数据——这就是**谓词下推(predicate pushdown)**在存储层的基石。

3.2 执行引擎:向量化 Volcano + Pipeline + Morsel-Driven 并行

DuckDB 的执行计划会被切成多条 Pipeline。一条 Pipeline 是一串「无阻塞」算子的组合,比如:

Scan(Parquet) → Filter → Project → Aggregate

Pipeline 之间是推模型(push-based):Scan 吐出一个向量,立刻被 Filter 处理,再立刻进 Aggregate,全程在 CPU 缓存里流动,不落盘、不排队。

并行靠 Morsel-Driven Parallelism:Scan 把一个大文件切成很多小片(morsel),扔进一个全局任务队列;Worker 线程池不断抢 morsel 来算。妙处在于不需要提前把数据分片规划好——谁来空闲谁就多干,天然负载均衡,也没有「某个分片特别大导致长尾」的尴尬。

3.3 异步 I/O:Work, Thread, Work(2026-07-31 官方博客重点)

这是 1.5.x 最值得讲的新机制。传统「扫描远程 Parquet(S3/HTTP)」的朴素做法是:

线程扫到需要某一块数据 → 发起读请求 → 阻塞等 I/O 回来 → 再继续算。

在「计算线程等 I/O」的空窗里,昂贵的 CPU 核心在睡觉。DuckDB 的改法是 Work, Thread, Work

  1. 算完手头这段(Work);
  2. 需要下一块远程数据时,不阻塞,而是发起异步 I/O,把「等数据」这件事交给后台 I/O 线程;
  3. 计算线程立刻去抢另一个 morsel 继续算(Work),而不是干等;
  4. 当异步读完成,结果被挂回任务队列,后续 Pipeline 阶段消费它。

效果:CPU 和 I/O 重叠起来跑。对于「远程对象存储上扫大 Parquet」这种 I/O 密集型分析,吞吐提升非常明显——因为网络往返的等待时间被计算完全掩盖了。这也是为什么 httpfs 扩展在 1.5 之后读 S3 目录快了很多。

3.4 事务与 MVCC:单写者多读者的代价

DuckDB 用 MVCC(多版本并发控制) 但实现很轻:存储是**仅追加(append-only)**的,一次 COMMIT 追加一个 transaction 块,旧版本靠版本号标记。并发模型是:

  • 同一时刻只允许一个写者(BEGIN 写事务会拿写锁);
  • 多个读者可同时跑,互不阻塞,因为读的是某个 snapshot 版本。

这意味着 DuckDB 不适合高并发 OLTP(别拿它当 Web 应用的主库)。但它的目标本来就不是这个——它是「分析引擎」,写通常是批量 ETL 式的大写入,一次写完大家读,MVCC 刚刚好。

3.5 扩展机制:核心很小,能力靠插件

DuckDB 内核只保证核心 SQL + 基础类型。读 Parquet、读远程文件、JSON、全文检索、ICU 排序、甚至 DuckLake,全都是 Extension(动态加载的 .duckdb_extension)。这让它体积小、启动快,又能按需长出「读 S3」「查湖仓」的本事。

四、代码实战:从一行 SQL 到生产级玩法

下面全是 DuckDB 1.5.5 可运行代码

4.1 零门槛起步:直接查文件,不落地

# pip install duckdb==1.5.5
import duckdb

# 1) 直接 SELECT 一个 Parquet,根本不用建表、不用导入
rows = duckdb.sql("""
    SELECT user_id, SUM(amount) AS total
    FROM 's3://my-bucket/events/2026-08-*.parquet'
    WHERE event_type = 'purchase'
    GROUP BY user_id
    ORDER BY total DESC
    LIMIT 10
""").fetchall()
print(rows)

注意:通配符 2026-08-*.parquet 会被展开成多个文件,且扫描时只读取用到的列按 Zone Map 跳过不匹配的文件/行组。你写的是 SQL,底层是列式 + 向量化 + 异步 I/O 的全力奔跑。

4.2 零拷贝:和 Pandas / Arrow / Polars 互通

这是 DuckDB 最爽的地方——它和 Arrow 内存格式兼容,所以和 Pandas DataFrame 之间是零拷贝转换:

import pandas as pd
import duckdb

df = pd.DataFrame({"id": [1, 2, 3], "v": [10, 20, 30]})

# 直接把 pandas DataFrame 当表来查,不复制数据
rel = duckdb.sql("SELECT SUM(v) AS s FROM df")
print(rel.fetchone())   # (60.0,)

# DataFrame -> Arrow -> 回 pandas,全程零拷贝
arrow_tbl = duckdb.sql("SELECT * FROM df WHERE v > 15").arrow()
back = arrow_tbl.to_pandas()
print(back)

也可以直接查询 Polars DataFrame(DuckDB 1.x 原生支持):

import polars as pl
import duckdb

pf = pl.DataFrame({"x": range(1_000_000), "y": range(1_000_000)})
out = duckdb.sql("SELECT AVG(x * y) AS r FROM pf").fetchone()
print(out)

4.3 批量写入:用 Appender,别用逐行 INSERT

新手最爱这么写,然后骂 DuckDB 慢:

# ❌ 灾难:每行一次 INSERT,事务 + 解析开销爆炸
con = duckdb.connect("analytics.duckdb")
con.execute("CREATE TABLE t(id INTEGER, v DOUBLE)")
for row in huge_iterable:
    con.execute("INSERT INTO t VALUES (?, ?)", row)

正确姿势——Appender(列块批量写,绕过逐行解析):

# ✅ 批量追加,接近裸写存储层的速度
con = duckdb.connect("analytics.duckdb")
con.execute("CREATE TABLE t(id INTEGER, v DOUBLE)")

# 方式一:一次性从 DataFrame / Arrow 追加(最快)
con.execute("INSERT INTO t SELECT * FROM df")

# 方式二:流式 Appender,适合逐条产生的数据
app = con.append("t")
for row in stream():
    app.append(row)        # 内部累积成向量后批量落盘
app.close()                # 别忘了 flush

实测:1000 万行,逐行 INSERT 可能要几分钟;Appender / 批量 INSERT 往往几十秒。差距来自「向量化写入 vs 单行事务」。

4.4 远程扫描 + 分区裁剪:DuckDB 当湖仓查询引擎

import duckdb

duckdb.sql("INSTALL httpfs; LOAD httpfs;")
duckdb.sql("""
    SET s3_region = 'us-east-1';
    -- 如用鉴权:SET s3_access_key_id='...'; SET s3_secret_access_key='...';
""")

# 按 Hive 分区路径自动识别分区列(dt=.../region=...)
rel = duckdb.sql("""
    SELECT region, COUNT(*) AS cnt
    FROM 's3://lake/events/dt=2026-08-*/region=*/*.parquet'
    GROUP BY region
""")
print(rel.fetchall())

Duckdb 会:

  • dt=2026-08-* 这类路径当成分区裁剪条件;
  • 只下载需要的列、需要的行组;
  • 配合 1.5 的异步 I/O,网络等待和 CPU 计算重叠。

4.5 DuckLake:当 DuckDB 长出「湖仓」的翅膀

2026 年 DuckDB Labs 推出 DuckLake——一个极简湖仓格式:

  • **元数据(Catalog)**存在你熟悉的数据库里(SQLite 或 PostgreSQL);
  • 数据文件是对象存储上的 Parquet;
  • 通过 Catalog 提供 ACID时间旅行(time travel)Schema 演进
  • 计算引擎就是 DuckDB 本身(也可接 Spark 等)。
-- 在 DuckDB 里直接用 DuckLake(需安装 ducklake 扩展)
INSTALL ducklake; LOAD ducklake;

-- 用 PostgreSQL 当 catalog 后端,把数据落在 S3
ATTACH 'ducklake:host=localhost port=5432 db=mylake user=me' AS my_lake;

CREATE TABLE my_lake.sales (id BIGINT, amount DOUBLE, ts TIMESTAMP);

-- 像普通表一样写,底层是 ACID + Parquet
INSERT INTO my_lake.sales
SELECT * FROM read_parquet('s3://raw/sales/*.parquet');

-- 时间旅行:看 3 个版本前的数据
SELECT * FROM my_lake.sales AT (VERSION => 3) LIMIT 100;

DuckLake 的意义在于:你不再需要一套重量级湖仓(Iceberg + 独立 catalog 服务 + 查询引擎)。DuckDB + 一个现成的关系库当目录,就凑齐了「小团队版湖仓」。这正是「进程内分析」哲学的延伸。

4.6 关系 API:用 Python 拼 SQL,而不是拼字符串

DuckDB 的 Relation 对象支持链式组合,避免脆弱的字符串拼接:

import duckdb

rel = (
    duckdb.sql("SELECT * FROM read_parquet('s3://lake/events/*.parquet')")
    .filter("event_type = 'purchase'")
    .aggregate("user_id, SUM(amount) AS total", "user_id")
    .order("total DESC")
    .limit(10)
)
print(rel.execute().fetchdf())

这在做多步 ETL 时特别香:每步都是一个可复用、可解释的 Relation,最后一次性物化。

五、性能优化:把 DuckDB 榨干的清单

下面这些不是玄学,是 1.5.x 上真正影响数量级的开关。

5.1 写入:永远批量,Appender 优先

见 4.3。逐行 INSERT 是最常见性能杀手。结论:能用 INSERT INTO ... SELECT 就别循环 INSERT。

5.2 只选要的列,别 SELECT *

列式引擎的优势建立在「只读用到的列」。SELECT * 会把所有列都读进来,I/O 和内存双双浪费。Wide table(几百列)尤其明显。

5.3 让谓词下推真正生效

DuckDB 会把 WHERE 下推到 Scan:

-- ✅ 分区/Zone Map 能跳过整文件整块
SELECT SUM(amount) FROM t WHERE dt = DATE '2026-08-19' AND region = 'cn';

-- ❌ 对分区列套函数,下推失效,全扫
SELECT SUM(amount) FROM t WHERE CAST(dt AS VARCHAR) = '2026-08-19';

别在分区/索引列上包函数,否则优化器没法跳过数据。

5.4 控制线程与内存,别让单机崩

import duckdb
con = duckdb.connect()
# 限制并行线程(默认=CPU 核数,容器里可能多吃)
con.execute("SET threads TO 4;")
# 限制内存;超了会 spill 到磁盘而不是 OOM
con.execute("SET memory_limit = '4GB';")
# 临时/spill 目录
con.execute("SET temp_directory = '/data/duckdb_tmp';")

这在容器 / 共享机器上至关重要。默认 threads = 核数,在小容器里可能抢满 CPU;memory_limit 不设,扫超大文件可能把宿主机内存吃光然后 OOM Kill。

5.5 大结果集:流式 fetch,而不是一次性 fetchall

# 百万行结果,别 fetchall 进内存
rel = duckdb.sql("SELECT * FROM huge_table")
for batch in rel.fetch_record_batch(chunk_size=100_000):  # Arrow 批次
    process(batch)

fetch_record_batch 返回 Arrow 流式批次,处理完就释放,内存恒定。

5.6 导出为 Parquet:ETL 的「黄金中间格式」

DuckDB 读写 Parquet 极快,常作为管道节点:

-- 清洗后的结果直接落 Parquet(列式压缩,体积小、可再被查)
COPY (
    SELECT user_id, SUM(amount) AS total
    FROM read_csv_auto('raw/*.csv')
    GROUP BY user_id
) TO 'clean/users.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

下游无论是 Spark、Athena 还是另一个 DuckDB,都能直接消费这个 Parquet。

5.7 预编译语句:循环查询请 prepare

con = duckdb.connect()
stmt = con.execute("PREPARE SELECT * FROM t WHERE id = $1")
for uid in ids:
    print(con.execute(stmt, [uid]).fetchone())

重复执行同构查询时,prepare 省掉反复解析 / 优化的开销。

六、总结与展望:进程内分析,正在成为默认选项

DuckDB 不是「又一个数据库」,它填补的是**「不想起服务器、但想要 SQL 级分析能力」**这片巨大空白。它的成功来自一个克制而聪明的定位:

  1. 站着 SQLite 的「无服务器」肩膀,但走 OLAP 的列式 + 向量化路线;
  2. 和 Arrow 生态零拷贝互通,让它天然是 Pandas / Polars / 数据管道的「加速器」而不是替代品;
  3. 异步 I/O(Work, Thread, Work) 把远程扫描的瓶颈从「等网络」变成「算和网络重叠」;
  4. DuckLake 把「进程内分析」延伸成「小团队也能玩得起的湖仓」。

什么时候该用 DuckDB?

  • ✅ 本地 / 笔记本上的数据分析、探索、特征工程;
  • ✅ 数据管道里「读一堆文件 → 清洗 → 聚合 → 落 Parquet」的 ETL 节点;
  • ✅ 直接在 Python 里对几 GB~ 上百 GB 数据跑 SQL,不想起服务;
  • ✅ 查询对象存储上的 Parquet(湖仓的轻量查询面);
  • ✅ 作为应用进程内的嵌入式分析引擎(比如桌面 BI、本地报表)。

什么时候不该用?

  • ❌ 高并发、多写者的 Web 应用主库(这是 PostgreSQL/MySQL 的地盘);
  • ❌ 需要跨节点横向扩展的 PB 级分布式数仓(那是 ClickHouse / Trino / Spark 的活);
  • ❌ 强一致多点写的业务系统。

一句话收尾:DuckDB 把「分析」从「一项需要申请资源的基础设施」降级成了「一行 import」。 当 1.5.x 把异步 I/O 打磨好、DuckLake 把湖仓门槛砍到最低,我们看到的不只是单个工具的进化,而是一个趋势——分析能力正在下沉进每一个进程,成为默认而非例外。 对写代码的人来说,这意味着:下次你想 pip install pandasdf.groupby 的时候,不妨先 import duckdb,写一句 SQL,然后看着它用你没想到的速度把活干完。


生产级调优速查(1.5.5)

-- 容器/共享机上请务必设置
SET threads = 4;
SET memory_limit = '4GB';
SET temp_directory = '/data/duckdb_tmp';
-- 常用扩展
INSTALL httpfs; LOAD httpfs;      -- 读 S3/HTTP
INSTALL parquet; LOAD parquet;    -- 读 Parquet(多数发行版已内置)
INSTALL json; LOAD json;          -- 读 JSON
INSTALL ducklake; LOAD ducklake;  -- 湖仓格式
-- 写数据:批量优先
INSERT INTO t SELECT * FROM read_parquet('s3://.../*.parquet');
-- 查数据:只选列、别包函数、按需流式
COPY (SELECT col_a, SUM(col_b) FROM t WHERE dt='2026-08-19' GROUP BY col_a)
  TO 'out.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);

推荐文章

Vue3中怎样处理组件引用?
2024-11-18 23:17:15 +0800 CST
GROMACS:一个美轮美奂的C++库
2024-11-18 19:43:29 +0800 CST
程序员茄子在线接单