编程 DuckDB 深度拆解:进程内向量化列式引擎如何把 TB 级分析塞进单机——从 Parquet 直查到 Pandas 替身的工程全链路实战

2026-08-18 04:10:53 +0800 CST views 12

DuckDB 深度拆解:进程内向量化列式引擎如何把 TB 级分析塞进单机——从 Parquet 直查到 Pandas 替身的工程全链路实战

一、背景介绍:为什么我们需要一个"分析版的 SQLite"

如果你写过数据处理的脚本,一定经历过这样的循环:先 pip install pandas,然后 pd.read_csv("几个G的大文件.csv"),紧接着内存爆了,笔记本风扇开始狂转,最后你不得不把数据切成分片、写一堆 chunksize 的丑陋代码,或者干脆把活儿丢给一台你并不想维护的 Spark 集群。

问题出在哪?不是 pandas 不好,而是它生来就不是为分析型(OLAP)负载设计的。pandas 的核心是 DataFrame——一个以行为主、按列存指针、对象装箱(object dtype)的二维表。它擅长"把数据拿在手里揉来揉去",但一旦数据量级过内存、或查询变成"对 20 个亿行做 GROUP BY",它的行式解释执行模型就原形毕露。

另一头是 ClickHouse、Doris 这类重型 OLAP 数据库。它们性能炸裂,但代价是:你要起一个服务、管一套集群、配 ZooKeeper/元数据、做权限和运维。对"我就在本地想 quickly 看一眼这份 Parquet 的统计"这种需求,杀鸡用牛刀。

这就留下了一块明显的空白:有没有一个像 SQLite 那样"零部署、单文件、塞进进程里就能跑",但内核是按 OLAP 重新设计的数据库?

DuckDB 的回答是:有。它的口号是 "The Analytic Database for Everyone / SQLite for Analytics"。它把两件事做到了极致:

  1. 进程内(in-process):没有客户端/服务器协议,没有独立进程,库直接链接进你的 Python/R/Node/Java/WASM 进程,查询走函数调用。
  2. 为分析而生的执行引擎:列式存储 + 向量化执行(vectorized execution),专门吃"大扫描 + 聚合 + 连接"这种查询模式。

本文不堆术语,而是从工程视角把 DuckDB 的执行模型、存储布局、Parquet 直查、与 pandas 的集成、性能调优,以及它在现代数据栈里的真实位置,一条线拆到底,并配可运行代码。读完你应该能判断:我手上的活儿,到底该用 pandas、DuckDB 还是直接上 ClickHouse?


二、核心概念:先分清三对容易混的概念

动手之前,先把几个经常被混淆的概念钉死,后面所有讨论才有锚点。

2.1 进程内(in-process)≠ 内存数据库

很多人的第一反应是:"塞进程里?那不就是 Redis / SQLite 内存模式?"错。

  • Redis 是内存数据库,数据主存,重启可能丢(除非持久化)。
  • SQLite 是进程内数据库,但它默认是行式存储,且执行是逐行解释,分析查询很慢。
  • DuckDB 是进程内数据库,但它是列式存储 + 向量化执行。它既能把数据放在内存里跑,也能直接"穿透"到磁盘上的 Parquet/CSV 文件上做延迟物化(late materialization)的扫描——即查询 100GB 的 Parquet 时,它不会把 100GB 读进内存,而是只把查询用到的列读上来、按需解压、向量化处理。

一句话:进程内描述的是"部署形态"(无服务),列式+向量化描述的是"执行形态"(为分析优化)。 DuckDB 两者都占。

2.2 列式存储:为什么按列放比按行放快

假设一张表有 20 个字段,你的查询只关心 amountevent_time 两列做 SUM:

  • 行式(MySQL/SQLite/pandas 默认):磁盘上一行挨着一行,要算 SUM(amount) 就得把整行(含你不要的 18 个字段)都读进来做投影,IO 浪费严重。
  • 列式(DuckDB/Parquet/ClickHouse):amount 这一列在物理上连续存放。读取时只扫 amount 这一列的数据块,IO 量是行式的 1/10。而且同列数据类型一致,能用紧凑的定长编码 + 字典编码 + 位压缩,进一步縮小数据体积。

这就是"列裁剪(column pruning)"能省下大量 IO 的根本原因。DuckDB 读取 Parquet 时,会把 SELECT 里的列下推(pushdown)到文件读取层,只解压需要的列。

2.3 向量化执行:别一行一行算,一次算一批

普通解释器是"取一行 → 算一行 → 取下一行"。CPU 每次都在做分支预测、函数调用、解释开销,而且根本用不上现代 CPU 的 SIMD(单指令多数据)指令。

DuckDB 的做法是:把数据按列切成固定大小的 向量(Vector),默认每向量 2048 行(常量 STANDARD_VECTOR_SIZE = 2048)。一个算子(operator)不是处理单条记录,而是一次吃进一个向量、吐出一个向量。在这个粒度上:

  • 可以利用 SIMD 批量做比较、累加、哈希;
  • 减少虚函数调用和解释分支;
  • 数据以列式 batch 在算子间流动,缓存命中率高。

这跟 pandas 的"底层用 C 写的向量化"(如 df['amount'].sum() 一次算一列)思路一致,但 DuckDB 更进一步:它把整个查询计划都建在向量批处理之上,而不只是单个算子。所以即使你写的是多表 JOIN + 窗口函数 + 子查询,它也是按向量流水线跑完,而不是 pandas 那种"先物化中间 DataFrame,再物化下一个"的反复分配。


三、架构分析:一条 SQL 在 DuckDB 里到底走了什么

理解架构,最好跟着一条 SQL 的生命周期走。以这条查询为例:

SELECT event_date, SUM(amount) AS revenue
FROM read_parquet('s3://bucket/logs/*.parquet')
WHERE country = 'CN' AND event_date >= DATE '2026-08-01'
GROUP BY event_date
ORDER BY event_date;

3.1 解析 → 绑定 → 逻辑计划 → 优化 → 物理计划

  1. Parser:把 SQL 文本解析成抽象语法树(AST)。DuckDB 内置了一个手写的高性能解析器。

  2. Binder:做语义绑定,把表名、列名、函数解析成具体的 catalog 引用,并做类型推导。比如 SUM(amount) 的返回类型是 BIGINT 还是 DOUBLE,在这里定。

  3. Logical Planner:生成逻辑计划(一颗关系代数树):ORDER BY → GROUP BY → FILTER → SCAN

  4. Optimizer:这是精髓所在。DuckDB 跑一系列基于规则的优化(rule-based)和代价无关的启发式重写,包括但不限于:

    • 谓词下推(Predicate Pushdown):把 WHERE country='CN' 推到 Scan 节点内部,让文件读取层在扫描 Parquet 时直接跳过不匹配的行组(row group)。
    • 列裁剪(Projection Pushdown):只读取 event_dateamountcountry 三列,其他列根本不解压。
    • 常量折叠、公共子表达式消除、连接重排序、子查询去关联(flatten subqueries)
    • 统计信息利用:Parquet 文件自带每列每 row group 的 min/max/null_count 等统计(footer metadata)。DuckDB 用这些信息做 zone map / min-max 剪枝:如果某个 row group 的 event_date 最大值都小于 2026-08-01,整个 row group 直接跳过,磁盘都不用读。

    你可以用 EXPLAIN 看到优化后的计划:

    import duckdb
    plan = duckdb.sql("""
        EXPLAIN SELECT event_date, SUM(amount)
        FROM read_parquet('s3://bucket/logs/*.parquet')
        WHERE country='CN' GROUP BY event_date
    """).fetchall()
    for row in plan:
        print(row[1])
    
  5. Physical Planner:把逻辑计划翻译成物理算子(Pipeline),并决定并行度。

3.2 Pipeline 与向量化执行

DuckDB 把物理计划拆成若干 Pipeline(执行流水线)。每个 Pipeline 是"一串向量化算子"的串联,数据以 2048 行的向量块在算子间流动。

关键设计:Pull 模型的 Push 执行。上层算子(如 Sink/聚合)向上层请求(Pull)一个向量;请求逐层下推,最终 Scan 节点从 Parquet 读出一批向量;这一批向量沿 Pipeline 向上流过 Filter、Projection、聚合 Hash 表,直到被 Sink 消费。整个过程是同步的、无锁单线程内完成一个 Pipeline 的批次,但多个 Pipeline 可以在不同线程上并行(SMP 并行)。

3.3 并行模型:SMP 而非分布式

DuckDB 的并行是单机多核(SMP)。它把一个大扫描按文件/row group 切成多个任务,丢进线程池,多个线程各自跑自己的 Pipeline 片段,最后在聚合/排序节点做合并。

# 控制并行度
con = duckdb.connect(config={'threads': 4})      # 限定用 4 个线程
# 或运行时改
duckdb.sql("PRAGMA threads=8;")
print(duckdb.sql("PRAGMA threads;").fetchone())

注意:DuckDB 不负责跨机器分布式。TB 级数据它能吃,前提是单机有那么多磁盘(或对象存储)和内存/吞吐。真要跨机,那是 MotherDuck(DuckDB 的云/混合形态)或你自己编排的事。对绝大多数"几百 GB 以内、单机 NVMe"的分析场景,DuckDB 的单机并行已经绰绰有余。

3.4 扩展系统:核心很小,能力靠插件

DuckDB 本体精简(核心编译出来只有几 MB),大量能力做成扩展(extension),按需 INSTALL + LOAD

  • parquet / json:文件格式读写(parquet 是内置的,json 在新版也是)。
  • httpfs:通过 HTTPS 直读 S3 / GCS / 任意 HTTP 上的 Parquet。
  • postgres / mysql:把远端数据库 ATTACH 进 DuckDB,做联合查询。
  • fts:全文检索。
  • spatial:地理空间。
  • sqlite / excel:互操作。

扩展机制让"核心稳定 + 生态可插拔",这也是它能保持单文件、嵌入式却又不失能力的原因。


四、代码实战:从安装到搭一个本地 Lakehouse

下面全是可运行代码(Python 3.9+,DuckDB 1.x)。

4.1 安装与最小可运行样例

pip install duckdb
import duckdb

# 直接对字符串里的 SQL 做查询,零配置
rel = duckdb.sql("""
    SELECT
        range AS n,
        n % 2 AS parity
    FROM range(1, 10000001)   -- 生成 1000 万行
""")
print(rel)  # 打印一个 Relation 的预览

# 聚合:向量化一次性算完,不会真的把 1000 万行物化成 Python 对象
result = duckdb.sql("""
    SELECT parity, COUNT(*) AS cnt, SUM(n) AS total
    FROM (SELECT range AS n, n % 2 AS parity FROM range(1, 10000001))
    GROUP BY parity
""").fetchall()
print(result)  # [(0, 5000000, 25000002500000), (1, 5000000, 25000005000000)]

注意 range(1,10000001) 是 DuckDB 的表函数,生成序列不占内存。这种"生成列 + 立刻聚合"的写法,是它和 pandas 最大的区别:pandas 得真造一个 1000 万行的 Series 才算,DuckDB 在向量引擎里流式算完。

4.2 直查文件:CSV / Parquet / JSON 零导入

DuckDB 最爽的点:不用 ETL 导入,直接查文件

# CSV:自动推断 schema
duckdb.sql("""
    CREATE TABLE orders AS
    SELECT * FROM read_csv_auto('orders.csv', header=True, sample_size=100000)
""")

# Parquet:列式直查,列裁剪 + 谓词下推自动发生
df = duckdb.sql("""
    SELECT user_id, SUM(amount) AS total
    FROM 'events.parquet'          -- 直接传文件名即可
    WHERE event_type = 'purchase'
    GROUP BY user_id
    ORDER BY total DESC
    LIMIT 100
""").df()

# 多个文件用通配符
big = duckdb.sql("""
    SELECT * FROM 'logs/*.parquet'
    WHERE ts >= TIMESTAMP '2026-08-01'
""")

# JSON:自动展开嵌套
duckdb.sql("""
    SELECT payload.user.id AS uid, payload.action
    FROM read_json_auto('clicks.jsonl', format='newline_delimited')
""")

关键认知:当你写 SELECT user_id, SUM(amount) FROM 'events.parquet',DuckDB 不会先把整个 Parquet 读进内存。它读 Parquet 的 footer 拿到列统计和 row group 布局,只把 user_idamount 两列方程块读上来,配合 min-max 剪枝跳过无关 row group,然后向量化聚合。一份 50GB 的 Parquet,一个只取两列的聚合查询,可能只读了几百 MB 的磁盘。

4.3 接住 pandas:让它当你的"查询加速器"

很多团队代码已经是一堆 pandas。别重写,让 DuckDB 接管重查询:

import pandas as pd
import duckdb

df = pd.read_parquet('events.parquet')   # 假设你已经有了 DataFrame

# 把 DataFrame 注册成一张临时表,用 SQL 跑聚合
duckdb.register('events', df)
out = duckdb.sql("""
    SELECT event_date, COUNT(*) FILTER (WHERE amount > 0) AS paid,
           AVG(amount) AS avg_amount
    FROM events
    GROUP BY event_date
""").df()

# 反向:DuckDB 结果直接给回 pandas / polars / arrow
rel = duckdb.sql("SELECT * FROM events LIMIT 1000")
pdf  = rel.df()      # pandas
polf = rel.pl()      # polars(零拷贝,底层 Arrow)
arl  = rel.arrow()   # pyarrow Table

实践里一个高频收益:用 DuckDB 做 GROUP BY / JOIN,把结果(小表)交回 pandas 做绘图/特征工程。pandas 的 df.groupby 在大表上又慢又吃内存,DuckDB 的列式聚合几乎秒回。

4.4 跨源联合:把 Postgres 和 S3 拉到一张 SQL 里

import duckdb

con = duckdb.connect()

# 装扩展(首次会下载)
con.sql("INSTALL postgres; LOAD postgres;")
con.sql("INSTALL httpfs;   LOAD httpfs;")

# 把生产库 ATTACH 进来
con.sql("""
    ATTACH 'dbname=prod user=reader password=xxx host=10.0.0.5' AS pg (TYPE postgres, READ_ONLY)
""")

# 把 S3 上的 Parquet 和 Postgres 的维度表 JOIN
con.sql("""
    CREATE TABLE revenue_cn AS
    SELECT u.user_name, SUM(f.amount) AS revenue
    FROM read_parquet('s3://warehouse/facts/*.parquet',
                      aws_region='us-east-1') AS f
    JOIN pg.users u ON u.id = f.user_id
    WHERE f.country = 'CN'
    GROUP BY u.user_name
    ORDER BY revenue DESC
""")
print(con.sql("SELECT * FROM revenue_cn LIMIT 10").df())

这让 DuckDB 变成了一个进程内的联邦查询层:本地 Parquet、对象存储、远端关系库,一张 SQL 全部 JOIN。中间结果不落盘、不启服务。

4.5 流式结果:别一次 .df() 把内存撑爆

当你查询的结果集本身就很大(比如要导出 100GB 聚合明细),不要 rel.df() 一次性物化。用分块拉取

rel = duckdb.sql("SELECT * FROM read_parquet('big/*.parquet')")
# 每次 fetch 一个向量块(默认 2048 行),内存恒定
while True:
    chunk = rel.fetch_df_chunk(vector_size=2048)  # 返回 pandas,流式
    if chunk.empty:
        break
    process(chunk)   # 处理完即丢,内存不累积

# 或者获取 Arrow 并转 Parquet,边算边落盘
rel = duckdb.sql("""
    COPY (SELECT * FROM read_parquet('big/*.parquet') WHERE amount > 0)
    TO 'filtered' (FORMAT PARQUET, PARTITION_BY (country))
""")

COPY ... TO ... (FORMAT PARQUET, PARTITION_BY) 还能直接做分区落盘——把输出按某列切成子目录(如 country=CN/country=US/),这是构建本地 Lakehouse 的标准动作。

4.6 一个真实小工具:CLI 日志分析仪

把上面组合起来,写一个 30 行的本地日志分析 CLI:

#!/usr/bin/env python3
# analyze.py —— 进程内分析任意 Parquet 日志,无需服务端
import sys, duckdb

path = sys.argv[1] if len(sys.argv) > 1 else 'logs/*.parquet'

sql = f"""
    SUMMARIZE SELECT * FROM '{path}'
"""
# SUMMARIZE 是 DuckDB 的彩蛋:一行命令给每张列的统计(min/max/avg/分位数/null比例)
print(duckdb.sql(sql).df())

# 再加一个 Top 用户
top = duckdb.sql(f"""
    SELECT user_id, COUNT(*) AS hits, SUM(bytes) AS traffic
    FROM '{path}'
    GROUP BY user_id ORDER BY traffic DESC LIMIT 20
""").df()
print(top)

SUMMARIZE 这个内置关系函数特别适合"拿到一份陌生数据先摸底数"的场景,自动输出每列的近似分位数、唯一值数、空值率。


五、性能优化:什么时候快,怎么让它更快

DuckDB 不是银弹。用对了飞起,用错了比 pandas 还别扭。下面是可落地的优化清单。

5.1 第一性原则:数据尽量以 Parquet 形态存在

如果你手里是 CSV,第一步就是转 Parquet:

duckdb.sql("""
    COPY (SELECT * FROM read_csv_auto('huge.csv'))
    TO 'huge.parquet' (FORMAT PARQUET, CODEC 'ZSTD', ROW_GROUP_SIZE 100000)
""")

为什么?Parquet 是列式 + 压缩 + 带统计的。DuckDB 读它时能列裁剪、能 min-max 剪枝、能跳 row group。CSV 是行式纯文本,几乎享受不到这些优化,解析也慢。Parquet 通常比 CSV 小 5-10 倍,查询快数倍到数十倍。这单条建议能解决 80% 的"DuckDB 怎么这么慢"。

5.2 分区 + 排序:让剪枝真正生效

Parquet 的 min-max 剪枝只在"同列的值在 row group 内相对连续"时才有效。如果 event_date 是随机打乱写入的,每个 row group 的 min/max 范围都覆盖全量,剪枝失效。

解法:按常用过滤列做分区 + 排序后再写

duckdb.sql("""
    COPY (SELECT * FROM staging ORDER BY event_date)
    TO 'warehouse' (FORMAT PARQUET, PARTITION_BY (event_date))
""")

这样 WHERE event_date = '2026-08-01' 在数据布局层面就能直接定位到对应分区目录,磁盘读取量降到 1/N。这是 Lakehouse 分区表的核心思想,DuckDB 原生支持。

5.3 控制资源:threads / memory_limit / 顺序

con = duckdb.connect(config={
    'threads': 8,                 # 别无脑拉满,留点给系统
    'memory_limit': '4GB',        # 限定内存,超出走落盘 spill
    'preserve_insertion_order': False,  # 不保证顺序时可加速并行聚合
})

preserve_insertion_order=False:当你的查询不需要保持输入顺序(多数聚合/去重不需要),关掉它让引擎在并行聚合时少做一道合并排序,能明显提速。

5.4 避免把大结果拉回 Python

前面提过:rel.df() 会把结果完整物化成 pandas,大结果直接 OOM。三种替代:

  1. 直接在 DuckDB 内完成计算,只取聚合后的小结果——这是最优形态。
  2. fetch_df_chunk() 流式——必须拿明细时。
  3. COPY ... TO 落盘 Parquet/CSV——导出时。

另外,和 pandas 互转时,优先用 .pl() / .arrow()(Arrow 零拷贝),而不是 .df()(要逐行转 Python 对象,慢且占内存)。

5.5 用 Appender 做高频小批写入

如果你要往 DuckDB 里持续灌数据(如实时落日志),别一条条 INSERT,用 Appender:

con = duckdb.connect('metrics.duckdb')
appender = con.append('metrics')   # 指定表
for row in stream():
    appender.append(row)            # 批量缓冲,减少事务开销
appender.close()

Appender 绕过单条 SQL 解析,走内部批量追加路径,写吞吐高一个数量级。

5.6 一个对照基准(量级感知,非严谨 benchmark)

在单机 8 核、数据 20GB Parquet(10 列、2 亿行)上做 SELECT country, SUM(amount) GROUP BY country

  • pandasdf.groupby('country')['amount'].sum() 大概率因内存峰值是原数据数倍而 OOM,或靠 chunksize 切分写出一堆样板代码,耗时数十秒到分钟级。
  • DuckDB:列式扫描 + 向量化聚合,只读了 country+amount 两列,常驻内存只是聚合哈希表,几秒内返回。
  • SQLite:行式 + 解释执行,同样查询可能是 DuckDB 的几倍到几十倍慢,且 CREATE TABLE 导入本身就卡。

结论很朴素:数据量过内存、或查询以聚合/扫描为主 → DuckDB;数据在内存内、要反复按行改值做特征工程 → pandas;需要跨机、高并发服务端、实时写入 → ClickHouse/Doris。


六、总结与展望:DuckDB 在现代数据栈里的位置

把视野拉高,DuckDB 不是来"取代谁"的,而是填了一个长期被忽视的格:

  • 它替代不了 ClickHouse:后者是分布式、服务端、为高并发在线分析设计。DuckDB 是单机、进程内、为"我在笔记本/单机/CI 里想快速分析"设计。
  • 它也不是 pandas 的敌人,而是放大器:用 DuckDB 扛重查询,pandas/Polars 做轻量后处理,是当下数据科学工作流里很顺的组合。
  • 它让"Lakehouse 平民化":S3 上堆一堆 Parquet,本地起个 DuckDB 就能当查询引擎,连 catalog 都不用建。配合 PARTITION_BY 落盘,你就是在手写一个迷你数据湖。

几个值得关注的方向:

  1. MotherDuck:DuckDB 背后的商业公司做的混合云形态,本地 DuckDB 和云端共享一份数据,解决"单机磁盘不够但想用同一套 SQL"的痛点。
  2. WASM 形态:DuckDB 能编译成 WASM 跑在浏览器里,前端直接查询本地 Parquet,催生了一批"浏览器内数据分析"的新玩法。
  3. 与 AI/湖仓的结合:Agent 工作流里,DuckDB 常作为"进程内数据准备层"——把杂乱文件先 SQL 清洗成结构化表,再喂给模型或下游。它轻、快、零依赖,非常契合"用完即走"的临时数据任务。

回到开头那个被风扇吵醒的夜晚:当你下次面对一份"几个 G 的 CSV / 一堆 Parquet",先别急着 pd.read_csvimport duckdb,然后 SELECT ... FROM 'file.parquet'。你会发现,原来单机就能把 TB 级分析跑得这么安静、这么快。

DuckDB 给程序员的启示其实很朴素:很多"必须上集群"的问题,只是因为你的工具底层还是行式解释执行。换一个为分析重新设计的引擎,瓶颈可能根本不存在。 这跟我们做系统优化时常说的那句话一脉相承——先怀疑数据结构和执行模型,再怀疑机器不够。


附:本文可运行环境

  • Python ≥ 3.9
  • pip install duckdb(1.x)
  • 数据:*.parquet / *.csv / *.json(可本地文件,也可 S3 路径配合 httpfs
  • 核心 API:duckdb.sql()duckdb.connect(config=...)relation.df()/.pl()/.arrow()relation.fetch_df_chunk()INSTALL/LOAD 扩展、COPY ... TO ... (FORMAT PARQUET, PARTITION_BY)SUMMARIZEEXPLAIN

推荐文章

PHP 8.4 中的新数组函数
2024-11-19 08:33:52 +0800 CST
Elasticsearch 聚合和分析
2024-11-19 06:44:08 +0800 CST
mysql 计算附近的人
2024-11-18 13:51:11 +0800 CST
windows安装sphinx3.0.3(中文检索)
2024-11-17 05:23:31 +0800 CST
html一些比较人使用的技巧和代码
2024-11-17 05:05:01 +0800 CST
Python Invoke:强大的自动化任务库
2024-11-18 14:05:40 +0800 CST
程序员茄子在线接单