← 返回信息流
AI 资讯Hacker News·3 天前

DuckDB 内部解析:为何性能如此卓越

原标题:DuckDB Internals: Why Is DuckDB Fast? (Part 1)

速览

本文是 DuckDB 内部机制系列文章的第一部分,旨在解答为何 DuckDB 能够保持极高的查询速度。文章将深入探讨其底层架构设计与优化策略,帮助开发者理解其高性能来源。这对于优化数据分析工作负载具有重要参考价值。

AI 深度解读

DuckDB 内部机制深度解析:为何它如此快速?(第一部分)

背景

DuckDB 自 2019 年在荷兰 CWI 阿姆斯特丹作为研究项目诞生以来,已发展成为过去十年中采用率最高的数据库之一。其应用场景极其广泛,涵盖了 Jupyter 笔记本、ETL 管道、数据仪表盘、CI 测试运行器、SaaS 产品内部的嵌入式分析,甚至能在 iPhone 上以 100 的规模因子运行 TPC-H 基准测试。

目前,许多公司正围绕 DuckDB 构建实际产品:

  • MotherDuck 将其封装为云数据仓库。
  • HexOmniEvidence 等 BI 和数据应用平台将其用作应用内执行引擎和缓存。
  • Fivetran 的托管数据湖服务在其数据湖写入器内部使用 DuckDB 进行合并和压缩。
  • Rill 在其基础上构建了开源 BI 工具。
  • Greybeam 也使用它来支撑数百万次查询的 BI 和分析工作负载。

DuckDB 是一个**进程内(in-process)**的分析型 SQL 数据库。

  • 分析型:意味着它针对扫描数百万行以进行过滤、聚合和连接查询进行了优化,而非针对通过主键查找单条记录的查询。
  • 进程内:意味着没有独立的服务器。你不需要连接 DuckDB,而是像加载 NumPy 或 Polars 一样,将其作为库加载到你的程序中。

DuckDB 之所以获得广泛采用,很大程度上是因为其易用性。它作为一个不到 20 MB 的单二进制文件分发,无外部依赖。你可以通过 pip install duckdbbrew install duckdb 或将 libduckdb 链接到 C++ 项目中轻松安装。它能像操作 SQL 数据库一样直接打开任何包含 Parquet、CSV 或 JSON 文件的目录。

此外,DuckDB 也是目前可用的最快单节点分析引擎之一,其性能经常能与每年花费数百万美元的全集群相媲美。

本文是深入探讨 DuckDB 内部机制的三部曲中的第一篇。我们将追踪一个查询从进入引擎到返回结果的整个过程,并在每个阶段剖析使其高速运行的设计选择。

核心内容

1. 消除网络与序列化开销:进程内执行

大多数分析型数据库(如 Snowflake、Postgres、BigQuery、Redshift)都是服务器架构。用户通过 TCP 协议发送 SQL,等待结果返回。在这个过程中,结果集中的每条记录都需要被序列化为网络协议格式,通过网络传输,并在另一端反序列化。

在数据库内部,查询结果以特定内存地址中的类型化值形式存在(例如,64 位整数在此处,字符串指针在彼处)。这些地址仅存在于该进程内部。为了将结果发送给另一台机器上的客户端,数据库必须将每个值重写为约定的字节格式(Postgres 有自己的一套,MySQL 有另一套,ODBC 和 JDBC 则是暴露在这些之上的客户端 API 驱动)。客户端随后将这些字节解析回其原生类型。对于大型结果集,这种编码和解码的工作量往往比查询本身还要耗时。

DuckDB 不是服务器,而是一个库。没有 DuckDB 守护进程,没有端口,没有集群。你将 libduckdb 加载到程序中并直接调用函数。

2017 年,Mark Raasveldt 和 Hannes Mühleisen 发表了《Don't Hold My Data Hostage》一文,测量了从数据仓库提取结果集时的实际开销。他们发现,客户端协议本身(ODBC、JDBC 及类似的逐行值 API)往往是整个查询中最慢的单个步骤,其耗时有时甚至远超数据库计算答案的时间。

造成这一现象有两个主要成本:

  1. 原始带宽限制:典型的千兆以太网链路速度上限约为 125 MB/s,大型结果集的传输时间可能比计算时间更长。
  2. 每值开销:ODBC 和 JDBC 逐行、逐值地返回结果,这意味着客户端为每一行的每个字段都进行单独的功能调用。对于 1 亿行的结果集,这意味着数亿次函数调用,每次调用都要进行内存拷贝、类型检查和字符串分配。

虽然 ADBC 使用列式 Arrow 格式在系统间传输数据,避免了 ODBC 和 JDBC 所需的逐行序列化/反序列化,但 DuckDB 通过让客户端和数据库运行在同一进程中,从根本上规避了这两个瓶颈。

2. 零拷贝集成:与 Pandas 和 Arrow 的无缝对接

当 Python 脚本通过 con.sql("SELECT ... FROM my_df") 对 pandas DataFrame 运行查询时,DuckDB 利用了一个名为**替换扫描(replacement scan)**的特性。

  • 机制:DuckDB 不会先将 DataFrame 复制到内部表中,而是将表引用替换为一个函数,该函数在查询运行时直接从 DataFrame 读取数据。
  • 零拷贝优势:在最理想的情况下,DuckDB 可以直接读取 Python 进程已经拥有的底层缓冲区,从而避免数据的双重完整拷贝。如果 NumPy 提供“100 万个 int64 值的连续内存缓冲区”,DuckDB 通常可以直接读取该缓冲区,因为它理解相同的物理布局。
  • 实际情况:是否真正实现零拷贝取决于 DataFrame 的物理布局、列类型、空值表示和字符串存储。如果类型或布局不匹配,DuckDB 可能会为某些列分配转换后的缓冲区。

Arrow 格式的优势:Arrow 是一种专为系统间共享数据而设计的列式、类型化内存格式。因此,将 DuckDB 结果返回为 Arrow 格式,或查询基于 Arrow 的数据,可以避免传统 API 强加的逐行转换开销。

3. 查询处理管线:从 SQL 到执行计划

一旦 SQL 到达 DuckDB,它将经历标准的处理阶段:解析(Parse)、绑定(Bind)、规划(Plan)和优化(Optimize)。

解析(Parse)

第一步是将 SQL 解析为抽象语法树(AST)。DuckDB 使用了 Postgres 解析器的分支,这也是其方言感觉如此熟悉的原因。 AST 是查询的树状表示,每个节点代表一个语法结构(如 SELECT 语句、列引用、函数调用、连接、字面量)。解析将扁平的 SQL 字符串(例如 SELECT sum(l_quantity) FROM lineitem WHERE l_shipdate > '2024-01-01')转换为引擎可以推理的结构化对象。

树状结构是引擎其余部分发挥作用的基础:

  • 绑定器遍历节点,将 l_quantity 解析为特定表中的特定列。
  • 优化器通过子树模式匹配,识别出 WHERE 谓词可以下推到扫描阶段。
  • 物理规划器将函数调用节点映射到可执行的操作符。 这些阶段无法在原始 SQL 上操作,它们需要遍历、模式匹配并重写类型化的结构。

绑定(Bind)

下一步是将 AST 中的每个名称与目录(catalog)进行解析。

  • lineitem 变为具有已知模式的特定表。
  • l_quantity 变为具有已知类型的特定列。
  • sum 变为输入类型与该列匹配的特定聚合函数。

类型检查也在此阶段进行:例如,将 l_shipdate 与字符串 '2024-01-01' 进行比较是可行的,因为绑定器会将字面量强制转换为日期类型。

输出结果是一棵绑定树(bound tree),其中每个节点都知道它引用的是什么以及它产生的类型。未解析的列、模糊引用和类型不匹配等错误会在此阶段暴露。

此时,DuckDB 已将原始 SQL 文本转换为类型化的树。引擎不再看到 l_quantity 这样的字符串,而是看到指向具体元数据和内存布局的指针。

关键要点

  • 架构差异:DuckDB 是进程内库而非服务器,消除了网络传输、TCP 协议开销以及序列化/反序列化的巨大性能损耗。
  • 零拷贝技术:通过与 Pandas 和 Arrow 等内存格式的紧密集成,DuckDB 能够直接读取现有内存缓冲区,避免数据冗余拷贝,显著提升处理
查看原文 →greybeam.ai