DuckDB 源码分析 - Logical Plan 逻辑计划
版本
- v1.3-ossivalis
 
背景
DuckDB 是一个面向分析型工作负载的嵌入式数据库,引擎采用 C++ 编写,整体模块划分清晰,源码注释也非常到位,十分适合作为学习 AP 型数据库的入门项目。为了追踪逻辑计划在引擎中的完整生命周期,本文基于 v1.3-ossivalis 分支,编译 Debug 版本并配合 GDB 进行断点调试,便于观察每个阶段的内部状态。
1  | git clone -b v1.3-ossivalis https://github.com/duckdb/duckdb  | 
SQL Parser
DuckDB 使用 PostgreSQL 的语法解析器来完成词法/语法分析阶段,随后借助 DuckDB 内部的 Transformer 将 PostgreSQL 的解析树转换成 DuckDB 的语法树(Parser::ParseQuery 返回的 ParserResult)。这一阶段的职责仅限于构造 SQL 的抽象语法树(AST),但不涉及语义校验。
在 Parser 段落调试时,可以配合 PRAGMA parser 将 SQL 转换成 JSON,或者直接在 Parser::ParseQuery 附近打断点,观察 statements 向量的增长过程。DuckDB 的 AST 会保留原始 SQL 的很多细节(例如关键字位置、原始字符串)。
1  | Parser parser(db->con->context->GetParserOptions());  | 
Binder
DuckDB 选择将绑定(Binding)与逻辑计划的生成耦合在 Planner 模块中完成。与许多传统数据库类似,绑定阶段既是”语义审计员”,也是逻辑计划构造的起点,主要负责以下几类工作:
- 校验 SELECT 列是否存在于作用域内;
 - 确认 WHERE、GROUP BY、ORDER BY 等子句的字段或表达式是否合法;
 - 为表、列、函数等对象分配 
Binding信息,便于后续阶段定位到具体的 Catalog 条目。 
Binder::Bind 会将 QueryNode 绑定为 BoundQueryNode,同时生成逻辑计划的根节点。调试时可以在此处打断点,观察 BoundStatement::plan 如何从空指针逐步填充:
Binder维护着当前作用域的BindContext,每当遇到子查询或 CTE 时都会临时切换上下文并在返回时合并结果;- 常量表达式会在绑定阶段尝试下折叠,从而避免在逻辑计划阶段重复构造相同的表达式树;
 - 如果 SQL 含有参数(
$1、?等),Binder 也会在此阶段推导出参数类型,并写入PreparedStatementData供执行阶段使用。 
1  | BoundStatement Binder::Bind(QueryNode &node) {  | 
Logical Plan 逻辑计划
逻辑计划由一棵 LogicalOperator 树组成,每个算子节点描述一次逻辑运算(投影、过滤、JOIN、聚合等)。DuckDB 在算子层面贯彻了“结构即语义”的设计:算子类型、子节点列表与表达式集合共同定义了逻辑意图。LogicalOperator 的骨架如下:
1  | class LogicalOperator {  | 
为了便于调试,我们构造一张测试表,并插入一些示例数据。这些数据既覆盖了 NULL、数组、唯一约束等场景,也方便在断点状态下观察列裁剪、谓词传播等细节:
在阅读 LogicalOperator 时可以先关注以下几个小技巧:
- 结合 
LogicalOperator::ToString输出的树形结构与下文的 ASCII 图,可以快速确认算子嵌套是否符合预期; - 当逻辑计划出现循环依赖或缺失列时,
Verify会立刻报错,因此开发调试阶段推荐始终在 Debug 模式下运行。 
1  | D CREATE TABLE test_table (  | 
开启 PRAGMA explain_output = 'all' 后可以同时查看逻辑计划与物理计划。如下查询的未优化逻辑计划包含三个算子,我们可以借助它来快速验证逻辑树结构与列绑定是否符合预期:
1  | D PRAGMA explain_output = 'all';  | 
1  | ┌─────────────────────────────┐  | 
LOGICAL_PROJECTION:对应 SELECT 列表,负责输出最终的列集;LOGICAL_FILTER:表达式为age > 30,对扫描结果做行级过滤;LOGICAL_GET:数据源算子,从test_table顺序扫描数据。
更多算子类型可以参考 logical_operator_type.hpp。
Logical Plan 生成
逻辑计划生成的核心入口是 Binder::CreatePlan(BoundSelectNode &statement)。函数内部按照 SQL 子句的语义顺序构建算子树,整体遵循“自下而上拼装子树、再逐层加壳”的思路:
- FROM 子句:
BoundSelectNode::from_table在绑定阶段已经转换成相应的LogicalGet或其他子查询算子,作为整个算子树的初始根节点。 - SAMPLE:若存在 
USING SAMPLE语法,将LogicalSample包裹在当前根节点外层。 - WHERE:使用 
PlanFilter将过滤谓词包装为LogicalFilter,并挂在现有根节点之上。 - GROUP BY / 聚合:当存在聚合或分组时,提前遍历子查询并生成 
LogicalAggregate;Grouping Sets、Grouping Functions 等高级语法也在此处处理。 - HAVING:再次生成 
LogicalFilter,与 WHERE 类似,只是输入来自聚合结果。 - 窗口函数与 QUALIFY:依次生成 
LogicalWindow和LogicalFilter,并处理其中可能嵌套的子查询。 - UNNEST:针对 LATERAL 或 UNNEST 场景,构造 
LogicalUnnest。 - SELECT 列表与投影:遍历 
select_list处理子查询后,创建LogicalProjection作为新的根节点。 - ORDER BY / LIMIT / DISTINCT:调用 
VisitQueryNode追加排序、去重、限制行数等算子。 - 列裁剪:若 
need_prune标记为 True,插入额外的LogicalProjection以仅保留需要的列。 
对应源码片段如下 (省略与主题无关的细节):
1  | unique_ptr<LogicalOperator> Binder::CreatePlan(BoundSelectNode &statement) {  | 
通过上述流程可以看到,逻辑计划的生成并不是单纯的”语法树到逻辑树”的转换,而是伴随着大量的语义分析和子查询规划,确保每个算子都具备足够的上下文信息供后续优化与执行阶段使用。
Logical Plan Optimizer 逻辑计划优化
逻辑计划完成生成后, 我们需要对逻辑计划展开优化:
1  | 
  | 
Optimizer::Optimize 的整体框架如下:
DuckDB 的逻辑优化器本质上是一个多阶段的规则引擎:首先应用表达式重写(常量折叠、谓词归并等),随后执行逻辑层的算子改写(谓词下推、JOIN 重排、列裁剪),最后再把控制权交给扩展点,让用户注入自定义的优化规则。每个阶段都通过 RunOptimizer 计时,配合 EXPLAIN ANALYZE 中的 Profile 信息可以精确定位耗时的规则。
1  | unique_ptr<LogicalOperator> Optimizer::Optimize(unique_ptr<LogicalOperator> plan_p) {  | 
DuckDB 内置了很多启发式规则, 其中内置的优化规则包括:
- EXPRESSION_REWRITER: 表达式重写.
 
表达式重写是
Optimizer初始化时构造函数里就填好的规则, 包括常量折叠, 分配律优化, CASE 语句简化等等.
- FILTER_PULLUP: 谓词上拉.
 
我们以下面这个 SQL 为例, FILTER_PULLUP 优化会将
vals1.i=5提至FILTER算子下方, 方便后续的谓词下推:
1  | D explain SELECT * FROM (SELECT * FROM vals1, vals2 WHERE vals1.i=5) as tbl1,  | 
FILTER_PULLUP 优化后的逻辑计划:
1  | ┌─────────────────────────────┐  | 
- FILTER_PUSHDOWN: 谓词下推
 
FILTER_PULLUP 将过滤条件提至了上端, FILTER_PUSHDOWN 搜集这些过滤条件下推到底层投影列, 这样在 SQL 执行的过程中可以提早的过滤数据, 减少 CPU 和 IO 的开销.
EMPTY_RESULT_PULLUP,
CTE_FILTER_PUSHER,
REGEX_RANGE,
IN_CLAUSE,
JOIN_ORDER:
JOIN_ORDER 优化方法主要目标是重新排列多表 JOIN 的顺序,以获得最优的查询执行性能.
DELIMINATOR,
UNNEST_REWRITER,
UNUSED_COLUMNS,
STATISTICS_PROPAGATION,
COMMON_SUBEXPRESSIONS,
COMMON_AGGREGATE,
COLUMN_LIFETIME,
BUILD_SIDE_PROBE_SIDE,
LIMIT_PUSHDOWN:
LIMIT_PUSHDOWN 优化是将 LIMIT 操作符下推到 PROJECTION 操作符之下,以减少不必要的行处理和提高查询性能.
TOP_N:
针对
ORDER BY ... LIMIT的查询,将排序限制在 Top-N 元素范围内执行,避免对全部数据排序。COMPRESSED_MATERIALIZATION:
在物化中保留压缩格式,仅在必要时解压以减少内存带宽和缓存占用。
DUPLICATE_GROUPS:
在聚合之前检测并消除重复的分组键组合,降低
GROUP BY需要处理的行数。REORDER_FILTER:
调整同一节点下多个过滤条件的顺序,优先执行选择性更高的条件以尽早裁剪数据。
SAMPLING_PUSHDOWN:
将采样操作下推到扫描算子,从数据源直接获取样本行,缩短上层管线等待时间。
JOIN_FILTER_PUSHDOWN:
将连接产生的过滤条件继续向下推送到参与连接的输入上,减少参与 JOIN 的候选行。
EXTENSION:
为用户自定义或外部扩展预留的规则集合,可在此阶段注入定制优化。
MATERIALIZED_CTE:
判断 CTE 是否需要物化,若可复用则缓存一次结果供多处引用,若仅使用一次则改为内联避免额外开销。
SUM_REWRITER: SUM() 聚合表达式的重写.
官方注释里标明了具体的优化规则即: SUM(x + C) -> SUM(x) + C * COUNT(x), x 为某列字段, C 为常量:
SUM(x + C) 需要对每列的数据先多一次加法, 每列数据额外引入了一条加法指令.
SUM(x) 对一大列连续内存做加法, 可以使用 SIMD,比如 AVX 指令一轮处理8或16个元素, 然后使用一条乘法指令 C * count(X) 即可.LATE_MATERIALIZATION:
在列式执行中尽量延迟宽列或大对象的加载,等到真正需要输出阶段再取回具体字段。
这些规则以流水线的方式运行:例如在 FILTER_PULLUP 将谓词统一收集到上层后,FILTER_PUSHDOWN 会立刻尝试把同一组谓词重新分发到 LogicalGet、LogicalJoin 等算子上,从而达到“上提 → 分发 → 裁剪”的闭环。
总结
本文梳理了 DuckDB 在逻辑层面的执行流程:从 PostgreSQL Parser 得到 AST,到 Binder 绑定语义,再到 LogicalOperator 树的逐步构建,最后交由启发式优化器对计划进行重写。逻辑计划优化器仍然是 DuckDB 中最值得深挖的部分,例如谓词上下推、JOIN 重排、统计信息传播等策略都蕴含着大量实现细节。后续文章将针对其中几类优化算法展开更深入的源码分析。