KES 优化行为避坑指南-那条从没被质疑过的 LEFT JOIN,在迁 KES 时终于露馅了

报表少了 40%:那条从没被质疑过的 LEFT JOIN,在迁 KES 时终于露馅了

上个周五吧,大概下午四点左右,运营那边的人在群里开始找我了。问的是这个事:"为什么用户报表今天的总数,比昨天少了快 40% 啊?"

我一开始想的是,是不是哪个定时任务没跑成功。就去翻了日志。翻了半天,没看到有啥报错。接着我又去看了那条核心的统计 SQL。语法看过去没毛病,执行也没抛异常。而且在我本地搭的测试环境里面跑,出来的结果也是对的。但是生产的数据它就是少了。

最后搞了两个小时,才找到原因。原因是什么呢?是一条 LEFT JOIN 的写法不对。它把优化器的外连接消除给触发了,直接把一部分数据给过滤掉了,而且没提示。更无奈的一个情况是,这条 SQL 在以前用的 MySQL 里面,其实也是这么跑的。只是说,以前压根就没人去仔细对过账,所以这个问题就一直没暴露出来。

这种坑吧,其实是我最近在做 MySQL 还有 PostgreSQL 往 KES 迁移的这个项目里头,碰到频率最高的。情况往往是这样的:SQL 语法能跑通,执行也不报错,测试的时候你也看不出来,但是业务那边的实际结果呢,它就是少了点东西。 那么这篇文章,我就把 LEFT JOIN 这个事儿从前往后拆解一下。顺便列个避坑的清单。也就是为了让大家以后碰到这个情况,能有个参考。

@toc


一、 事故复盘:一条 SQL 引发的对账问题

先说一个我实际碰到的事儿吧。那个项目的背景是一个省级的政务平台。他们以前用的是 MySQL 8.0。最近这段时间呢,是要整体切到金仓数据库 KES 去,版本是 V9R1C10。结果业务刚上线的第一周,运营那边就说了,"用户订单综合报表"里面的数据总条数,比以前的老系统少了差不多 40%。

我当时把那条有问题的 SQL 拿过来,做了一下脱敏,简化一下差不多就是下面这样:

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 在跑:

sql 复制代码
-- 原系统另一处对账逻辑
SELECT COUNT(DISTINCT u.user_id)
FROM   t_user u
LEFT   JOIN t_order o ON u.user_id = o.user_id;

这条 SQL 它查出来的就是全部的用户数量。为什么呢?因为它没有在 WHERE 那里去加任何 nullable-side 的条件。这样的话,LEFT JOIN 本来的意思就还在。这就是为啥以前做业务的人总觉得"用户总数这栏是对的",但是那个报表却一直"少数据"的原因了。其实就是这两条 SQL 走了不一样的逻辑路径。只不过以前没人去顺着这个逻辑去查过。

**我先说结论吧:这事真不是 KES 自己优化得太猛了。而是最开始的那种写法,它本身就有个问题,就是"外连接被偷偷消除了"这么个语义上的毛病。其实 KES 跟 MySQL 还有 PG 在处理这条路径的时候,做法是一模一样的。**只不过是因为做迁移的时候,大家的眼睛都盯着新的数据库看。业务那边就以为,哦,是新数据库出毛病了。

二、 底层机制:为什么 LEFT JOIN 会"变成" INNER JOIN

要把这个坑说明白,有一件事得先说一下。就是在关系代数这个层面去看的话,你写 LEFT JOIN 然后后面跟着个 WHERE 去过滤右表不能为空的条件,这玩意儿跟你直接写 INNER JOIN 加上一样的条件,它们俩是划等号的。 优化器只要看出来这两个东西是一样的,它就会把外连接改成内连接去跑。这个操作呢,有个叫法,叫外连接消除

1. SQL 的执行顺序假象

很多人觉得 SQL 的执行顺序是这样的:

vbnet 复制代码
FROM → JOIN → WHERE → GROUP BY → HAVING → 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 里,我第一时间会用 EXPLAINEXPLAIN ANALYZE 去看执行计划:

sql 复制代码
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 JoinNested Loop Left Join,而是直接的 Hash Join(内连接)或 Nested Loop。仅这一个观察点,就足以在迁移代码审计阶段发现绝大部分外连接消除案例。

text 复制代码
-- 被消除后的执行计划关键行(示意)
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 找出"未匹配"的记录,比如"哪些用户从未下过订单":

sql 复制代码
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 子句里:

sql 复制代码
-- 正确写法: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:使用非空对比操作符

sql 复制代码
-- 反例:>、<、!=、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:函数包裹的右表列

sql 复制代码
-- 反例:函数结果同样是 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 子句

sql 复制代码
-- 反例:t2.status 为 NULL 时不会命中任何列表值
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE  t2.status IN ('A', 'B', 'C');

IN 本质上是多个 = 的 OR,NULL 与任何值 IN 判断都是 Unknown。 同样触发消除。

陷阱 4:混合了 IS NULL 和其他右表条件

sql 复制代码
-- 反例: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 全部 reject 掉了,导致整体依然被消除。

陷阱 5:多层嵌套 JOIN 的"传递消除"

sql 复制代码
-- 反例: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 反向消除

sql 复制代码
-- 反例: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 清单。项目里我常用的简化版正则思路是:

regex 复制代码
# 匹配 LEFT JOIN ... WHERE ... right_alias.col = 之类的模式
LEFT\s+JOIN\s+(\w+)\s+(\w+).*?WHERE.*?\2\.\w+\s*(?!IS\s+NULL)

这一步不追求 100% 精准,目的是先把可疑清单圈出来,交给 DBA 与业务共同评审。

2. EXPLAIN 抽样验证

对静态扫描输出的可疑 SQL 抽样,在 KES 里执行 EXPLAIN,重点看:

  • JOIN 节点关键字是否包含 Left / Right
  • Nullable-Side 表的 Filter 条件是否被下推到扫描节点;
  • 若关键字为普通 Hash Join / Nested Loop,则确认外连接已被消除。

对确认被消除、但业务意图确实需要保留外连接的 SQL,进入下一步改写。

3. 改写规则:ON 子句 vs. COALESCE

大部分场景推荐把过滤条件下推到 ON

sql 复制代码
-- 改写前
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 安全"的比较:

sql 复制代码
-- 用 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 差集对比:

sql 复制代码
-- 差集验证:改写前后结果集是否完全一致
(SELECT * FROM query_before)
EXCEPT
(SELECT * FROM query_after);

(SELECT * FROM query_after)
EXCEPT
(SELECT * FROM query_before);

任何一边返回非空,都说明改写引入了语义变化,需要复盘。

6. 走金仓社区的"同行者"通道

坦白讲,外连接消除只是国产化迁移里几十种语义陷阱中的一种。 每次踩坑,如果只靠自己团队闷头搜文档,效率是非常低的。 我这半年养成的习惯是------遇到疑难杂症,先去金仓社区 bbs.kingbase.com.cn 看看有没有同类问题的帖子,很多时候原厂研发或者前辈们已经把避坑记录留下来了。 前不久金仓社区还发起了「同行者计划」,鼓励一线工程师把自己所在企业的迁移场景、优化案例反哺回社区。 我个人觉得这种"社区共建 → 集体经验沉淀"的模式,对于国产化落地阶段是非常有价值的,尤其是像"外连接消除"这种"官方文档写得对、但实战里最容易踩"的坑,靠一线的口口相传远比自己啃产品手册来得快。

六、 总结

咱们回到文章开头说的那个情况。就是报表少了40%数据的那次。我把改好的SQL上线以后,业务那边看新报表看得很满意。他们甚至跑来问我,说这个新数据库是不是更聪明一点,为什么老系统就算不出来这个数。我当时听完也就是笑笑没多说话。其实吧,老系统一直跑的都是错的数据。只不过以前没人去仔细抠过对账口径的细节。金仓 KES 的话,它也就是按照SQL标准去跑了。结果呢,把以前藏得很深的语义Bug给暴露出来了。这也就是在这次国产化改造的时候刚好碰上了。

我自己平时做这种迁移改造做得比较多。所以关于外连接消除这个事,我个人的话有三点想法。跟大家总结一下。

  1. 这事儿真不是数据库的问题,说白了就是SQL语义得把好关。 不管你要迁到哪个库里面去。在改造的时候,外连接的语义你都得去查一遍。金仓 KES 它就是严格遵守SQL标准的。它跑出来的结果跟主流库基本是一样的。这样的话,你换平台的时候数据才不会出问题。

  2. 看执行计划才是最靠谱的办法。 文档写得再详细也没用。代码看着再怎么标准也没用。最后到底SQL是怎么跑的,你还得看执行计划里面的那几个关键字。平时调试的话,养成先敲 EXPLAIN 的习惯。这个习惯能帮你省很多事。以后生产上出了问题,排查起来也快得多。

  3. 国产化替换往往仅仅只是个借口,其实就是让你去把老代码重新看一遍。 金仓 KES 的优化器在处理语义的时候比较严谨。它对标准的兼容卡得很严。而且它自带的诊断工具也多。这其实也就是逼着我们去把以前的存量代码重新捋一捋。以前老系统里谁也没注意到的语义Bug,这次迁移的时候全给你找出来了。这其实不是什么坏事。我觉得,这反而是这次国产化改造比较实在的一个用处。

外连接消除这个点,其实在国产化迁移里只是很小的一部分。后面的话,我还会接着写。把遇到的各种迁移坑还有优化办法都发出来。也就是希望能给现在正在做同样事情的兄弟们一点参考。大家看完的话,可能就能少踩几个坑了。

相关推荐
Database_Cool_1 小时前
RAG 混合检索首选:阿里云 Lindorm 向量+全文一体化检索
数据库·阿里云·django·云计算
ByWalker_1 小时前
web应用技术—MyBatis入门
java·数据库·mybatis·lombok
指尖的爷1 小时前
Ubuntu 24.04 彻底关闭自动更新(桌面+服务器通用)
服务器·数据库·ubuntu
gcw10242 小时前
Cron专业工具:定时任务与开发办公的一站式在线工具箱
java·前端·数据库·工具·crontab·cron·spring cron
江华森2 小时前
Gephi 可视化 + NetworkX 网络分析——《釜山行》人物关系(三)
数据库·python
whaledown2 小时前
互联网大厂Java求职面试三轮提问详解(涵盖Spring Boot、微服务、Kafka等核心技术)
java·jvm·数据库·spring boot·微服务·面试·kafka
运维大师2 小时前
【安全与故障排查】06-【复盘】生产数据库删库事故:RTO4小时恢复纪实
数据库·安全
ywl4708120873 小时前
Mysql,使用B+树存储的索引增删改查效率(七)
数据库·b树·mysql
喜欢的名字被抢了3 小时前
MongoDB基础入门:从安装到CRUD与索引优化
数据库·mongodb