引言
从2019年阿姆斯特丹CWI的一个研究项目起步,DuckDB发展成为过去十年中应用最广泛的数据库之一。它应用场景众多,包括笔记本、ETL管道、仪表盘、CI测试运行器、SaaS产品内的嵌入式分析,甚至能在iPhone上以100的规模因子运行TPC - H。许多公司围绕它开发出了实际产品,如MotherDuck将其封装成云数据仓库,Hex、Omni和Evidence等将其用作应用内执行引擎和缓存,Fivetran在数据湖写入器中使用它进行合并和压缩,Rill基于它构建开源BI工具,Greybeam也用它处理数百万次查询。
什么是DuckDB?
DuckDB是一个进程内分析型SQL数据库。“分析型”意味着它针对扫描数百万行数据进行过滤、聚合和连接的查询进行了优化,而非通过主键查找单条记录的查询;“进程内”表示它没有服务器,可像加载NumPy或Polars一样,作为库加载到程序中。它以单个小于20 MB的二进制文件形式发布,无需外部依赖,可通过`pip install duckdb`、`brew install duckdb`安装,或在C++项目中链接`libduckdb`。它能直接打开包含Parquet、CSV或JSON文件的目录,将其视为SQL数据库。而且,DuckDB还是目前最快的单节点分析引擎之一,常能与花费数百万美元的集群相抗衡。
查询在进程内运行
将DuckDB指向笔记本电脑上一个6 GB的Parquet文件,不到一秒就能得到结果,无需集群、设置、迁移和`CREATE TABLE`。大多数分析型数据库是服务器,如Snowflake、Postgres、BigQuery和Redshift,需打开连接,通过TCP发送SQL语句,等待结果返回,此过程中结果集记录要序列化、反序列化,对于大型结果集,这项工作耗时可能比查询本身还长。DuckDB是库,无守护进程、端口和集群,只需加载`libduckdb`到程序中调用函数即可。2017年,Mark Raasveldt和Hannes Mühleisen研究发现,客户端协议是查询过程中最慢的一步,主要受带宽限制和每个值处理开销影响。DuckDB与客户端处于同一进程,避开了这两个瓶颈。当Python脚本针对Pandas数据框执行查询时,DuckDB可使用替换扫描功能,理想情况下能直接读取Python进程的底层缓冲区,实现零复制。以Arrow格式返回结果或查询基于Arrow的数据,可避免传统API带来的逐行转换开销。
从SQL到逻辑计划
解析
SQL语句进入DuckDB后,第一步是解析为抽象语法树(AST),DuckDB使用Postgres解析器的一个分支。AST是查询的树形表示,解析过程将扁平字符串转换为引擎能理解的结构化对象。树形结构便于引擎其他部分工作,绑定器、优化器和物理规划器都依赖它处理查询。
绑定
绑定阶段根据目录解析AST中的每个名称,进行类型检查,输出绑定树,暴露未解析列、模糊引用和类型不匹配等错误,将原始SQL文本转换为类型化的树。
优化器
DuckDB的优化器由一系列小型、专注的转换组成,可单独检查和禁用。如过滤下推,将`WHERE`谓词靠近扫描操作;子查询展开,将相关子查询重写为连接操作;动态连接 - 过滤下推,在哈希连接中利用构建侧数据计算边界,推回到探测侧扫描操作;连接顺序优化,使用动态规划算法选择最优连接顺序。整个优化阶段通常在约一毫秒内完成,之后得到逻辑计划。
物理计划
将逻辑步骤映射到物理操作符
优化器输出的逻辑计划说明计算内容,但未指定算法。DuckDB遍历逻辑计划,根据节点输入形状和谓词选择物理操作符,输出物理计划,即由执行器知道如何运行的物理操作符组成的树。物理计划会拆分为多个管道。
管道
管道可想象成装配线,数据从一端进入,经过一系列站点处理后传递给下一个站点。如`WHERE`操作、投影操作、哈希连接的探测侧等可构成管道,管道能并行执行。
管道中断器
有些操作符需看到整个输入数据才能产生输出,如`ORDER BY`、`GROUP BY`、哈希连接的构建侧,这些操作符是管道中断器或接收器,标志着一个管道的结束和下一个管道的开始。物理计划由接收器连接起来的一系列管道组成。
接收器中的操作
接收器的操作分为接收、合并和最终处理三个阶段。接收阶段,每个线程接收数据块并写入本地状态;合并阶段,将各线程本地状态合并到全局状态;最终处理阶段,合并后的全局状态作为下一个管道的输入。
并行性是局部的
管道和接收器通过为线程分配数据块和本地状态实现并行运行,DuckDB一次只对一个管道进行并行处理,这是分块驱动的并行处理和向量化执行能有效工作的原因之一。
存储层
DuckDB数据库
DuckDB数据库是单一文件,通常扩展名为`.duckdb`或`.db`,数据分割成固定大小的块,文件头包含元数据,每个块带有校验和,用于检测数据是否损坏。
列、行组和区域映射
块内部各列分开存储,列存储在分析查询中更具优势。每列分割成行组,行组是并行处理的基本单位。每个行组带有区域映射,包含最小值、最大值和空值计数,可用于跳过不满足谓词的数据。区域映射的有效性取决于列的排序方式。
Parquet
用户常将DuckDB指向Parquet文件进行查询。Parquet是列存的,按列和行组存储最小/最大统计信息,DuckDB可利用这些信息确定满足查询谓词的行组,只读取所需列块。若文件在远程位置,DuckDB可按需获取字节,合理的`WHERE`子句可提高网络性能。
CSV
CSV文件无自我描述性,DuckDB通过CSV嗅探器确定列的分隔符、值是否加引号、引号如何转义、第一行是否包含列名以及每列的类型。嗅探器包括方言检测、列类型检测和标题行检测,基于采样数据工作,可调整采样大小。
执行
查询运行前需进行大量工作,包括解析、绑定、优化和编译成物理计划,存储层也会提前做很多工作。第2部分将从执行阶段开始介绍。