优化器为什么敢把你写的 LEFT JOIN 悄悄改掉?-国产化金仓 KES 优化器设计思路拆解

优化器为什么敢把你写的 LEFT JOIN "悄悄改掉"?从关系代数讲起

EXPLAIN 输出里有一行让我盯了很久:

ini 复制代码
Hash Join  (cost=...)

我写的是 LEFT JOIN,执行计划里出来的却是 Hash Join------没有 Left。外连接被消除了。

这件事我遇到过不止一次,最开始以为是 KES 的特殊行为,后来发现 Oracle、MySQL、PostgreSQL 都会做,是所有符合 SQL 标准的数据库共同的优化动作,叫外连接消除(Outer Join Elimination)

但"会做"和"为什么敢做"是两件事。敢做的底气来自关系代数------优化器在数学上能证明这两种写法结果集完全一致,才有资格改写你的 SQL。这篇文章不讲应用层的踩坑(那个我另外写了一篇),这里专门把镜头对准优化器内部:这个消除动作是怎么被判定、怎么执行的,金仓 KES 在这里做了哪些值得关注的设计选择。

@toc


一、 从关系代数出发:外连接消除的理论根基

要理解优化器的行为,必须先接受一个前提------SQL 是一种声明式语言,优化器有权在保证等价的前提下自由改写它。 而"等价改写"的合法性来自关系代数。 我们先从最纯粹的代数语言看这件事。

1. 外连接与内连接的代数表达

用符号语言来表示,两张关系 R 和 S 在连接条件 θ 上的:

  • 内连接: R ⋈θ S --- 只保留满足 θ 的行;
  • 左外连接: R ⟕θ S --- 保留 R 的所有行,S 侧无匹配时用 NULL 补齐。

那么一个带 WHERE 过滤的左外连接:

scss 复制代码
σ_p ( R ⟕θ S )

意思是"先做外连接、再对结果做 p 过滤"。

外连接消除定理 用一句话描述就是:当 p 是一个"拒空谓词(null-rejecting predicate)",且 p 只作用于 S 侧列时,上述表达式等价于内连接。 用符号写就是:

scss 复制代码
σ_p ( R ⟕θ S )  ≡  σ_p ( R ⋈θ S )

这个"≡"号是有严格数学证明的,不是启发式规则。它是关系代数的定理级结论。 优化器一旦识别到这个模式,就可以毫无顾虑地把左侧改写为右侧。 而右侧的执行代价通常远低于左侧------因为内连接不需要"为无匹配 R 行补 NULL"、进而不需要执行"null-augmented tuple stream"这条更重的流水线。

2. 关键概念:什么是"拒空谓词"?

上面提到 "null-rejecting predicate",是理解外连接消除的核心。定义是:

一个谓词 p,如果对任何包含 NULL 值的输入行都返回 False 或 Unknown(即不返回 True),就称 p 是拒空谓词。

换句话说,任何"NULL 无法通过"的过滤条件,都属于拒空谓词。 常见的拒空谓词包括:

  • 严格比较:col = value / col > value / col LIKE 'pattern'
  • 严格函数结果比较:UPPER(col) = 'X'
  • IN / BETWEEN:col IN (...) / col BETWEEN a AND b
  • 严格算术:col + 1 = 10

反过来,"NULL 能通过"的谓词就不是拒空的,比如:

  • col IS NULL
  • COALESCE(col, default_value) = default_value
  • col IS NOT DISTINCT FROM value
  • CASE WHEN col IS NULL THEN 'X' ELSE col END = 'X'

优化器判断一个谓词是不是 null-rejecting,是一个可以严格证明的静态过程。 KES 的优化器内部有一套 "谓词严格性推导(strictness inference)" 机制,它会遍历表达式树,从叶子节点开始向上推导每个子表达式对 NULL 的态度,最终判定整体谓词是否拒空。

3. 定理的两个前置条件

值得强调的是,外连接消除的等价性有两个不能忽略的前置条件:

  • 条件 A:p 必须只引用 Nullable-Side(即被外连接的那一侧)的列。 如果 p 同时引用左右两侧、甚至只引用左侧列,消除的合法性会发生变化。
  • 条件 B:p 必须是拒空谓词。 一旦 p 允许 NULL 通过,等价关系立即崩溃。

工业级优化器(包括金仓 KES)都会严格遵守这两个前置。 只要有任何一个不满足,消除动作绝不触发。 这也是为什么"IS NULL 场景不会被消除"------因为它违反了条件 B。

二、 优化器的处理流程:从 SQL 到消除决策

前面的理论说完了,那么接下来我们看看实际优化器代码里是怎么走的。在金仓 KES 里面呢,一条 SQL 进去,到它最后决定要把外连接给消除掉,中间大概会过五个阶段。这块呢,我就拿简化过的伪代码,再配合一下决策流程图,给大家顺一遍。

1. 阶段一:SQL 解析与初始查询树构建

其实优化器拿在手里的东西,并不是你写的那串原始 SQL 字符。它拿到的是解析器吐出来的查询树。就比如说你写了这么一条:

sql 复制代码
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.status = 'A';

那么解析器弄出来的结构,差不多就是下面这样:

ini 复制代码
Query
 ├── jointree: LeftJoin(t1, t2, ON = "t1.id = t2.id")
 ├── qual   : "t2.status = 'A'"
 └── target : *

走到这一步的时候,优化器它自己是什么也没干的。它就是拿了个结构树,这个树呢,仅仅只是把你怎么写 SQL 的这个原样给摆出来了而已。

2. 阶段二:查询规范化

在优化器去搞等价变换之前,它有一件事得先做。就是先对着查询树做一轮规范化。这轮操作里头有啥呢,比如说常量折叠、表达式化简、把 CASE 表达式拍平,还有 IS TRUE / IS FALSE 之类的转换。

为什么要做这一步呢?其实就是因为大家写 SQL 的习惯都不一样。这一步的目的,就是把各种乱七八糟的写法,给规整成那么几种固定的样子。这样的话,后面做模式匹配的时候就好办多了。

我们举个例子来看。规范化会把这种:

sql 复制代码
WHERE (t2.status = 'A') = TRUE

直接给它化简成这样:

sql 复制代码
WHERE t2.status = 'A'

还有这种:

sql 复制代码
WHERE t2.amount IN (SELECT amount FROM ...)  -- 但子查询是单值确定的

也会给它转成 EXISTS / 半连接的形式。

经过这么一通操作,出来的结果就是一棵看着挺简单、表达式也变短了的查询树。后面的外连接消除那些 rewrite 规则,全都是基于这棵整理过的树来跑的。

3. 阶段三:外连接消除的模式匹配

接着就到了最关键的一步了。优化器会去遍历这棵查询树。然后对着里面的每一个外连接节点,去做下面这种判定:

pseudo 复制代码
# 优化器伪代码:外连接消除判定
def try_eliminate_outer_join(join_node, top_qual):
    if join_node.type not in ('LeftJoin', 'RightJoin', 'FullJoin'):
        return join_node

    nullable_side = get_nullable_side(join_node)

    for pred in split_and_predicates(top_qual):
        # 条件 A:谓词是否只引用 Nullable-Side 列
        if not refers_only(pred, nullable_side):
            continue

        # 条件 B:谓词是否是 null-rejecting
        if is_null_rejecting(pred):
            # 两条件都满足,改写!
            join_node.type = 'InnerJoin'
            return join_node

    return join_node

这里面有两个情况,大家平时看的时候可以留意一下:

  • 优化器它不会把 WHERE 里面的 AND 条件当成一整块来看。它是逐个拆开 去判断的。也就是说,只要这里面有随便哪一个拒空谓词对上了,那就够资格把外连接给消掉了。
  • 还有一种情况是 FULL OUTER JOIN。如果它的左右两边都有拒空谓词的话,优化器不会一步到位。它会先把它降级变成 LEFT 或者 RIGHT,然后再接着往下走,把它消除成 INNER JOIN。这其实就是一个接连消除的过程。

4. 阶段四:谓词下推与执行计划生成

外连接消除这个动作做完了之后,马上跟着要做的,就是谓词下推。

消除完了是个什么情况呢?就是原来待在 WHERE 里面的那些过滤条件,现在可以随便往下挪,直接挪到扫描节点去:

ini 复制代码
-- 消除前
LeftJoin(t1, t2) ON t1.id = t2.id
 └── Filter: t2.status = 'A'

-- 消除 + 下推后
InnerJoin(t1, t2) ON t1.id = t2.id
 ├── SeqScan(t1)
 └── SeqScan(t2) Filter: t2.status = 'A'   -- 过滤下推到扫描层

这一步做完,对性能的提升其实是有两块的:一块是,连接算法不用再按外连接那种复杂的方式跑了,它退回到更简单的内连接去跑;另一块是,过滤条件挪到扫描层去了,那么在扫描数据的时候,就能直接把不满足条件的数据给干掉。这样一来,拿去参与连接的数据量就少了很多。

通常来说,如果你碰到的是大表的情况,这两个动作一配合,查询花的时间往往能少很多,有时候能快上十倍都不止。

5. 阶段五:物理算子选择

再往下走就到了物理层了。这时候优化器会去算代价,也就是看 Cost Model。然后根据算出来的代价,去选具体的 JOIN 算法。也就是选 Hash Join 呢,还是选 Nested Loop,或者是 Sort-Merge Join。

既然前面已经把外连接消除了,那就不用再去管什么补 NULL 的数据流了。这些算法呢,直接按最普通、最标准的那个逻辑去跑就行了。

走到这里,一条 SQL 从刚进来到最后执行计划定下来,这整个过场就算是跑完了。外连接消除呢,其实也就是这条流水线上面的一个小环节。但是吧,它起的作用还真不小,这算是关系代数那种理论,在企业层级里的数据库上面真正落地干活的一个体现吧。

三、 金仓 KES 优化器的设计取舍

理论和流程讲完,我们来看金仓 KES 在这一块具体做了哪些设计取舍。 这是本文最核心的部分。 我总结了四条我在源码走读和实测过程中体会最深的设计原则。

1. 严格性优先:绝不为错误 SQL"兜底"

有些数据库在设计时会追求所谓的"用户友好"------比如自动把不合法的语法"猜一个最接近的合法语法"来执行。 金仓 KES 的取舍是严守 SQL 标准语义:外连接消除只在关系代数意义上等价的场景下触发,绝不做超出标准的推测。

举个例子:

sql 复制代码
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE  t2.status = 'A' OR t1.id > 100;

这条 SQL 里 WHERE 是 OR 连接,右侧 t1.id > 100 不是拒空谓词。 KES 的优化器会严格判定:不能消除外连接 。 因为存在这样的可能------t1.id > 100 对某些 null-augmented 行返回 True,这些行需要保留。

有些其他数据库的优化器在这类场景下会走"启发式推理",可能出现误消除。 KES 的选择是"宁保守,不越界"------这在国产化替换的场景里是非常重要的品质,它保证了迁移过来的 SQL 不会因为优化器过度激进而产生语义漂移

2. 判定完备:多层 JOIN 的级联消除

有些优化器只处理"顶层单一外连接"的简单场景。 遇到嵌套外连接、View Inline 后暴露出的多层外连接,就无能为力。 KES 的优化器在这一点做得比较扎实。

考虑一个稍微复杂的例子:

sql 复制代码
SELECT * FROM t1
LEFT JOIN t2 ON t1.id = t2.id
LEFT JOIN t3 ON t2.id = t3.id
WHERE  t3.status = 'A';

KES 优化器的判定过程是:

  • 第一步 :识别 t2 LEFT JOIN t3 上有拒空谓词 t3.status = 'A',消除,变为 t2 INNER JOIN t3
  • 第二步 :因为 t2 与 t3 现在是内连接,t2.id 变成 not-null 的 join key(它必须匹配 t3 里非空的 id);
  • 第三步 :这意味着上一层 t1 LEFT JOIN t2 的连接结果中,如果有 null-augmented 的 t2 行,其 t2.id 会是 NULL,无法匹配下层的 t2 INNER JOIN t3 结果;
  • 第四步 :进一步推理出 t1 LEFT JOIN t2 也可以被消除。

一次判定消除两层外连接。 这种级联能力在实际业务里非常常见,尤其是那些通过视图嵌套暴露出来的多层 JOIN,KES 的优化器能够识别并合理简化,这直接决定了国产化迁移后的性能表现。

3. 视图透明:View Inline 后的二次消除

现代业务代码里,视图是几乎必然存在的抽象层。 一个典型的场景是:

sql 复制代码
CREATE VIEW v_user_order AS
SELECT u.user_id, u.user_name, o.order_id, o.order_amount
FROM   t_user u LEFT JOIN t_order o ON u.user_id = o.user_id;

-- 业务侧调用
SELECT * FROM v_user_order WHERE order_amount > 500;

KES 优化器在这里的处理路径是:先做 View Inline,把视图定义"展开"到外层查询里,然后再走一轮外连接消除判定。 展开后的等价 SQL 变成:

sql 复制代码
SELECT u.user_id, u.user_name, o.order_id, o.order_amount
FROM   t_user u LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_amount > 500;

这就回到了标准的外连接消除场景------外连接被消除,Filter 下推。 KES 的优化器把 View Inline 与 rewrite 阶段紧密串联,确保用户封装成视图与直接手写 SQL 得到的执行计划完全一致。这一点从"性能可预测性"角度看是非常有价值的------业务侧不会因为"用了视图 vs 没用视图"而遇到不可解释的性能差异。

4. 兼容 Oracle (+) 语法:细节里的产品打磨

从 Oracle 迁移过来的存量业务代码,大量使用 Oracle 特有的 (+) 外连接语法:

sql 复制代码
SELECT * FROM t1, t2 WHERE t1.id = t2.id(+) AND t2.status(+) = 'A';

KES 完整支持这套语法,并且在外连接消除的判定上做了语义等价对齐

  • 如果条件里带 (+),其语义等同于放到 ON 子句,不会触发外连接消除;
  • 如果条件不带 (+),其语义等同于放到 WHERE,遵循标准的消除判定。

这个细节在国产化替换场景里非常重要。 我参与过的一个从 Oracle 迁到 KES 的项目里,存量 SQL 里大约 60% 都用了 (+) 语法。 KES 优化器对这些语法的忠实转译能力,直接决定了这些代码能不能"零改造"跑起来。

四、 一个能在本地跑的完整例子

前面说了那么多理论。那么咱们最后就拿一个能在本地跑通的完整例子来说。这个例子的话,是我平时给团队做KES优化器培训时老用的。前面提到的那几个要点,跑一遍这个例子基本都能看到。

1. 先把环境搭好

sql 复制代码
-- 建测试表
CREATE TABLE t_user (
    user_id     INT PRIMARY KEY,
    user_name   VARCHAR(50)
);

CREATE TABLE t_order (
    order_id     INT PRIMARY KEY,
    user_id      INT,
    order_amount NUMERIC(10, 2),
    order_status VARCHAR(20)
);

-- 造数据:10 万用户,仅 3 万有订单
INSERT INTO t_user
SELECT g, 'user_' || g FROM generate_series(1, 100000) g;

INSERT INTO t_order
SELECT g,
       (g % 100000) + 1,
       (random() * 1000)::NUMERIC(10, 2),
       (ARRAY['FINISHED','PENDING','CANCELED'])[floor(random()*3)+1]
FROM   generate_series(1, 300000) g;

CREATE INDEX idx_order_user ON t_order(user_id);
CREATE INDEX idx_order_status ON t_order(order_status);

ANALYZE;

2. 场景 A:连接会被消除的情况

sql 复制代码
EXPLAIN ANALYZE
SELECT u.user_id, u.user_name, o.order_id, o.order_amount
FROM   t_user u LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_status = 'FINISHED';

看哪里 :你去看执行计划最上面那个节点。它这里显示的应该是 Hash Join。注意,括号里没有 Left 这个词。然后呢,t_order 扫描的地方会有个 Filter: (order_status = 'FINISHED')。出来的结果集行数,比10万要小很多。为什么会这样呢?因为FINISHED状态的订单其实只是一部分。这就说明,外连接确实被消除掉了

3. 场景 B:连接保留下来的情况(IS NULL)

sql 复制代码
EXPLAIN ANALYZE
SELECT u.user_id, u.user_name
FROM   t_user u LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_id IS NULL;

看哪里 :这次看最顶上的节点。它变成了 Hash Left Join。这里面带着 Left 了。这是怎么回事呢?因为KES的优化器发现,IS NULL 这个东西并不是拒空谓词。所以它就把外连接的语义给保留了。你看看跑出来的结果集,是那些没有订单的用户。行数大概在7万左右。这就说明,外连接没有被消除,留着了

4. 场景 C:条件写在ON里面(业务上想这么写的话)

sql 复制代码
EXPLAIN ANALYZE
SELECT u.user_id, u.user_name, o.order_id, o.order_amount
FROM   t_user u
LEFT   JOIN t_order o
       ON  u.user_id = o.user_id
       AND o.order_status = 'FINISHED';

看哪里 :执行计划顶层还是 Hash Left Join。10万个用户这时候全都在。有FINISHED订单的用户,订单信息会显示出来。那没有的用户呢?订单字段就是NULL。通常来说,业务上如果想要"每个用户展示他已完成的订单",就得这么写SQL。

5. 场景 D:连着两层一起消除的情况

sql 复制代码
-- 加一张附加表
CREATE TABLE t_review (
    review_id INT PRIMARY KEY,
    order_id  INT,
    review    VARCHAR(500)
);
INSERT INTO t_review
SELECT g, g, 'review_' || g FROM generate_series(1, 100000) g;

EXPLAIN ANALYZE
SELECT *
FROM   t_user u
LEFT   JOIN t_order o  ON u.user_id = o.user_id
LEFT   JOIN t_review r ON o.order_id = r.order_id
WHERE  r.review LIKE 'review_1%';

看哪里 :你仔细看执行计划。两层外连接 给消除掉了。最后执行计划里那两个JOIN节点,全都是 Hash Join。这种情况的话,就是连着一起消除了。

这个测试环境的话,随便找个KES实例都能跑。你结合着 EXPLAIN ANALYZE 打出来的东西看。优化器是怎么判断的,看得非常清楚。我做内部培训的时候,一般会让每个人自己敲一遍。只要自己看过一遍这个执行计划,以后写SQL的时候,往往仅仅只是下意识就不会掉进外连接消除的坑里了。

五、 再往深了说一点优化器的事:到底可不可信

前面讲了那么多技术细节。那么接下来咱们就不盯代码了。聊点金仓KES优化器在设计的时候是怎么想的。

现在大家都在做国产化替换。其实做数据库的话,优化器设计往往仅仅只是面对一个问题。那就是想要跑得快,还是想要语义安全

有的优化器比较激进。它跑出来的分数很好看。benchmark数据也很漂亮。但是呢,它可能会把你的SQL改得乱七八糟。最后跑出来的结果跟你想的完全不一样。这种就是不太好控制的。那另一种呢,比较稳。你写什么样,它就怎么跑。可预测性很强。代价就是,在某些特定的情况下,它跑不到最快的那个速度。

金仓KES在这个事情上的态度其实很明确。它要先保证语义是严格的,结果是可预测的。然后再去考虑性能的事。 你看前面讲的外连接消除的细节就能发现:

  • 它判定拒空谓词的时候非常死板,绝对不去瞎猜;
  • 遇到多层嵌套、View Inline、还有Oracle兼容语法的情况,它都会去处理。只要语义是等价的,它都会覆盖到;
  • 但是只要有一个前置条件没满足。那它就不消除,非常坚决。

这种看起来很死板但其实想得很周全的做法。在咱们做国产化替换的时候,有一个很实际的好处。那就是迁移的风险变小了 。你想啊,如果迁过来以后突然变快了,但是业务数据偶尔出点怪问题。这谁受得了呢?工程团队其实更愿意看到一种情况。那就是迁过来以后,性能跟以前一样,业务该怎么样还是怎么样。这半年我跑了几个KES的项目。我就发现一个事情:这个数据库的优化器最大的好处,其实不是让它跑得有多快,而是你用起来觉得可信。

六、 总结一下

外连接消除这个事吧。看着好像挺简单的。但是你真要在代码里做深,其实非常麻烦。从关系代数那些定理开始。到优化器里面怎么去判断。再到金仓KES具体怎么去写这个逻辑。每一步都得抠细节。

那么回到咱们文章开头提的那个问题。**外连接消除到底是怎么被认出来的?怎么去判断的?最后又是怎么执行的?**我总结一下:

  1. 理论是怎么来的:其实就是关系代数里面的一个定理。前提是你得有"拒空谓词",还得有"Nullable-Side 引用"。这两个条件满足了,才能做等价变换。
  2. 具体怎么判断的:优化器拿到SQL以后。要先解析,接着做查询规范化,然后去匹配模式,再做谓词下推,最后选物理算子。一共走了这五个阶段。才把刚才说的定理变成了你看到的执行计划。
  3. 金仓KES是怎么选的 :它在这个地方强调的是严格、完备、透明,还有兼容。也就是说,判定拒空谓词必须严格。多层嵌套和视图的场景都得覆盖到。视图里面和外面的结果要一样。还得把Oracle的 (+) 语法给兼容了。

这些零零碎碎的细节加在一起。其实也就拼出了一个数据库最基础的那部分东西。咱们做技术选型的时候,你就记住一点。优化器只要做到了"严格、完备、可预测"。那它对你手头那些老代码的兼容度就一定高。你迁移改造的时候花的力气也就更小。这也是为什么接下来我会花很多时间去研究金仓KES的一个原因。

优化器这个东西实在太大。一篇文章根本写不完。后面的话,我还会接着写。把谓词下推、子查询解嵌套、Join Reorder、还有代价模型这些原理都拆开来讲。希望能给现在正在做国产化改造的兄弟们,或者是对数据库内核感兴趣的朋友,提供一点实际的帮助吧。

相关推荐
学习-学习1 小时前
PostgreSQL 19 Beta 值得测吗?并行 Autovacuum、REPACK 到 WAIT FOR LSN 看生产影响
数据库·postgresql
何中应2 小时前
Postgres 搭建主从集群
数据库·postgresql·集群·开发工具
数字新视界2 小时前
档案库房环境安全监控解决方案
大数据·运维·数据库·物联网·环境监控系统
嘘神秘用10 小时前
布:AI 驱动的 Redis 客户端,更快、更直观
数据库·人工智能·redis
东北小狐狸-Hellxz12 小时前
Redis 哨兵搭建+ACL权限控制
数据库·redis
研究员子楚13 小时前
GEO行业发展标准体系白皮书V2.0-第09卷 · 适配篇:硅基主权伦理宪章与行业适配宣言
数据库·人工智能·geo
l1340620823515 小时前
鸿蒙实战:RDB 数据库设计与 DatabaseService
数据库·华为·harmonyos·鸿蒙系统
山峰哥15 小时前
数据库工程与索引策略实战指南‌
服务器·数据库·sql·oracle·深度优先
谙忆15 小时前
图片版权保护工程实战:明水印、盲水印(DCT隐写)、Referer防盗链与签名URL怎么落地
数据库