土法炼钢 · 系统与基础设施

查询优化器:System R 动态规划、Cascades Memo 与基数误差

文章导航

分类入口
algorithmsdatabase
标签入口
#query-optimizer#system-r#volcano#cascades#cardinality-estimation#cost-model#postgresql#calcite#dpccp

目录

同一条 SQL 可以写得很短,执行计划却可能从一次索引嵌套循环到多路哈希连接差几个数量级。优化器要解决的问题不是“会不会用 hash join”,而是在三件事之间同时取舍:哪些等价变换应该纳入搜索,哪些物理性质值得为上层保留,以及统计信息的误差会不会把搜索导向错误的计划。

本文按一条线索展开:System R 用左深动态规划和 interesting orders 建立了代价优化器的基本范式;Volcano/Cascades 把等价表达式、规则和物理性质放进 memo;后来的 DPccp/DPhyp 重新审视 join-order 枚举;生产系统再用阈值、启发式和超时约束规划时间。所有枚举数字与基数误差实验来自同目录 reproduce/query_optimizer_experiment.py,源码事实钉住 PostgreSQL REL_16_9 与 Apache Calcite 1.37.0。

一、优化器到底在优化什么

关系查询优化器输入的是逻辑代数树,输出的是物理执行计划。对一个只含内连接的子问题,可以把参与连接的表记为集合 \(S\),优化器通常要为每个 \(S\) 找到若干候选计划:

对 \(n\) 张表,左深树的叶子排列已有 \(n!\) 种;完全二叉 bushy 树的叶子标号数为 \(n! C_{n-1}\),其中 \(C_{n-1}\) 是 Catalan 数。\(n=10\) 时,左深排列是 \(3,628,800\),bushy 形状乘标号是 \(17,643,225,600\)。这还没有乘上访问路径、join 算子和有序性的组合。优化器必须把“枚举足够多”与“规划时间可接受”同时作为目标。

本文不重复上一篇 Join 算法:嵌套循环、排序归并与 Hybrid Hash 的代价和实现 已经讲过的 join 算子内部实现;这里把 join 当作候选物理算子,重点看它们如何被组织进搜索空间。

二、System R:左深动态规划与 interesting orders

Selinger、Astrahan、Chamberlin、Lorie 和 Price 在 SIGMOD 1979 的 “Access Path Selection in a Relational Database Management System” 中给出了后来代价优化器的基本形状。System R 的输入是关系代数表达式和统计信息,输出是预计代价最小的访问路径与连接顺序。它有三个今天仍能在优化器里看到的设计。

左深动态规划

System R 只枚举左深树(left-deep tree):每一步把已经得到的中间结果与一个基表相连。设 best[S, p] 是连接集合 \(S\)、并满足物理性质 \(p\) 的最佳计划,简化后的递推是:

for each base relation R:
    keep cheapest access paths for {R}
for size = 2..n:
    for each subset S with |S| = size:
        for each R in S:
            L = S - {R}
            if L can join R:
                combine every retained plan of L with every access path of R
                keep cheapest plan per required physical property

这个递推利用了最优子结构:若一个左深计划最后一步是 \(L \bowtie R\),那么 \(L\) 的子计划也应该是某个性质下的最优候选。它牺牲了 bushy 计划,但让搜索从超指数形状压到按子集组织的动态规划。PostgreSQL 的常规 join 搜索仍然按 join relation 的大小分层,这一点与 System R 的思路相同,尽管它会在有连接谓词时生成有限的 bushy 组合(第七节)。

Interesting orders

System R 的一个关键洞察是:局部最便宜不等于全局最便宜。一个索引扫描也许比顺序扫描贵,但它输出的顺序可能让上层 merge join、ORDER BY 或 GROUP BY 省掉排序。System R 把这种对后续算子有价值的顺序称为 interesting order。

因此,优化器不能只为每个 \(S\) 保留一个最低代价计划,而要按物理性质保留多个候选:一个可能是无序但便宜的 hash join,另一个可能是稍贵但按连接键有序的 merge join。这个想法后来在 Volcano/Cascades 里被推广为 trait 或 physical property。

代价公式与边界

System R 论文里的代价函数把页读取(page fetch)与 RSI 调用数(可近似理解为元组级 CPU 工作)合在一起,形式是页读取数加权 CPU 调用数。这个模型很粗,但它确立了一个原则:优化器比较的是估算代价,而估算代价由统计信息、算子模型和物理性质共同决定。

这也给 System R 留下边界:它主要处理单块查询的访问路径与左深 join 顺序;对于大量等价变换、复杂物理属性和可扩展规则集,手写动态规划会迅速变得难维护。

三、连接顺序枚举:形状比表数更重要

连接顺序不是只由表数决定,还由查询图决定。把每张表看成顶点,每个 join 谓词看成边;不允许笛卡尔积时,候选子计划必须对应连通子图。链、星和团三种图在同样表数下给优化器的压力完全不同。

reproduce/query_optimizer_experiment.py 实现了两个小枚举器:

运行命令:

cd post/algorithms/60-query-optimizer/reproduce
taskset -c 15 python3 query_optimizer_experiment.py

本机复核环境是 Python 3.14.5、matplotlib 3.11.2、Linux 6.6.87.2,CPU 可用编号为 0–23;实验指标是候选数和代价比,不依赖墙钟时间。脚本输出 results/enumeration_counts.csv、results/cardinality_error.csv、results/summary.txt,并重画本文三张 SVG。

链、星、团三类查询图在 4 到 12 张表时的左深候选数和 CSG-CMP 候选数;团图随表数指数增长,链图只缓慢增长

\(n=12\) 时,脚本得到的候选数如下:

查询图 连通子集数 DPsize 左深候选 CSG-CMP 候选
chain 78 132 286
star 2,059 11,275 11,264
clique 4,095 24,564 261,625

三点值得注意。

第一,链式查询虽然有 12 张表,但连通子集只有所有区间,共 \(78\) 个;优化器只要沿相邻边扩展,搜索很小。第二,星形查询的连通子集已经接近 \(2^{11}\):任何包含中心点的叶子集合都是连通的。第三,团图中 bushy CSG-CMP 候选远多于左深候选,因为任意两个不相交子集都能连接。只看“表数超过多少”不能判断优化难度,查询图的稠密度同样关键。

Moerkotte 和 Neumann 的贡献就在这里。VLDB 2006 的 DPccp 关注如何枚举 connected subgraph/complement pair 而不重复、且避免无连接谓词的笛卡尔积;SIGMOD 2008 的 “Dynamic Programming Strikes Back” 引入 DPhyp,把动态规划扩展到更复杂的 hypergraph 表达。它们反驳了“DP 只能做小查询”的简单说法:如果枚举对象选得对,DP 在不少实际查询上仍有竞争力。

四、Volcano 与 Cascades:memo、规则和性质

Graefe 与 McKenna 在 ICDE 1993 的 Volcano Optimizer Generator 中把优化器写成可扩展的规则系统:逻辑变换、物理实现和搜索策略解耦。Graefe 1995 年的 Cascades 进一步把 memo、top-down 搜索和物理性质整合成一个框架。今天很多系统并不逐字实现 Cascades,但 memo 化搜索、规则驱动和 trait 传播已经成了优化器工程的通用语言。

Memo 的结构

Cascades memo 不是一棵树,而是等价表达式的 DAG。一个 group 表示同一逻辑结果的等价类;一个 group expression 是某个具体表达式,它的孩子指向 group,而不是指向某棵固定子树。这样,交换律、结合律生成的新表达式可以共享同一个子问题。

Cascades memo 中 Group、逻辑表达式、物理表达式与 trait 的关系;一个 group 存多个等价表达式,winner 按 required trait 分开选择

图里 Group 0 表示三表连接结果;它可以包含逻辑 join 的不同结合顺序,也可以包含 hash join、merge join 等物理表达式。Group 1、Group 2 等子 group 被多条表达式共享。重要的是:winner 不是全局唯一的。同一个 group 可能在“无序输出”下 hash join 最便宜,在“按连接键有序”下 merge join 加少量排序更便宜。

规则与 enforcer

Cascades 风格的规则通常分三类:

规则类型 输入与输出 例子
transformation 逻辑表达式到逻辑表达式 join commutativity、join associativity、predicate pushdown
implementation 逻辑表达式到物理表达式 logical join \(\rightarrow\) hash join / merge join / nested loop
enforcer 改变物理性质 sort enforcer 产生有序输出,exchange enforcer 产生分区输出

Top-down 搜索从根 group 和 required properties 出发,给每个子 group 传递所需性质与剩余代价上界。若当前表达式的局部代价已经超过上界,就可以剪枝。这与 System R 的 bottom-up DP 不矛盾:两者都依赖 memo,只是搜索方向与规则组织不同。

Calcite 的 VolcanoPlanner

Apache Calcite 1.37.0 的 org.apache.calcite.plan.volcano.VolcanoPlanner 是一个可核对的生产级例子。findBestExp() 会先 ensureRootConverters(),再 ruleDriver.drive() 触发规则,最后调用 root.buildCheapestPlan(this) 生成最便宜计划。registerImpl() 会调用 rel.onRegister(this) 递归注册子表达式,用 digest 去重,并把等价表达式放入 RelSet / RelSubset。

Calcite 的名字是 VolcanoPlanner,但它也有 Cascades 语境里的关键部件:memo 化等价类、规则驱动、trait set 和 converter。把它称为“使用 Volcano/Cascades 思想的 planner”比把它说成某篇论文的逐字实现更准确。

五、基数估计:优化器争论的主战场

搜索算法再精巧,也要吃估算基数。设真实基数为 \(c\),估计基数为 \(\hat c\),数据库论文常用 q-error 衡量相对误差:

\[ q(\hat c, c) = \max\left(\frac{\hat c}{c}, \frac{c}{\hat c}\right). \]

\(q=1\) 表示完全准确,\(q=10\) 表示高估或低估 10 倍。Ioannidis 和 Christodoulakis 在 SIGMOD 1991 的 “On the Propagation of Errors in the Size of Join Results” 中已经指出,join 结果大小误差会沿连接树传播;在独立性假设失效时,多个选择率误差会相乘。

一个可复现实验

脚本里的第二个实验固定一张 6 表查询图和真实选择率。优化器用带 lognormal 噪声的选择率估计做左深 DP,选出计划;然后用真实选择率重新计算该计划的真实代价,并与真实最优左深计划比较。每个误差强度跑 30 个固定种子,指标仍然不依赖时钟。

选择率估计误差增大时,最大 q-error 与真实代价退化的关系;中位数计划常保持最优,但 p90 与最坏情况快速上升

results/summary.txt 的汇总如下:

lognormal \(\sigma\) 最大边 q-error 中位数 真实代价比中位数 真实代价比 p90 最坏真实代价比
0.0 1.00 1.00 1.00 1.00
0.5 2.14 1.00 1.00 3.75
1.0 4.60 1.00 3.75 4.69
1.5 9.85 1.00 3.75 4.69
2.0 21.12 1.00 4.69 44.98

这个小实验不代表真实数据库负载,但它展示了两个机制。第一,轻微误差不一定改变计划:许多种子下最优顺序仍然稳定,所以中位数代价比为 1。第二,尾部风险增长很快:当某几条边同时被严重低估或高估,优化器会把大中间结果提前物化,最坏代价比达到 \(44.98\)。

Leis、Gubichev、Mirchev、Boncz、Kemper 和 Neumann 在 PVLDB 2015 的 “How Good Are Query Optimizers, Really?” 用 Join Order Benchmark 系统地讨论了同一个问题。他们把优化器拆成四部分评估:基数估计、代价模型、物理算子选择和枚举算法。论文的核心结论是:在他们的实验中,基数估计误差是导致坏计划的主因;如果喂给优化器真实基数,很多系统即使用相对简单的代价模型也能得到接近最优的计划。

这不是说代价模型无关。代价模型决定 hash join、merge join、index nested loop、排序和并行的取舍;但如果一个中间结果被估成 \(10^3\) 行、真实却是 \(10^9\) 行,再精细的 CPU cache 参数也救不了错误的连接顺序。

六、代价模型:粗糙但必须一致

一个代价模型至少要把三类量放到同一单位里:

System R 的页读取加 CPU 调用只是最早的版本。现代系统会有更多参数,但核心仍是“可比较”。PostgreSQL 的 EXPLAIN 显示的是抽象 cost,不是毫秒;MySQL、SQL Server、Oracle 也不会把代价单位暴露成真实时间。抽象单位的好处是稳定,坏处是参数不准时容易和硬件脱节。

Interesting orders 可以看成代价模型与物理性质的交界:一个计划为了保持排序多花了本地代价,但让上层少一次排序。Cascades 的 required properties 则把这种取舍系统化:上层请求“需要按 \(k\) 有序”,子 group 才会比较“直接产生有序输出”和“先无序输出再加 sort enforcer”的总代价。

工程上最危险的不是模型简单,而是模型前后不一致。例如,规划阶段认为 hash join 不会溢出,执行阶段却因为内存估计错误分批落盘;或者统计信息假设列独立,真实数据里两个过滤条件高度相关。这些问题最终都会以“代价模型输入错了”的形式表现出来。

七、PostgreSQL 16:DP、bushy 限制与 GEQO

PostgreSQL REL_16_9 的常规 join 搜索可以在 src/backend/optimizer/path/joinrels.c 里直接看到。join_search_one_level(PlannerInfo *root, int level) 的注释写明:它构造恰好包含 level 个 jointree items 的 join relations,是 standard_join_search 动态规划的一步。

代码分三段。

  1. 先把 level - 1 个关系的 joinrel 与一个基表相连,也就是左深或右深扩展。若有相关 join clause 或 join order restriction,调用 make_rels_by_clause_joins();若没有,就生成必要的 clauseless join。
  2. 再枚举 bushy 组合:把大小为 \(k\) 的 joinrel 与大小为 level-k 的 joinrel 相连,但只在 have_relevant_joinclause() 或 have_join_order_restriction() 成立时生成,避免无谓的笛卡尔积爆炸。
  3. 如果前面完全没有生成可用 joinrel,最后才用 clauseless join 兜底,处理某些 join 子问题被语法边界隔开的特殊情况。

make_rels_by_clause_joins() 的注释还说明,(a join b) join c 与 (b join c) join a 会落到同一个 RelOptInfo,不同路径作为 Path 加进去;join_rel_level 机制保证新 joinrel 只加入列表一次。这正是 memo 思想在 PostgreSQL 自有数据结构里的体现。

表很多时,PostgreSQL 默认不再使用穷举 DP。src/backend/utils/misc/guc_tables.c 中 geqo_threshold 的默认值是 12,说明文字是 “Sets the threshold of FROM items beyond which GEQO is used.”。达到阈值后会进入 GEQO(Genetic Query Optimizer):src/backend/optimizer/geqo/geqo_main.c 里可以看到 gimme_pool_size()、gimme_number_generations()、random_init_pool()、sort_pool() 以及 chromosome 交叉变异相关代码。

这不是“PostgreSQL 只能优化 12 表以内查询”。准确说法是:默认配置下,FROM items 数达到 12(levels_needed >= geqo_threshold)时,优化器为了限制规划时间,会从穷举动态规划切到遗传搜索;用户可以调整 geqo_threshold 或关闭 GEQO,但代价是规划时间可能急剧上升。

八、学习型优化器:开放问题而不是替代品

学习型优化器常被写成“取代 CBO”,但论文里的主张更具体。

Neo(Marcus、Negi、Mao、Zhang、Alizadeh、Kraska、Papaemmanouil、Tatbul,PVLDB 2019)把优化过程表示成候选计划搜索与神经网络价值评估:模型从已执行查询中学习计划质量,逐步引导搜索。Bao(Marcus、Negi、Mao、Tatbul、Alizadeh、Kraska,SIGMOD 2021)更工程化:它不直接生成任意计划,而是在现有优化器外面选择一组 hint,让底层数据库继续负责合法计划生成;模型根据实际执行反馈更新。

二者的共同点是:都承认传统优化器已有大量工程约束,学习组件更现实的位置是补充估计或选择,而不是绕过执行器、事务语义和物理算子限制从零生成计划。

学习型方向仍有几个具体开放问题:

因此,本篇把学习型优化器放在开放问题里,而不是把它写成 System R → Cascades 之后的必然终点。Leis 等人的结论也提醒我们:先把基数估计、相关性建模和生产实现里的边界做好,往往比换一个搜索器更重要。

九、工程判断:读执行计划时先看什么

面对一条慢 SQL,按下面顺序排查比直接调优化器开关更稳。

问题 观察点 常见处理
基数估计是否错得离谱 EXPLAIN ANALYZE 中 estimated rows 与 actual rows 的 q-error 更新统计信息,增加多列统计,改写相关谓词,必要时拆查询
Join 顺序是否由坏估计触发 大中间结果是否过早出现 先修统计;临时用 hint 或 join order 约束验证假设
物理算子是否溢出 hash join batch 数、sort spill、临时文件 I/O 调整 work memory 或让过滤更早发生
Interesting order 是否被利用 计划中是否额外 sort,索引顺序是否能满足 ORDER BY/merge join 增加合适索引或避免破坏顺序的算子
规划时间是否过长 表数、查询图稠密度、是否触发 GEQO/超时 分解查询、物化中间结果,或调整搜索阈值

几个经验边界也要分清。

十、复现程序说明

reproduce/query_optimizer_experiment.py 做三件事:

  1. 枚举 chain、star、clique 三种查询图在 \(n=4..12\) 时的连通子集、左深候选和 CSG-CMP 候选;
  2. 在固定 6 表查询图上注入选择率估计误差,记录最大 q-error、所选计划真实代价和真实最优代价;
  3. 生成 plan-enumeration.svg、cardinality-error.svg 与 cascades-memo.svg。

复现命令如下(需要 Python 3 与 matplotlib;脚本只输出计数与代价,不计时):

cd post/algorithms/60-query-optimizer/reproduce
taskset -c 15 python3 query_optimizer_experiment.py
cat results/summary.txt

输出应与第三、五节表格一致:n=12 时 chain/star/clique 的候选数分别为 132/11,275/24,564(左深)和 286/11,264/261,625(CSG-CMP);sigma=2.0 时 median_q=21.12、p90_regret=4.69、worst=44.98。

如果系统 Python 没有 matplotlib,安装依赖或使用带 matplotlib 的虚拟环境即可;脚本本身不依赖本机私有路径。生成图使用固定 svg.hashsalt,没有墙钟时间元数据。

十一、参考资料

源码与文档

核心论文

Join 枚举论文

学习型优化器

实验


系列导航: - 上一篇:Join 算法:嵌套循环、排序归并与 Hybrid Hash 的代价和实现 - 下一篇:数据库缓冲池替换:LRU-K、2Q 与生产级扫描保护

相关阅读: - Join 算法:嵌套循环、排序归并与 Hybrid Hash 的代价和实现 - 数据库缓冲池替换:LRU-K、2Q 与生产级扫描保护 - 页面置换算法:从 OPT、LRU 到 ARC 与 Linux 页面回收

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-04-27 · algorithms / database

数据库缓冲池替换:LRU-K、2Q 与生产级扫描保护

从数据库缓冲池的 fix/unfix、脏页和扫描污染出发,对照 LRU-K、2Q、CLOCK-Pro 的学术脉络,以及 PostgreSQL 16 与 InnoDB 8.0 的源码实现,用可复现 trace 比较命中率和元数据开销。

2025-07-15 · algorithms / database

HyperLogLog:从概率计数到 Redis 实现的基数估计

12 KB 的 HyperLogLog 误差从哪来:沿 FM85、LogLog、HLL、HLL++ 到 Ertl 估计器推导,用 1000 次模拟实测各代估计器在小、中、大基数上的偏差,并对照 Redis 7.2.5 源码拆解稀疏/密集编码和三次更换的估计公式。

2026-04-28 · algorithms / database

WAL 与 ARIES:pageLSN、CLR 与可重启恢复

从 steal/no-force 缓冲策略出发,拆解 ARIES 的 Analysis、Redo、Undo、pageLSN、CLR 与 fuzzy checkpoint,并用可复现实验验证恢复中再次崩溃的幂等性;最后对照 PostgreSQL 16 与 SQLite 3.46 的真实 WAL 边界。


By .