最近帮团队做异构数据库迁移,从 MySQL/PostgreSQL 迁到金仓 KES。业务逻辑没改,SQL 也没动,上线后却发现报表数据莫名其妙变少了。排查一圈下来,问题居然出在一个“看起来很合理”的 LEFT JOIN 写法上。本文把这次踩坑过程、原理分析和解决方案梳理出来,希望能帮大家少走弯路。

一、问题现场:LEFT JOIN 怎么就把数据“吞”了?

先还原一下当时的业务场景。

我们有两张表:

  • t1:订单主表(左表,驱动表)

  • t2:订单扩展信息表(右表)

业务需求很简单:查出所有订单,如果扩展信息表中存在 name2 = 'cc'的记录,就一并展示;如果没有,扩展字段显示 NULL。

当时开发同学写的 SQL 如下(为了方便阅读,做了简化):

SELECT *
FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t2.name2 = 'cc';

在原来的 MySQL / PostgreSQL 环境中,这条 SQL 的行为是“符合直觉”的:

  • t1的所有订单都会返回;

  • 如果 t2中有匹配且 name2 = 'cc',则显示对应字段;

  • 否则,t2相关字段为 NULL。

然而,迁移到 金仓 KES​ 后,诡异的事情发生了:

结果集中,大量原本应该出现的 t1记录消失了

第一反应是:是不是数据没同步全?是不是有触发器没迁过来?查了一圈,数据没问题。

于是把执行计划拉出来一看,真相浮出水面:

-> Hash Join (cost=...)
     Hash Cond: (t1.id1 = t2.id2)
     -> Seq Scan on t1
     -> Hash
         -> Seq Scan on t2
             Filter: (name2 = 'cc')

注意到了吗?计划中并没有出现 Left Join,而是直接变成了 Hash Join(内连接)

也就是说,KES 的优化器在这里做了一个等价变换:外连接消除(Outer Join Elimination)

二、为什么会这样?Nullable-Side 条件的“隐式强约束”

要理解这个问题,得先回到 SQL 的执行语义上。

1. SQL 的逻辑执行顺序

在标准 SQL 中,一条带 JOIN 的查询,逻辑上的执行顺序是:

  1. FROM / JOIN:先做连接,生成中间结果集

  2. WHERE:对中间结果集进行过滤

  3. SELECT:投影需要的列

  4. ORDER BY / LIMIT 等

对于 LEFT JOIN来说:

  • 如果右表(t2)没有匹配行,那么 t2的所有列都会被填充为 NULL

  • 这些“补出来的 NULL 行”,依然属于 LEFT JOIN的结果集。

2. Nullable-Side 条件带来的“副作用”

现在看我们的 SQL:

WHERE t2.name2 = 'cc';

这里的 t2.name2属于 Nullable-Side(右表的非主键列)。

考虑两种情况:

  • 情况 A:t2有匹配行,且 name2 = 'cc'→ 条件成立,保留;

  • 情况 B:t2没有匹配行 → t2.name2NULLNULL = 'cc'的结果是 Unknown该记录被 WHERE 过滤掉

也就是说:

所有由 LEFT JOIN 产生的 NULL 行,都会被这个 WHERE 条件无情地干掉。

3. 优化器的“等价变换”逻辑

站在优化器的角度看问题:

  • 既然 WHERE t2.name2 = 'cc'会把所有右表为 NULL 的行全部过滤掉;

  • 那么,“LEFT JOIN + WHERE 过滤”的最终结果,在数学逻辑上完全等价于“INNER JOIN + WHERE 过滤”。

为了降低执行代价,优化器就会选择:

把 LEFT JOIN 改写成 INNER JOIN(外连接消除)

这是一个完全合法、符合 SQL 标准的优化行为,但在业务语义上,却往往不是开发人员想要的。

三、什么时候不会消除?IS NULL 是个例外

并不是所有涉及右表的条件都会触发外连接消除。

一个非常典型的反例是 IS NULL

SELECT *
FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t2.name2 IS NULL;

这条 SQL 的业务语义是:找出在 t2 中没有匹配记录的 t1 行

如果优化器把它转成内连接,那这些“缺失匹配”的记录根本不可能出现在结果集中,逻辑就完全错了。

因此,在 KES 中,这类包含 Nullable-Side IS NULL的查询:

  • 不会触发外连接消除

  • 执行计划中依然会保留 Left Join的特征。

这也是为什么很多“找差集”的 SQL(比如“查找未绑定扩展信息的订单”)在迁移过程中反而表现稳定的原因。

四、Oracle (+)语法下的坑(KES 兼容模式)

顺带一提,我们在迁移过程中还遇到了 Oracle 风格外连接的写法。

KES 支持 Oracle 的 (+)语法,但坑也不少。

1. 错误示例

SELECT *
FROM t1, t2
WHERE t1.id1 = t2.id2(+)
  AND t2.name2 = 'cc';

这里 (+)只出现在 JOIN 条件上,而 t2.name2 = 'cc'是普通 WHERE 条件。

效果和前面 ANSI JOIN 的例子一模一样:外连接被消除,数据“丢失”。

2. 正确写法

如果确实想保留左表全部数据,同时过滤右表,需要把过滤条件也标记为外连接侧:

SELECT *
FROM t1, t2
WHERE t1.id1 = t2.id2(+)
  AND t2.name2(+) = 'cc';

这种写法在语义上等同于:

SELECT *
FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2 AND t2.name2 = 'cc';

不过,从可读性和可维护性角度,我更推荐直接使用 ANSI JOIN 语法,避免 (+)这种“上古语法”带来的心智负担。

五、Non-Nullable-Side 条件:左表条件的影响

还有一种容易混淆的情况:条件写在左表上。

SELECT *
FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t1.name1 = 'a';

这里 t1.name1属于 NonNullable-Side

这个条件的语义是:

  • 先筛选出 t1.name1 = 'a'的记录;

  • 再对这些记录做 LEFT JOIN。

结果是:不符合 t1.name1 = 'a'的左表记录被过滤掉,这是符合预期的。

但如果把条件写到 ON子句中:

SELECT *
FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2 AND t1.name1 = 'a';

语义就变成了:

  • t1所有记录都参与外连接;

  • 只有 t1.name1 = 'a'的行才会尝试匹配 t2

  • 其他 t1行仍然会出现在结果集中,只是 t2字段为 NULL。

一句话总结:

  • WHERE是对最终结果集的过滤;

  • ON是对连接行为的控制。

这个原则在 KES 中同样适用,也是排查外连接行为异常的重要切入点。

六、迁移实战中的排查套路

在这次迁移项目中,我们总结出了一套比较实用的排查流程,供大家参考。

1. 先看执行计划

在 KES 中,可以用:

EXPLAIN (ANALYZE, VERBOSE)
SELECT ...

重点关注:

  • JOIN 类型是否还是 Left Join

  • 有没有变成 Hash Join/ Nested Loop但没有标明 Left 语义;

  • 过滤条件被下推到了哪一步。

如果发现原本期望的外连接在计划中“消失”,就要警惕外连接消除了。

2. 明确业务语义

拿到一条 SQL,先问自己三个问题:

  1. 是否必须保留左表全部记录?

  2. 过滤条件到底是限制左表,还是限制右表?

  3. 是否允许右表为 NULL?

如果答案是“必须保留左表 + 过滤右表”,那么:

  • 右表的过滤条件一定要放到 ON子句中

3. 改写 SQL 的正确姿势

回到最开始的例子,正确的写法应该是:

SELECT *
FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
            AND t2.name2 = 'cc';

或者,如果逻辑上更清晰,也可以写成子查询形式(视执行计划而定):

SELECT *
FROM t1
LEFT JOIN (
    SELECT * FROM t2 WHERE name2 = 'cc'
) t2_filtered
ON t1.id1 = t2_filtered.id2;

在实际测试中,这两种写法在 KES 中都能稳定保持外连接语义,不会被优化器“偷偷”转成内连接。

七、性能与一致性的取舍

有同学可能会问:

外连接消除不是一种性能优化吗?为什么要抵制它?

这是一个非常好的问题。

在纯性能视角下,外连接消除确实是好事:

  • 内连接可以选择更丰富的连接算法;

  • 可以减少中间结果集大小;

  • 在很多 OLTP 场景下能显著降低延迟。

但在 数据库迁移场景​ 中,优先级是不一样的:

  1. 第一优先级:逻辑一致性

    • 报表数字不能错;

    • 业务行为不能变;

    • 迁移前后结果必须一致。

  2. 第二优先级:性能

    • 在逻辑正确的前提下再做调优;

    • 必要时可以通过 hint 或 SQL 改写控制执行计划。

因此,在 KES 迁移项目中,我的建议是:

  • 默认假设优化器会做外连接消除;

  • 显式写出你想要的语义(条件放 ON);

  • 再通过执行计划验证,而不是“赌优化器不动它”。

八、总结与避坑清单

这次踩坑,本质上是一次“优化器聪明过头”的典型案例。

为了避免大家重蹈覆辙,我把关键点整理成一个简短的避坑清单:

  1. LEFT JOIN + 右表 WHERE 条件 = 高风险

    • 除非你明确只想查匹配成功的记录;

    • 否则请把右表过滤条件挪到 ON子句。

  2. 执行计划是第一真相来源

    • 不要只看 SQL 文本;

    • 要看计划中 JOIN 的类型是否发生变化。

  3. ON vs WHERE,语义截然不同

    • ON:控制连接行为;

    • WHERE:控制最终结果。

  4. Oracle (+)语法要慎用

    • 过滤条件是否带 (+),结果可能完全不同;

    • 迁移时优先考虑改成 ANSI JOIN。

  5. 迁移阶段以一致性为先

    • 性能可以慢慢调;

    • 数据错误是事故。

写在最后

数据库迁移从来不是“语法替换”这么简单,优化器行为的差异往往是最大的隐形雷区。

这次在金仓 KES 上遇到的外连接消除问题,其实在其他数据库中也有类似机制,只是在 KES 的优化策略下表现得更加“积极”。理解其背后的 Nullable-Side 逻辑,不仅能帮你避开迁移坑,也能让你在日常 SQL 编写中写出更健壮、语义更清晰的查询。

如果你也在做 MySQL / PostgreSQL 到 KES 的迁移,欢迎在评论区聊聊你遇到的奇葩问题,我们一起填坑。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐