教程·阅读约 3 分钟·
DuckDB 为什么这么快?深入解析其内部架构(上篇)

DuckDB 为什么这么快?深入解析其内部架构(上篇)

从查询解析到存储层,一文看懂 DuckDB 的六大性能设计选择:进程内执行、列式存储、向量化执行、Morsel 驱动并行等

原文来源:Greybeam Blog — 从查询进入引擎到结果返回,逐阶段拆解 DuckDB 的性能秘密

DuckDB 自 2019 年从荷兰 CWI 研究所的研究项目起步,十年间已成为过去十年中应用最广泛的数据库之一。它出现在各种场景中:数据笔记本、ETL 管道、仪表盘、CI 测试运行器、SaaS 产品的嵌入式分析,甚至有人在装着干冰的 iPhone 上运行 TPC-H 基准测试。

围绕 DuckDB 已经形成了一个完整的生态。MotherDuck 将它包装成云数据仓库。Hex、Omni、Evidence 等 BI 平台用它作为应用内执行引擎和缓存。Fivetran 的托管数据湖服务在内部用它做数据合并和压缩。Rill 基于它构建了开源 BI 工具。Greybeam 自己也用它来处理数百万次 BI 和分析查询。

这篇文章是 DuckDB 内部原理三部曲的第一部。我们会追踪一条 SQL 查询从进入引擎到返回结果的完整路径,在每个阶段停下来看看那些让 DuckDB 变快的设计选择。

DuckDB 是什么

DuckDB 是一个进程内分析型 SQL 数据库。这里的"分析型"意味着它优化的不是主键查询,而是扫描数百万行进行过滤、聚合和连接的分析查询。"进程内"意味着它没有服务器——你不是"连接"到 DuckDB,而是在程序里把它当库加载,就像导入 NumPy 或 Polars 一样。

它的易用性是它成功的关键。单个二进制文件不到 20 MB,没有外部依赖。一行 pip install duckdbbrew install duckdb 就能装好。任何目录下的 Parquet、CSV 或 JSON 文件,它都能像 SQL 数据库一样直接打开查询。

而且它是目前最快的单节点分析引擎之一,经常能和每年花费数百万美元的大型集群一较高下。

DuckDB 的速度来自六个核心设计选择:

  1. 进程内执行 — 省去序列化和网络传输开销
  2. 列式压缩存储 + Zone Map — 只读取需要的列,跳过无关数据块
  3. 向量化执行 — 一次处理一批列数据,充分利用 CPU 缓存和 SIMD 指令
  4. Morsel 驱动并行 — 动态分片任务,自适应调度到所有 CPU 核心
  5. 快照隔离 + 乐观 MVCC — 读写不互斥,事务无锁
  6. 还有很多 — Adaptive Expression Compilation、异步 IO 等

这篇文章覆盖前两个阶段:查询进入引擎后的准备工作,以及存储层的布局。

—— 广告 ——

进程内执行:为什么省掉网络就是最大的优化

你把 DuckDB 指向一个 6 GB 的 Parquet 文件,结果一秒内就返回了。没有集群、没有配置、没有迁移、不需要 CREATE TABLE。这是怎么做到的?

大多数分析数据库都是服务器。Snowflake、Postgres、BigQuery、Redshift——你建立一个连接,通过 TCP 发送 SQL,等结果返回。过程中每一条记录都要序列化为线协议格式,通过网络传输,然后在另一端反序列化。

数据库中的查询结果以类型化值的形式存在于特定内存地址。一个 64 位整数在这里,一个指向字符串的指针在那里。这些地址只在该进程中有效。要把结果发给另一台机器上的客户端,数据库必须把每个值重写成约定的字节格式(Postgres 有自己的协议,MySQL 有另一套),通过 TCP 套接字推出去,客户端再解析回来。每条数据被多次触碰——编码一次,解码一次。在大结果集上,这个过程的耗时往往超过查询本身。

2017 年,Mark Raasveldt 和 Hannes Mühleisen(DuckDB 的作者们)发表了论文《Don't Hold My Data Hostage》。他们测量了从数据仓库拉取结果集时的实际开销,发现客户端协议本身——ODBC、JDBC 以及其他逐行值 API——常常是整个查询中最慢的一步,有时比数据库计算答案的时间还要长。

DuckDB 不是服务器。它是一个库。没有 daemon、没有端口、没有集群。你把 libduckdb 加载到程序里,直接调用函数。查询结果就在当前进程的内存里,函数直接返回指针和长度。零序列化、零网络、零拷贝。如果另一个进程需要结果,通过 Arrow 零拷贝共享内存即可。

这就是为什么一个 5 美元 VPS 上的 DuckDB 在处理 100 GB 的本地数据时,可能比 5000 美元集群上的 Snowflake 还快——不是因为 DuckDB 运行查询速度更快,而是因为 Snowflake 要把所有数据通过网络拉回来。

从 SQL 到逻辑计划:解析与重写

当你把 SQL 字符串送入 DuckDB,第一个处理它的组件是解析器。DuckDB 使用标准的 PostgreSQL 解析器(经过修改),将 SQL 文本解析成抽象语法树。

但 DuckDB 并不是直接基于 AST 做优化的。它有一个关键的设计决策:AST 会被立即转换为关系代数树,DuckDB 称之为逻辑操作符树

这棵树上的每个节点都是一个逻辑操作符:SELECTJOINFILTERAGGREGATEORDER BY 等。DuckDB 对这棵树应用一系列优化规则,每条规则遍历树并做特定变换。

优化器执行了超过 10 条重写规则的流水线:

  • 列剪枝 — 去掉 SELECT 和 JOIN 中不需要的列。如果一张表有 100 列但你只查 3 列,另外 97 列直接从计划中删除。这是最重要的优化之一,尤其对列式存储而言。
  • 过滤器下推 — 把 WHERE 条件尽量推向数据源。如果过滤条件可以提前应用到扫描阶段,后续操作需要处理的数据量就小得多。
  • 子查询解嵌套 — 将相关子查询重写为 JOIN 或聚合。DuckDB 是唯一能完整解嵌套所有子查询的分析引擎之一。
  • 常量折叠 — 计算编译期就能确定值的表达式,比如 WHERE date > '2024-01-01' 在解析后变成具体值。

大多数数据库做这些优化也是类似的,但 DuckDB 的不同之处在于,这些优化之后的处理——物理计划的生成和执行方式——才是真正的性能关键。

物理计划:把逻辑变成可执行的操作

逻辑计划告诉数据库"做什么"。物理计划告诉数据库"怎么做"。

逻辑计划中的关系操作符(JOIN、AGGREGATE)在物理计划中被替换为具体的算法实现:

  • JOIN — 是小表用哈希连接,还是一张大表需要排序合并连接,还是数据已经有序可以用归并连接?
  • AGGREGATE — 是简单聚合(COUNT(*))还是分组聚合(GROUP BY)?是否需要哈希聚合还是排序聚合?
  • ORDER BY — 数据量能否放入内存做快速排序,还是需要外部归并排序?

DuckDB 有一个成本模型来决定这些选择。虽然不像 PostgreSQL 或商业数据库的成本模型那样复杂,但它会考虑数据量估计(通过统计信息)、操作符的选择度和可用的并行度。

物理计划中的每个操作符都暴露一个核心接口:GetData()。上层操作符调用下层操作符的 GetData() 来获取数据块。这个接口返回的是向量,不是行。这对理解 DuckDB 的执行模型至关重要。

存储层:列式压缩与 Zone Map

这一部分是 DuckDB 从存储角度获得性能优势的核心。

行组与列段

DuckDB 将表数据划分为行组,每个行组包含约 1200 万行(可配置)。每个行组内,数据按列独立存储为列段。也就是说,如果一个表有 10 列,每个行组就有 10 个列段。

这种布局的关键好处:查询只需要读取涉及到的列的列段,其他列段完全跳过。在一个 100 列的表上做 SELECT AVG(salary),DuckDB 只读取 salary 这一个列段。

列式压缩

每个列段内部存储为一系列数据块,每个数据块通常包含 2048 行的数据。DuckDB 对每个数据块独立应用压缩算法。它支持的压缩方法包括:

  • 常量编码 — 如果数据块内的所有值相同,只存一份值
  • 游程编码 — 对连续重复值的序列编码
  • 字典编码 — 将重复出现较多的值映射为更短的整数编码
  • 增量编码 — 存储相邻值的差值而非原始值
  • Patas 编码 — 针对字符串的快速字典编码
  • Chimp 算法 — 针对浮点数的压缩,基于 Facebook 的 Gorilla TSDB 算法
  • FSST — 快速字符串压缩算法,专门处理短文本

DuckDB 会自动为每个数据块选择最合适的压缩算法组合,用户不需要手动指定。

Zone Map:跳过不必要的数据

每个数据块都有一个Zone Map,存储该块的元数据:最小值、最大值、是否有 NULL、非 NULL 值的数量。当查询包含过滤条件时,DuckDB 可以先检查 Zone Map,跳过完全不符合条件的数据块。

举个例子:假设一个按日期分区的销售表,你查 WHERE date >= '2025-01-01'。如果某个行组的第一列段的 Zone Map 显示该块日期范围全在 2024 年,这个数据块会被直接跳过,连解压都不需要。

Zone Map 信息存储在列段的元数据中,可以在不读取实际数据的情况下检查。对于大规模数据分析,这意味着 DuckDB 经常只需要扫描总数据量的一小部分就能返回结果。

直接读取 Parquet 的专项优化

DuckDB 最强大的功能之一是可以直接查询 Parquet 文件而不需要导入。这种能力背后的实现非常巧妙:

  • DuckDB 使用 Parquet 文件自己的元数据(行组和列元数据)来做谓词下推
  • 它利用 Parquet 的统计信息(min/max)实现 Zone Map 同样的数据跳过效果
  • 它只解压实际需要的列和行组
  • 对 Parquet 文件做了延迟物化:先只读取过滤条件涉及列的统计信息,确定需要哪些行之后,再读取其他列的具体值

这意味着 DuckDB 经常能只读取 100 GB Parquet 文件中的几百 MB 就完成查询,而查询速度几乎和使用本地表没有区别。

持久化存储格式

DuckDB 使用自己的持久化存储格式写入磁盘。文件的后缀是 .duckdb,本质上是一个写入时复制的结构化文件。新数据不是覆盖原有文件,而是写入新的页,元数据指向最新版本。这使得 DuckDB 支持 ACID 事务并能够安全地从崩溃中恢复。

值得一提的是,即使没有显式创建持久化数据库,DuckDB 也完全可以用作纯内存数据库,在 session 结束后不保存任何数据。这让它非常适合作为 ETL 流水线中的临时计算引擎。

等待执行

到这里,DuckDB 已经完成了 SQL 解析、逻辑优化、物理计划生成,并准备好了存储层的读取路径。

接下来的 Part 2 将覆盖真正的执行过程:向量化引擎如何处理一批批数据、Morsel 驱动的并行调度如何让多核 CPU 满载运行、以及各种操作符(聚合、连接、排序)的实际实现细节。

这篇文章覆盖了"一条查询从你输入到引擎准备好开始运行"的全过程,加上查询将要读取的存储层布局。等 Part 2 出来的时候,你会发现:真正的魔法不在于"存了多少数据",而在于"为了跳过这些数据,DuckDB 做了多少准备"。

如果你之前只觉得 DuckDB 是个方便的嵌入式数据库,希望这篇文章能让你看到它背后的工程深度。一个 20 MB 的二进制文件之所以能和几十万美元的集群比速度,靠的是一层又一层的精心优化——每一层都减少一点不必要的工作,最终累积出数量级的性能差距。

分享到
微博Twitter

© 2026 四月 · CC BY-NC-SA 4.0

原文链接:https://www.aprilzz.com/tutorials/duckdb-internals-part1