编程 DuckDB 深度实战:当分析引擎塞进进程里——从向量化执行到湖仓一体的技术革命(2026)

2026-07-14 03:13:19 +0800 CST views 12

DuckDB 深度实战:当分析引擎塞进进程里——从向量化执行到湖仓一体(2026 深度长文)

2026 年 6 月,DuckDB 同时发布了 1.5.4(Variegata)1.4.5 LTS(Andium) 两个版本,并正式把「client-server(Quack)」能力推到前台。一个当年被戏称为「分析界的 SQLite」的小项目,如今已经长成了能直接读 Parquet、查 Iceberg、连 Postgres、做向量检索、还能当湖仓查询引擎的「进程内数据操作系统」。本文不堆术语,从一次真实的数据分析痛点出发,把 DuckDB 的执行引擎、扩展体系和性能开关一次讲透,并配齐可运行代码。

一、背景:我们为什么一直在「搬运数据」?

任何一个做过数据分析的工程师都有过这种经历:

  1. 数据在 S3 / HDFS / 某个 MySQL 从库里;
  2. pandas.read_csvread_parquet 把它拉到本地内存;
  3. 做了一堆 groupbymerge
  4. 发现内存爆了,于是加 chunksize、改 dtype、换 polars
  5. 最后把结果再写回数据库或另一份文件。

整套流程里,真正产生价值的是第 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 它和谁不是对手

维度SQLitePandasDuckDBClickHouse
定位进程内 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)一次性吃进去一整批、吐出一整批。

这带来三个收益:

  1. 循环内无解释开销:一批 2048 行在一个紧凑循环里算完,CPU 分支预测和指令缓存友好;
  2. SIMD 友好:列存 + 定长类型天然适合向量指令;
  3. 缓存友好:同一列的数据连续存放,扫描时几乎没有指针跳转。

伪代码对比:

// 行式(慢):每行一次函数调用 + 随机内存访问
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_idamountorder_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 需要的列:列存下,少读一列就少一次磁盘扫描;
  • 分区字段进 WHEREorder_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_parquetgroupby 之前,先试试把那条 SQL 直接丢给 DuckDB。 你会发现,大部分「数据工程」的苦活,其实只是一个没被用对的查询下推。


参考版本:DuckDB 1.5.4(Variegata)/ 1.4.5 LTS(Andium),2026-06 发布。扩展 API(fts / vss / iceberg / ducklake)以你所使用版本的官方文档为准。

推荐文章

git使用笔记
2024-11-18 18:17:44 +0800 CST
如何优化网页的 SEO 架构
2024-11-18 14:32:08 +0800 CST
Vue3中的虚拟滚动有哪些改进?
2024-11-18 23:58:18 +0800 CST
在Vue3中实现代码分割和懒加载
2024-11-17 06:18:00 +0800 CST
Vue3中的Scoped Slots有什么改变?
2024-11-17 13:50:01 +0800 CST
实用MySQL函数
2024-11-19 03:00:12 +0800 CST
Linux 网站访问日志分析脚本
2024-11-18 19:58:45 +0800 CST
网络数据抓取神器 Pipet
2024-11-19 05:43:20 +0800 CST
纯CSS绘制iPhoneX的外观
2024-11-19 06:39:43 +0800 CST
Go 开发中的热加载指南
2024-11-18 23:01:27 +0800 CST
程序员茄子在线接单