前言
先看个挺常见的现象。同一句
SELECT * FROM a JOIN b ...,张三写出来,跑几百万行才勉强把结果熬出来。李四拿过去,就改了两笔,一秒不到就返回了。SQL 看着差不多,结局差出十万八千里。问题基本不在 SQL 本身,得往数据库里头找:里头有两个部件在替你兜底,一个叫优化器,一个叫执行器。
这篇就拆这俩。先说优化器,核心是它手里那招「等价变换」:趁你没留神,把 SQL 换个写法,结果一分不差,跑起来却轻快不少。再挪到执行器,聊「条件执行调度」。计划虽然定死了,可真跑起来还有一堆事得临场决断------哪个算子先动,哪些中间结果得先攒着,要不要临时拉几个 worker 并行干。这些没有标准答案,全看当场碰上什么数据。最后把几款主流数据库排一排,看看各家都是怎么取舍的。
顺带交代一句:文里出现的执行计划节点名、参数和优化规则,我拿金仓 KingbaseES 当样板来讲。但底下那些道理,换个关系库也照样说得通。
@TOC
一、优化器在做什么:从一条 SQL 到一棵执行树
聊等价变换之前,得先把一条 SQL 在引擎里走过的路捋清楚。你按下执行,到结果回来,中间其实悄悄分了五步:
text
1. parser 词法语法分析 SQL 文本 → 解析树
2. analyzer 语义检查 解析树 → 查询树(表在不在、列对不对、类型合不合理)
3. rewriter 查询重写 展开视图、套规则 → 新的查询树
4. planner 查询优化 查询树 → 执行计划(一棵算子树)
5. executor 查询执行 照着计划树一步步跑,吐结果
真正费心思的是第 4 步,planner。它自己又劈成两半,两边路子完全不一样:
- 逻辑优化,圈子里叫 RBO,靠规则。 它手里攥着一堆关系代数的等价变换规则,拿它们把查询树改写成另一个样子------结果一样,形状更顺手。这种变换有个特点:闭着眼改都不会更差,属于那种稳赚的操作。
- 物理优化,叫 CBO,靠算代价。 逻辑优化把形状定下来,这层负责挑具体干活的法子。这张表是全表扫还是走索引?两个表连一块用 Hash Join 还是 NestLoop?一个个估代价,挑最便宜的那条。
这么一比就清楚了。逻辑优化管的是写法,把查询树的形状捋顺;至于怎么落地、用哪种法子干活更省,那是物理优化的事,它得在候选里挑最便宜的。等价变换主要在逻辑优化这层折腾。但它改完还不算完,成果得递给 CBO 再过一遍代价。原因也简单:变换只敢打包票「方向没错」,至于这条路最后走不走,代价点不点头才算数。
二、等价变换:让 SQL「换一种写法」的代数规则
2.1 先得有合法根基:关系代数定律
那么问题来了,等价变换凭什么敢乱动你的 SQL,还不怕改错?它的靠山是关系代数。这是门专门研究关系运算的数学,里头有一大堆定律,每条定律都给出同一个承诺:照着它改,查出来的结果一行都不会少,一行都不会多。常用到的其实就那么几条,列在下面:
text
选择的级联: σ(c1 ∧ c2)(R) = σc1( σc2(R) ) ← 谓词能拆开
选择与连接交换: σc(R ⋈ S) = (σc(R)) ⋈ S 当 c 只涉及 R
连接交换律: R ⋈ S = S ⋈ R (内连接)
连接结合律: (R ⋈ S) ⋈ T = R ⋈ (S ⋈ T) ← 连接顺序能换
投影下推: πL( σc(R) ) = πL( σc( πL∪c(R) ) ) ← 只留用得上的列
看不懂这些符号别慌,抓住一个感觉就行。凡是那种「先过滤再连接」「小表先连、大表后连」「只挑用得上的几列」的操作,你回头一看,背后都站着一条定律给它撑腰。下面挨个说,先从最值钱的开始。
2.2 谓词下推:把过滤使劲往前挪
这条最值钱,也最符合直觉。直觉就是:数据越早被筛掉,后面每一步要搬的行就越少。
sql
-- 你写的:子查询里先过滤 amount,外层再过滤 region
EXPLAIN SELECT * FROM (
SELECT * FROM orders WHERE amount > 1000
) v WHERE v.region = 'EAST';
等价变换一上手,region = 'EAST' 就被拽过子查询的边界,跟 amount > 1000 凑到一块儿,一起下推到 orders 的扫描层:
text
变换前(概念): 变换后(实际计划):
Filter(region='EAST') Seq Scan on orders
└─ Filter(amount>1000) Filter: (region='EAST') AND (amount>1000)
└─ Scan orders
靠的就是上面那条「选择能跟连接、投影交换位置」的定律:只要过滤条件只用到某一侧的列,它就能越过投影、越过子查询边界,一路沉到数据源。视图、子查询、UNION 的各个分支,这套都管用。
2.3 外连接消除:认出「空值拒绝条件」
外连接(LEFT/RIGHT JOIN)比内连接费------它得给那些「没匹配上」的行补一串 NULL。可要是优化器发现,这些补出来的 NULL 行最后铁定会被过滤掉,那这外连接不就白干了?直接降级成内连接就好。判定的依据有个名字,叫空值拒绝条件(reject-NULL):只要 WHERE 里对内表(可空那一侧)的条件一为假,NULL 行就被挡掉了。
sql
-- b 是 LEFT JOIN 的内表;WHERE b.code = 'X' 对 b 来说是空值拒绝条件
EXPLAIN SELECT * FROM a LEFT JOIN b ON a.id = b.id WHERE b.code = 'X';
-- 计划里冒出来的是 Hash Join(内连接),而不是 Hash Left Join
道理很简单:那些补 NULL 的行,过 b.code = 'X' 这关必然被刷掉,所以「保留无匹配行」这层外连接语义在这条 SQL 里根本看不出效果------等于内连接,干掉它就省了补 NULL 和处理空行的开销。
不过这里有个老坑,条件写在哪,直接决定能不能消除:
sql
-- ❌ 写在 WHERE,又是空值拒绝 → 能消除成内连接
... LEFT JOIN b ON a.id=b.id WHERE b.code = 'X'
-- ✅ 写进 ON,或者长成 b.code IS NULL 这样 → 消不掉(外连接语义真生效了)
... LEFT JOIN b ON a.id=b.id AND b.code = 'X'
... LEFT JOIN b ON a.id=b.id WHERE b.code IS NULL
本该写在 ON 里的条件,你要是随手丢到 WHERE,可能「悄悄」把外连接变成了内连接,也可能反过来让优化器帮你消除------这正是迁移和改写 SQL 时最容易丢数据的地方。
2.4 子查询上拉与展开:让连接顺序能重排
子查询天生带层次,而层次会绊住物理优化的脚------藏在里头的表没法跟外面的表一起做连接顺序的动态规划。所以优化器会尽量把子查询、子链接上拉(pull up),拍平到跟外层同一层:
sql
EXPLAIN SELECT * FROM orders o
WHERE o.cust_id IN (SELECT cust_id FROM vip_customers);
-- IN 子链接上拉成半连接(Semi Join),vip_customers 就能参与连接顺序和算法的挑选了
EXISTS / NOT EXISTS / IN / ANY / ALL 这一类子链接,会被改写成半连接或反半连接。一旦拍平,物理优化就能在 orders 和 vip_customers 之间随便挑谁当外表、用 Hash 还是 NestLoop。这个过程受 from_collapse_limit 和 join_collapse_limit 两个参数管着------超了阈值就不展开了,免得规划时间爆掉。
2.5 等价谓词重写与条件化简
引擎对有些谓词处理得快(比如能蹭上 B-tree 范围扫描的),那就把等价的慢谓词改写成快谓词;同时借着等式、不等式的性质,把冗余的、甚至互相矛盾的条件给化简掉。
sql
EXPLAIN SELECT * FROM t WHERE name LIKE 'abc%'; -- 改写成范围扫描,走索引
EXPLAIN SELECT * FROM t WHERE id + 1 - 1 = 5; -- 常量折叠成 id = 5,能走索引
EXPLAIN SELECT * FROM t WHERE 1 = 0; -- 恒假 → Result 节点,直接吐空集
还有一类特别值钱的化简,来自等价类(传递闭包) :a.id = b.id AND b.id = 10 能推出 a.id = 10,于是原本只对 b 成立的 id = 10,被「传染」到 a 身上,a 这边也能下推了。
2.6 再进一步:规则插件能多干点啥
上面那些是通用规则。除此之外,KingbaseES 还靠 kdb_rbo 这个逻辑优化插件,多塞了一批更细的等价变换,专门去够那些通用规则够不着的地方:
sql
-- kingbase.conf 里把插件加载上
shared_preload_libraries = 'kdb_rbo'
挑几条有代表性的看看:
- count(distinct) 等价转换 :把单个
count(DISTINCT col)改写成GROUP BY加聚合的样子,这样就能蹭上哈希或并行,不用卡在单线程排序上。 - distinct 消除 :目标列本身要是唯一的(比如主键),
SELECT DISTINCT直接砍掉。 - 合并子查询公共表达式:好几个子查询里都有的那块相同表达式,只算一次,后面直接拿来用,省掉重复的表访问和连接。
- UNION 外层条件下推:把外层的连接条件等价地塞进 UNION 的各个分支,让每个分支都能提前筛一把。
- 不等式变换:对一堆不等式约束做等价重排,干掉冗余过滤,顺便腾出能用索引的空间。
说白了,它们的底子还是那句老话------结果不变,形式更优,只是触发条件更精细,每条都由对应的 GUC 开关单独把控。
三、等价变换的边界:啥时候不该动手
变换可不是越多越好。优化器给每条规则都设了「能不能用」的前提判断,越了界,要么出错,要么帮倒忙:
- 语义红线不能破 。外连接消除得看 reject-NULL 判定,子查询上拉得有「不改结果集」的等价证明撑着。前提一旦不成立------条件写在 ON 而不是 WHERE、带
IS NULL、相关子查询有副作用------变换必须立刻停手。 - 代价得复核。变换只担保「方向不亏」,可不敢拍胸脯说「最优」。举个例子,谓词下推之后,被推下去的条件要是选择率极低、本来就该先跑,那确实更优。可万一把原本挺顺的连接顺序给搅乱了呢?也不怕。CBO 在物理优化那层还会把代价重算一遍,变换递的是候选方案,最后到底用哪个,得 CBO 量完代价才点头。
- 规划成本得收敛 。表一多,麻烦就跟着来了。连接顺序的组合是爆炸式增长的,全穷举根本算不起。优化器给子查询展开设了两道闸,
from_collapse_limit和join_collapse_limit,超了就不展开。更狠的在后面:一旦参与的表超过geqo_threshold(默认 12 张),它干脆撂挑子,不搞精确的动态规划了,转头用遗传算法(GEQO)去做启发式搜索。快是真快,但结果不保证最优,有时还带点随机性,同一句话跑两回,计划都不一定一样。
text
表数量 连接顺序搜索策略 性质
≤ ~8 动态规划(DP),穷举左深树 精确
~8 ~ 12 DP + 子查询展开受限 精确,但被 collapse_limit 拴着
> 12 遗传算法 GEQO(启发式) 快,可能次优、结果不确定
也就是说,一条等价变换想真见效,得同时凑齐三件事:规则上允许、代价上更划算、规划开销还在能忍的范围里。
四、条件执行调度:执行器的底层逻辑
优化器交完差,剩下的是执行器的事:把那棵算子树真正跑出结果来。这一步看着简单,里头的门道全在一个「调」字上。先动哪个算子、后动哪个,哪些中间结果得提前存起来,要不要临时把人马拉齐了并行干------这些决定,绝大多数都不是规划阶段就钉死的,而是等真跑起来、碰上具体数据了,才临场定下来。这就是「条件执行调度」这个名字的来由。下面挑四块底层机制,一块一块拆。
4.1 Volcano 迭代器模型:按需拉取的流水线
你去看今天主流的行式数据库,执行器那一层,基本都是搭在一个叫「迭代器」的模型上,学名 Volcano。这模型最拧巴的地方在于:数据不是下层主动往上推的,而是上层什么时候需要了,自己下去「要」一行。每个算子对外就开三个口子,简单到让人觉得有点偷懒:
text
interface Node:
open() // 初始化
next() -> Tuple|null // 吐一行,或者说「没了」
close() // 收尾
翻成伪代码,差不多是这么个样子:
text
// 过滤算子:向下层要一行,合条件才往上交
Filter.next():
while true:
t = child.next()
if t == null: return null
if predicate(t): return t
// 嵌套循环连接:外表流式跑,内表先物化好再反复扫
NestLoop.open():
outer = left.open(); innerBuf = materialize(right)
NestLoop.next():
while outer != null:
for t in innerBuf:
if match(outer, t):
outer = left.next() // 外表往前挪一步
return combine(outer, t)
outer = left.next()
return null
这套「按需拉取」捎带出来一个挺实在的好处,业内管它叫流水线,pipeline。你想,要是整条路径上一溜排下去,全是那种「喂一行、吐一行」的算子------扫描、过滤,还有些连接也是这路货------那第一条结果几乎眨眼就出来了,根本犯不着等全部数据都算完。这对 LIMIT、对游标、对流式往外吐结果的场景,意义大了去了。你只想要头 10 行,引擎不至于傻到先把上千万行全塞进内存、再慢吞吞给你挑。
可凡事总有例外。有那么几种算子,天生就是堵在路上的「阻断点」。排序算一个,聚集算一个,Hash Join 建内表那一步也算一个。它们的共同毛病是:非得把输入从头啃到尾,才肯往外吐第一行。怎么把这些堵点提前认出来、尽可能往少了压,是写执行器那帮人成天琢磨的活儿。
4.2 物化 vs 流水线:啥时候该缓存
偶尔会碰上这么个局面:某个子计划被上层算子反复扫,一遍不够,得扫上好几遍。每扫一遍都从头算一次,那不亏大了?执行器的办法是往里塞一个 Material 节点,把子节点的结果先缓存住,往后谁要扫,直接读缓存就行:
sql
-- 三表连接里,要是 b 被反复当内表扫,计划里就会冒出 Material
EXPLAIN SELECT * FROM a, b, c
WHERE a.id=b.id AND b.id=c.id;
text
Nested Loop
├── ... (外表 a 相关)
└── Material ← 把 b 的结果存下来,免得反复扫
└─ Scan on b
到底塞不塞这个 Material,不是写死的,得算。优化器会估一下:反复重扫的代价,和占着内存不还的代价,哪个更大。只有当前者更亏,才插一个进去。背后有个开关 enable_material 管着它。说穿了就是拿 CPU 换内存,或者反过来,典型的看条件办事。
4.3 一次性过滤与短路:Result 节点
还有一类条件,算一遍就能拍板:这棵子树到底还跑不跑。执行器拿 Result 节点来兜这种活儿,行话叫「一次性过滤」(one-time filter)。
sql
EXPLAIN SELECT * FROM orders WHERE 1 = 0;
-- Result One-Time Filter: false ← 条件恒假,整棵子树根本不跑,直接吐空集
text
Result
One-Time Filter: false
└─ (本来该扫 orders 的子计划,被短路跳过了)
真要论「条件执行」的精髓,就得看这儿。条件要是假的,底下那一整棵子树连一行都不用碰,直接零成本放过去。只有条件为真,引擎才会真刀真枪下去扫一遍。所以碰上那种只需算一次的过滤------比如恒假条件,或者那种一次就能求完值的相关子查询判断------这条短路替你省下的扫描量,相当可观。
4.4 并行触发:代价门槛说了算
一条查询要不要拉起一队 worker 去并行跑,这事可不是拍脑袋定的,得让代价来说话。优化器这儿用的招数挺朴素:先把串行方案的花费估出来,再去估并行方案的花费,两个数摆一块儿比一比,哪个小就走哪个:
text
// 并行决策的简化逻辑
cost_serial = 估算(串行计划)
cost_parallel = parallel_setup_cost × workers // 启动 worker 的固定成本(默认 1000)
+ parallel_tuple_cost × 行数 / workers // 元组跨进程搬运的成本(默认 0.1)
+ 没法并行那部分的串行代价
if 表/索引大小 >= min_parallel_*_scan_size // 先过体量门槛(默认 8MB / 512KB)
and cost_parallel < cost_serial:
上并行;并行度 = clamp(估算值, 1, max_parallel_workers_per_gather)
else:
老老实实串行
有意思的是,同一张表,换个性过滤条件,计划能长得完全不一样:
sql
-- 低选择率(返回少):并行划算
EXPLAIN SELECT * FROM student WHERE age = 6;
-- Gather Workers Planned: 2
-- └─ Parallel Seq Scan on student Filter: (age = 6)
-- 高选择率(差不多返回一半):并行反而更慢,退回串行全表
EXPLAIN SELECT * FROM student WHERE age < 50;
-- Seq Scan on student
真跑起来并行的时候,Gather / Gather Merge 节点就是那个调度枢纽。它负责去申请 worker 进程,把活儿拆给大伙儿,各 worker 算完的结果走动态共享内存汇到 leader 手里。就连这次到底派几个 worker,也不是定死的------表多大、选了多少行、系统还有多少余粮,几个因素一块儿定。
4.5 控制流节点:Append、RecursiveUnion、位图运算
还有一类算子,专门管计划树里的「控制流」,它们调度的不是数据,而是「子计划按什么顺序、怎么组合着跑」:
- Append :一个接一个地跑多个子计划,伺候
UNION ALL和分区表------说白了就是「排队调度多个分支」。 - RecursiveUnion :搭着
WorkTable Scan一起实现WITH RECURSIVE,这是「迭代式调度」------翻来覆去跑工作集,直到再也生不出新行为止。 - BitmapAnd / BitmapOr:先让各子计划各自产出位图,再做个「与/或」运算,最后一锅端回表------把多个索引条件的执行顺序和组合方式,调度成一条位图路径。
这帮节点的共同点:它们自己不直接吐业务数据,管的是「子计划啥时候跑、怎么凑一块儿」,属于调度层的构造。
4.6 运行时形态的可控开关
最后补一句,几乎每个关键算子背后都蹲着一个 enable_* 开关(enable_indexscan、enable_hashjoin、enable_mergejoin、enable_material、enable_nestloop、enable_hashagg......)。它们干的事说白了就是让优化器在生成计划时「有条件地」把某一类物理实现纳入或排除------既能拿来排错(关掉某个算子,看计划怎么变),也是 HINT 干预的底层抓手。等 CBO 被不靠谱的统计信息带沟里去了,开发者就能用 HINT 强行指定连接顺序、连接方式、并行度这些:
sql
-- 估算不准的时候,人工下场干预连接顺序和算法
SELECT /*+ Leading(c b a) NestLoop(c b) Hashagg */ ...
五、主流数据库的设计取舍对比
搞懂了单库的机制,再来看「为什么不同数据库做了不一样的选择」。这事其实没啥标准答案,每家都是在自己的场景和成本里,挑了个能接受的折中。
5.1 优化器框架:自底向上 vs 自顶向下
先说优化器这套架子,大致分两派。
一派是自底向上的动态规划,鼻祖是 System-R 那套老思路。它从单表的路径开始,一层一层往上拼,靠动态规划把最优的连接顺序搜出来。好处是实在、好调,毛病是稍微复杂点就抓瞎------比如你想加几个能互相换的子查询重写,规则之间就容易打架,顾此失彼。
另一派是自顶向下,代表是 Cascades,SQL Server 还有不少新一代商用库走的是这条路。它用一种叫 Memo 的结构,把所有等价的表达式分门别类存成一个个 group,然后从顶往下搜,搜的过程中随时能剪枝。加新规则顺手,还能并行着搜,代价是这套框架本身比较沉,搭起来费劲。
所以你看,框架这东西,灵活和开销基本是绑一块的:越活,能玩的变换越多,可规划起来也越费劲、越难写。
5.2 RBO 和 CBO 的配比
很早以前的数据库,优化基本全靠规则,也就是 RBO。后来大家都往 CBO 上靠了,现在主流的姿势是 CBO 打主力、规则当帮手。规则管那种「稳赚不赔」的硬变换------谓词下推、列裁剪、外连接消除,这些;剩下那些「也许更好」的路径选择,全交给 CBO 去算。各家的差距,就差在这两头的深浅上:规则库有多厚,代价模型有多细。像多列统计、表达式统计、数据倾斜这些硬骨头,谁能啃得动、啃到什么程度,差距一下就拉开了。
5.3 执行模型:行式迭代器 vs 向量化批处理
这块分歧更明显。行式迭代器(就是上面说的 Volcano 那套)一次处理一行,控制流简单,头一行出得快,特别对 OLTP 的胃口------那种又短又密的查询,它能伺候得很好。但它也有个老毛病:每吐一行都得走一次虚函数调用,CPU 的 cache 吃不饱,向量化指令也用不上,纯靠堆频率。
向量化批处理走的是另一个极端,ClickHouse、DuckDB 这些分析型引擎是典型。它一次搂一大批,几百上千行紧凑地塞在列式结构里一起算。cache 命中率高,SIMD 也能用上,OLAP 那种大扫描大聚合,跑起来飞快。代价也不是没有:头一行出得慢,碰上高并发的点查就笨重了。
所以这俩谁好?看场景。要低延迟、伺候 OLTP,行式占优;要高吞吐、扛 OLAP,向量化是王者。新一代的系统有不少干脆两者都做,看查询类型自己切。
5.4 连接顺序搜索:精确 DP vs 启发式
表少的时候没争议,大家都老实用动态规划穷举。表一多,分歧就来了。有人咬死还要 DP,配上更狠的剪枝硬扛;有人直接换道,投奔遗传算法、模拟退火这些启发式玩法(GEQO 就是这一路)。前者更可能摸到真正的最优解,但规划时间跟着表的数量往上窜,窜得吓人;后者快得多,可结果不保险,有时候次优,甚至同一句话多跑几次计划都不一样。你可能也碰到过------某张表一多,执行计划就开始飘,原因多半就在这。
5.5 并行和代码生成
先说并行这件事怎么触发。单机行式库一般比较抠门,平时不并行,等代价门槛过了才「按需拉 worker」;分布式库和分析型库就大方多了,往往默认就重度并行,有的干脆走 MPP,数据按分片一切,天然就能并行起来。
再说代码生成,也就是 JIT。一条分析查询要是跑得很久,把里头的表达式和算子编译成原生机器码(比如借助 LLVM),解释执行那部分开销能砍掉一大块。可 JIT 本身也要花时间编译,查询要是很短,编译的功夫都够跑完好几回了,反而得不偿失。所以开不开 JIT,通常也得看这条查询值不值得------又是一个「按代价看着办」的活儿。
5.6 透明度和可干预度
再聪明的优化器,也总有看走眼的时候。这种时候,它给你留了多少干预的余地,就直接决定了你用得顺不顺手。
头一个看的是计划可见性,也就是 EXPLAIN 这套东西能让你看多细------代价估了多少、行数估计准不准、实际跑了多久,这些能不能摆出来给你看。
再一个就是 HINT。它管的是你能不能亲手摁住连接顺序、算法、并行度、聚合方式这些细节。Oracle 系的 HINT 家族是出了名的庞大;KingbaseES 这边也备了一整套,Leading、NestLoop、HashJoin、Hashagg、Parallel、Rows(还有管参数化路径的 PRows)、Set、Blockname,该有的都有。值得一提的是 blockname,它能让你跨着查询块去引用里头的子查询,干预的颗粒度可以做到挺细。
最后还有个不那么常见的,叫查询重映射。KingbaseES 里头有这么个 Query Mapping(靠 create_query_rule 来配),它能让你把一整类语句,整个映射成另一种等价的写法------比方说把外层条件下推到 UNION 里去。这相当于把「自定义一条等价变换规则」的权力,直接塞到了你手里。规则体系对外开放了一个口子,你想补什么规则,自己去登记就行。
结语
全篇拉通看,折腾来折腾去,其实就两拨人在干活。
优化器这拨,靠山是关系代数,干的活儿是趁你不留神,把 SQL 改写成跑起来更顺的样子。不过它有底线,不是想怎么改就怎么改。两条红线它不敢碰:语义上不能改出错,代价上还得过关。
执行器那拨就不同了,它的活儿全压在运行时。中间结果要不要先攒着,碰到恒假条件能不能直接短路,这趟查询要不要喊几个 worker 并行去跑------诸如此类,全得盯着当场碰上的数据临场决断。它手里能用的家伙,一是 pull 模型那套按需拉取,二是那一道道卡着的代价门槛。
编译期那边,账早被算得明明白白;等真到了运行期,就只剩见招拆招了。等你把这两拨人的脾气都摸透,再打开任何一条 EXPLAIN,上面那些东西就不再是天书,而是一堆你瞧得懂的取舍。