报表少了 40%:那条从没被质疑过的 LEFT JOIN,在迁 KES 时终于露馅了
上周末接到一个求助,运营那边在群里炸了锅。事情是这样的:“用户报表今天的数据总数,比昨天少了快 40%,怎么回事?”
当时的第一反应是,哪个定时任务没跑成功。翻了半天日志,什么异常都没看到。接着去查那条核心统计 SQL,语法没问题,执行也不报错。在本地测试环境里跑,结果也是对的。但生产环境的数据,实打实地少了。
折腾了两个小时后,才找到问题的根源。说到底,就是一条 LEFT JOIN 的写法出了岔子,触发了优化器的外连接消除,直接把一部分数据过滤掉了,而且全程没有任何提示。更让人无奈的是,这条 SQL 在原来的 MySQL 里也是这么跑的,只不过以前从没人认真对过账,这个 Bug 就一直潜伏着。
这种情况,在最近这段时间做 MySQL 和 PostgreSQL 向 KES 迁移的项目里,可以说是高频出现的“隐形杀手”。典型的症状就是:SQL 语法能跑通,执行不报错,测试结果也没问题,但业务那边的最终数据,就是少了点东西。 下面就把 LEFT JOIN 这个坑从头到尾拆解清楚,顺便整理一份实用的避坑清单,下次碰到类似问题,可以少走很多弯路。

一、 事故复盘:一条 SQL 引发的对账问题
先还原一下具体场景。这是一个省级政务平台的项目,原来用的是 MySQL 8.0,最近要整体迁移到金仓数据库 KES(版本 V9R1C10)。结果业务刚上线第一周,运营那边就发现,“用户订单综合报表”的数据总条数,比之前的老系统少了将近 40%。
把那条有问题的 SQL 拿过来,脱敏简化后,差不多是下面这样:
复制代码-- 业务需求:查询所有用户,并附带展示其"已完成状态"的订单信息;
-- 如果用户没有已完成订单,也需要显示该用户,订单相关字段为 NULL。
SELECT u.user_id,
u.user_name,
o.order_id,
o.order_amount,
o.order_status
FROM t_user u
LEFT JOIN t_order o
ON u.user_id = o.user_id
WHERE o.order_status = 'FINISHED';
这段 SQL,你丢到 MySQL、PostgreSQL 或者 KES 里,语法上都没问题,都能跑出来。但关键是,它跑出来的结果集大小,在 MySQL 8.0 和金仓 KES 里面完全一样。你没看错。这里真正的问题,其实不是“迁移后两边行为不一致”。真正的问题是,这条 SQL 从一开始就是错的,只不过在原来的系统里,一直没人发现而已。
后来进一步确认了问题所在。原来老系统里还跑着另一条配套的对账 SQL:
复制代码-- 原系统另一处对账逻辑
SELECT COUNT(DISTINCT u.user_id)
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id;
这条查出来的就是全部用户数,原因很简单——它没有在 WHERE 里加任何 nullable-side 的条件,保留了 LEFT JOIN 的原始语义。这就是为什么以前做业务的人总觉得“用户总数这栏是对的”,但报表却一直“少数据”的原因。两条 SQL 走了不一样的逻辑路径,只是从来没人顺着这个逻辑去追究。
先说结论:这事儿真不是 KES 自己优化得太猛了。而是最开始那种写法,本身就有个“外连接被偷偷消除”的语义问题。其实 KES、MySQL 和 PG 在处理这条路径时,做法一模一样。只不过迁移的时候,大家的注意力都集中在新的数据库上,业务那边自然就以为是新数据库出了毛病。
二、 底层机制:为什么 LEFT JOIN 会"变成" INNER JOIN
要把这个坑彻底说明白,有一件事得先搞清楚。在关系代数层面,你写 LEFT JOIN 然后跟个 WHERE 去过滤右表不能为空的条件,本质上跟你直接写 INNER JOIN 加上一样的条件,是等价的。 优化器只要识别出这两个是等价的,就会把外连接改成内连接来执行。这个操作,数据库领域里有个专门的叫法——外连接消除。

1. SQL 的执行顺序假象
很多开发者对 SQL 执行顺序的理解是这样的:
复制代码FROM → JOIN → WHERE → GROUP BY → HA VING → SELECT → ORDER BY
这个顺序,只谈“逻辑上的意思”的话,确实是对的。也就是说,先执行 LEFT JOIN——左边的表所有行都保留,右边没有匹配上的行用 NULL 填充。然后轮到 WHERE 登场,对这个结果集做过滤。
那这里面就有个问题了。WHERE o.order_status = 'FINISHED' 这个条件,碰到那些“外连接补出来的 NULL 行”时,会发生什么?
答案是:NULL = 'FINISHED' 在三值逻辑里,结果是一个 Unknown。而 WHERE 看到 Unknown 的行,会直接过滤掉。结果就是,那些靠外连接补出来的 NULL 行,全被清理干净了。
最后剩下的,仅仅是那些“真正匹配上了,并且 order_status 是 'FINISHED'”的行。这不就是 INNER JOIN 的行为吗?
2. 优化器的"等价改写"
数据库优化器在进入代价估算阶段之前,会先做一步逻辑等价变换。它会扫描 WHERE 子句里的所有条件,看有没有能证明“外连接补出的 NULL 行不符合需求”的条件。判断规则主要有两条:
- 条件作用对象:条件里用到的列,必须来自 Nullable-Side(即外连接中可以为空的一侧);
- 条件对 NULL 的态度:这个条件遇到 NULL 时,必须返回 False 或 Unknown——也就是所谓的 "null-rejecting"(拒空条件)。
以我们这条 SQL 为例,o.order_status = 'FINISHED' 这行,两个条件都满足:它使用了右边的列,而且它排斥 NULL。
于是优化器就动手了,把 LEFT JOIN 改写成了 INNER JOIN。改写完之后,再往下走 Hash Join 或 Nested Loop 等实际的执行路径。
这就是数据不见了的真正原因:不是数据凭空消失了,而是按照 SQL 的语义,那些数据本来就不该出现在结果集里。
3. 主流数据库的一致态度
这里必须说明一点:这事儿真不是哪个国产数据库独创的“特色”。基本上所有支持 SQL:2008 标准的关系型数据库,都会做这个优化。你熟悉的 MySQL、PostgreSQL、Oracle、SQL Server,包括金仓 KES,全都会做外连接消除。因为大家都得遵循标准 SQL 引擎的那套规则。
唯一的区别在于,不同数据库的优化器在什么时候能“发现”这种等价情况。比如涉及子查询、视图或者 UDF 时,不同数据库的识别能力存在差异。这就造成了后面我们将会谈到的、在生产环境中特别难发现的那类坑。
三、 KES 的兼容性设计:把"意图"还给业务
如果单纯从“符合 SQL 标准”的角度看,上面那条 SQL 被优化成 INNER JOIN 完全没毛病。但从“迁移改造工程”的角度看,这里其实有一个非常关键的产品设计取舍——新库到底应不应该复现旧库的错误行为?
金仓 KES 在这里的设计思路,我个人非常认可:严格遵守 SQL 标准语义,绝不为错误 SQL 进行“歪打正着”的兜底,同时提供极其完备的执行计划可视化能力,让开发者在改造阶段就能把这类语义陷阱暴露出来。
1. 用 EXPLAIN 立即揪出被消除的外连接
在金仓 KES 里,第一时间应该用 EXPLAIN 或 EXPLAIN ANALYZE 查看执行计划:
复制代码EXPLAIN ANALYZE
SELECT u.user_id, u.user_name, o.order_id, o.order_amount, o.order_status
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE o.order_status = 'FINISHED';
如果外连接被消除,返回的执行计划中,你会看到关键字不是 Hash Left Join 或 Nested Loop Left Join,而是直接的 Hash Join(内连接)或 Nested Loop。仅仅这一个观察点,就足以在迁移代码审计阶段发现绝大部分外连接消除的案例。
复制代码-- 被消除后的执行计划关键行(示意)
Hash Join (cost=... rows=...)
Hash Cond: (u.user_id = o.user_id)
-> Seq Scan on t_user u
-> Hash
-> Seq Scan on t_order o
Filter: (order_status = 'FINISHED')
注意上面这个 Filter: (order_status = 'FINISHED') 已经被下推到 t_order 的扫描节点,而 JOIN 节点直接标记为 Hash Join——外连接消失得干干净净。
2. 显式补齐 IS NULL 条件时的兜底行为
有一种非常常见的业务写法,是故意用 LEFT JOIN 找出“未匹配”的记录,比如“哪些用户从未下过订单”:
复制代码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;
这条 SQL 在 KES 里不会被消除,原因很简单:IS NULL 条件的本意就是捕捉“外连接补出的 NULL 行”。如果优化器强行把它改写为内连接,业务语义就被彻底摧毁了。KES 的优化器在这里的判定规则严守边界:只要 WHERE 条件不是 null-rejecting,就不消除外连接。
这个细节说明什么?说明 KES 的优化器是语义安全优先的,而不是“能优化就优化”的粗放模式。这一点在国产化迁移里非常重要——你在旧系统里写对的 SQL,在 KES 里一定跑得对;写错的 SQL,KES 会用与主流数据库一致的方式对待,不会因为追求兼容性而牺牲标准。
3. 走 ON 子句:把过滤留给"连接规则"
回到文章开头那条报表 SQL,业务方的真实意图是“所有用户都要出现,有匹配的已完成订单时显示订单信息”。正确写法是把过滤条件挪到 ON 子句里:
复制代码-- 正确写法:ON 子句里过滤右表,左表所有行保留
SELECT u.user_id,
u.user_name,
o.order_id,
o.order_amount,
o.order_status
FROM t_user u
LEFT JOIN t_order o
ON u.user_id = o.user_id
AND o.order_status = 'FINISHED';
这里的语义差别至关重要:ON 子句控制的是“如何连接”,WHERE 子句控制的是“最终留下哪些行”。 ON 里加过滤,等价于“先把 t_order 过滤成只剩 FINISHED 的行,再与 t_user 做外连接”——用户表的所有行完整保留,即便对应的订单不满足条件,也会以 NULL 出现在结果集里。
这条改写之后,业务对账立即回到了正确状态。
四、 MySQL/PG 到 KES 迁移的六个高频"外连接消除"陷阱
除了最典型的“WHERE 里过滤右表列”之外,实际项目里还有大量变种写法会触发外连接消除。下面这六个是近半年迁移项目中反复出现的高频陷阱。
陷阱 1:使用非空对比操作符
复制代码-- 反例:>、<、!=、LIKE 等对 NULL 都返回 Unknown
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE t2.amount > 100;
t2.amount > 100 对 NULL 值同样返回 Unknown,触发外连接消除。这类写法在报表 SQL 里极其常见。
陷阱 2:函数包裹的右表列
复制代码-- 反例:函数结果同样是 NULL
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE UPPER(t2.name) = 'A';
只要函数是“严格函数(strict function)”——即遇到 NULL 输入返回 NULL 输出——外连接依然会被消除。KES 里绝大多数系统函数都是严格函数。
陷阱 3:IN / BETWEEN 子句
复制代码-- 反例:t2.status 为 NULL 时不会命中任何列表值
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE t2.status IN ('A', 'B', 'C');
IN 本质上是多个 = 的 OR 组合,NULL 与列表中的任何值比较,结果都是 Unknown。同样会触发消除。
陷阱 4:混合了 IS NULL 和其他右表条件
复制代码-- 反例:AND 一旦包含 null-rejecting 条件,整体也变成 null-rejecting
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE t2.status = 'A' AND t2.other_col IS NULL;
看起来有 IS NULL 就“安全”了?并不是。整个 AND 表达式必须对 NULL 输入至少有可能返回 True,才不会触发消除。这里 t2.status = 'A' 已经把 NULL 全部排斥掉了,导致整个条件依然被识别为 null-rejecting。
陷阱 5:多层嵌套 JOIN 的"传递消除"
复制代码-- 反例:t3 的过滤条件传染到 t2,间接把 t1→t2 的外连接也消除掉
SELECT * FROM t1
LEFT JOIN t2 ON t1.id = t2.id
LEFT JOIN t3 ON t2.id = t3.id
WHERE t3.status = 'A';
这条 SQL 里,t3.status = 'A' 直接消除了 t2 LEFT JOIN t3;一旦 t2 → t3 变成内连接,t2.id 就变成 not-null 的 join key,进而会继续消除 t1 → t2 的外连接。 一个条件消除两个外连接,损失非常大。
陷阱 6:视图/子查询里的外连接被外部 WHERE 反向消除
复制代码-- 反例:v_user_order 内部是 LEFT JOIN,外层 WHERE 反向消除
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;
视图内部虽然是外连接,但外层 WHERE 里带了 nullable-side 条件,一旦优化器“看穿”视图(View Inline / Query Rewrite),外连接依然会被消除。这类“跨层陷阱”排查难度最高,因为视图定义看起来是对的,问题出在业务侧的调用方式。
五、 一份实操级避坑清单
结合前面的分析,下面这份“外连接消除”排查清单,是日常做迁移改造时总结出来的。在几个项目里跑下来,基本可以在改造阶段拦截 90% 以上的类似语义陷阱。
1. 静态代码扫描:拦截"疑似消除"写法
在 CI/CD 流水线里,可以借助脚本对存量 SQL 做正则扫描,输出“包含 LEFT/RIGHT JOIN 且 WHERE 子句引用了 Nullable-Side 列(且不是 IS NULL)”的可疑 SQL 清单。项目里比较常用的简化版正则思路是:
复制代码# 匹配 LEFT JOIN ... WHERE ... right_alias.col = 之类的模式
LEFTs+JOINs+(w+)s+(w+).*?WHERE.*?2.w*s*(?!ISs+NULL)
这一步不追求 100% 精准,目的是先把可疑清单圈出来,交给 DBA 和业务方共同评审。
2. EXPLAIN 抽样验证
对静态扫描输出的可疑 SQL 进行抽样,在 KES 里执行 EXPLAIN,重点观察:
- JOIN 节点的关键字是否包含
Left/Right; - Nullable-Side 表的 Filter 条件是否被下推到扫描节点;
- 如果关键字是普通的
Hash Join/Nested Loop,说明外连接已被消除。
对于确认被消除、但业务意图需要保留外连接的 SQL,进入下一步改写。
3. 改写规则:ON 子句 vs. COALESCE
大部分场景推荐把过滤条件挪到 ON 子句里:
复制代码-- 改写前
LEFT JOIN t2 ON t1.id = t2.id WHERE t2.status = 'A'
-- 改写后
LEFT JOIN t2 ON t1.id = t2.id AND t2.status = 'A'
如果业务逻辑比较复杂,条件无法简单下推(比如条件涉及左右表混合列的 CASE 表达式),可以使用 COALESCE 手动构造一个“NULL 安全”的比较:
复制代码-- 用 COALESCE 处理 NULL 值场景
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE COALESCE(t2.status, 'A') = 'A';
COALESCE(t2.status, 'A') 遇到 NULL 时返回 'A',条件对 NULL 输入返回 True——这是标准的“null-preserving”写法,KES 优化器会识别到它不是 null-rejecting,从而不消除外连接。
4. 视图与子查询的封装原则
对于视图或 CTE 里的外连接,保持一个原则:不允许调用方在外层 WHERE 里对视图输出的 Nullable-Side 列做 null-rejecting 过滤。 如果业务上必须过滤,视图内部应该用 COALESCE 把 NULL 值兜住,或者在视图定义里额外输出一个 is_matched 布尔标记列,让调用方通过这个标记列来过滤,避免 nullable-side 直接暴露给外部。
5. 生产环境回归测试
改造完成后的关键动作,是把改写前后的 SQL 在同一份测试数据上执行,做 EXCEPT 差集对比:
复制代码-- 差集验证:改写前后结果集是否完全一致
(SELECT * FROM query_before)
EXCEPT
(SELECT * FROM query_after);
(SELECT * FROM query_after)
EXCEPT
(SELECT * FROM query_before);
任何一边返回非空结果,都说明改写引入了语义变化,需要重新复盘。
6. 善用社区资源
坦白说,外连接消除只是国产化迁移中几十种语义陷阱里的一种。每次踩坑,如果只靠自己团队闷头搜文档,效率非常低。一个比较好的习惯是——遇到疑难杂症,先去金仓社区看看有没有同类问题的帖子,很多时候原厂工程师或前辈已经把避坑记录分享出来了。社区也发起了面向一线工程师的交流计划,鼓励大家把迁移场景和优化案例反哺回社区。这种“社区共建 → 集体经验沉淀”的模式,对于国产化落地阶段非常实用,尤其是像“外连接消除”这类官方文档写得对、但实战中最容易踩的坑,靠一线工程师的口口相传,远比啃产品手册来得高效。
六、 总结
回到文章开头那个案例。把改好的 SQL 上线后,业务方看着新的报表非常满意,甚至跑来问:“这个新数据库是不是更智能?为什么老系统就算不出这个数?”当时也就是笑笑,没多解释。其实老系统一直在跑错的数据,只是从来没人去深究对账口径的细节。金仓 KES 按照 SQL 标准去执行,反而把以前藏得很深的语义 Bug 给暴露出来了。这场迁移,刚好成了清理历史债务的契机。
总结一下关于外连接消除的几个关键认知:
-
这真不是数据库的问题,核心是 SQL 语义要过关。 不管你要迁移到哪个数据库,改造时对外连接的语义都必须仔细检查。金仓 KES 严格遵循 SQL 标准,跑出来的结果跟主流数据库基本一致,这样才能保证换平台后数据不出问题。
-
看执行计划才是最靠谱的方法。 文档写得再详细、代码看着再规范,最后 SQL 到底怎么执行的,还得看执行计划里的那几个关键字。养成写 SQL 后就敲
EXPLAIN的习惯,能省很多排查时间。 -
国产化替换其实是个契机,逼着你去重新审视老代码。 金仓 KES 的优化器在语义处理上比较严谨,对标准兼容卡得很严,配套的诊断工具也多。这其实就是逼着我们把以前的存量代码重新梳理一遍。以前老系统里谁也没注意到的语义 Bug,在这次迁移中全都浮出水面——这其实是个不错的附带价值。
外连接消除只是国产化迁移中的一个细节。后面会继续整理遇到的各类迁移坑点和优化方法,希望能给正在做同样事情的同行提供一些参考。多看执行计划、多做差集验证、多交流实战经验,很多坑是可以提前绕开的。
