踩坑实战|异构数据库迁移必踩:LEFT JOIN 莫名丢数据,KES 外连接消除优化行为避坑指南

说真的,如果不是亲身踩过这个坑,我大概永远不会去深究外连接消除这么个东西。
事情是这么开始的。去年年底我们团队接了个活儿,把一套跑了好几年的业务系统从MySQL迁到KES(KingbaseES)。说实话迁移本身不算太难,用KDTS工具把表结构和数据同步过去,存储过程改吧改吧,大部分功能测试都过了。上线那天一切正常,大家还挺高兴,结果第二天业务方就来找茬了——一张核心报表的数据量明显不对,少了大概三分之一的行。
最诡异的是啥呢,程序没报错,SQL一行没改,表结构也对得上,数据也查过了没丢。就是把数据库换了一下,结果集就缩水了。起初我们怀疑是迁移工具漏数据了,反反复核对了好几遍表行数,完全没问题。后来又怀疑是字符集的事儿,编码翻来覆去看也没毛病。折腾了大半天,最后靠一个EXPLAIN执行计划才找到真凶。
说实话当时看到执行计划的那一刻,我是有点懵的。SQL里明明白白写的是LEFT JOIN,执行计划里愣是变成了Hash Join,那个"Left"的字样直接消失了。这不是Bug,这是KES优化器主动干的——它把我的外连接给"消除"了。
先把问题复现一下
为了说清楚这个事儿,我拿最简单的例子来演示。建两张表,塞几行数据进去:
-- 建测试表
CREATE TABLE t1 (id1 INT, name1 VARCHAR(20));
CREATE TABLE t2 (id2 INT, name2 VARCHAR(20));
-- 插数据
INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c');
INSERT INTO t2 VALUES (1, 'cc'), (2, 'dd');
需求很简单:查出t1的全部记录,关联t2里name2等于’cc’的信息,匹配不上的就显示NULL。
在MySQL里,很多人会这么写:
SELECT * FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t2.name2 = 'cc';
按LEFT JOIN的语义,你期望的结果应该是三行——t1的a、b、c全部返回,其中a匹配到了t2的cc,b和c没匹配到所以t2的字段是NULL。
但实际跑出来的结果只有一行:
id1 | name1 | id2 | name2
-----+-------+-----+-------
1 | a | 1 | cc
b和c去哪了?消失了。被干掉了。而且没有任何报错提示。
你要是去翻KES的执行计划,就会看到这个:
EXPLAIN SELECT * FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t2.name2 = 'cc';
QUERY PLAN
--------------------------------------
Hash Join
Hash Cond: (t1.id1 = t2.id2)
-> Seq Scan on t1
-> Hash
-> Seq Scan on t2
Filter: (name2 = 'cc'::text)
注意看,这里写的是Hash Join,不是Hash Left Join。那个"Left"没了,这就是外连接被消除的直接证据。优化器把你的LEFT JOIN悄悄改成了INNER JOIN。
为什么外连接会"变成"内连接
这个事儿说起来其实分两层,一层是SQL本身的执行逻辑,另一层是优化器的等价变换。
先说第一层。SQL标准里头,WHERE的过滤是发生在JOIN之后的。也就是说,数据库会先把LEFT JOIN做完,得到一个中间结果集——t1的三行都在,t1的b行和c行因为没匹配到t2,对应的t2字段被填成NULL。然后WHERE条件才开始干活,它要筛t2.name2 = ‘cc’。
问题就在这儿。NULL跟任何值做等值比较,结果都是Unknown,不是true也不是false。但是WHERE在过滤的时候,Unknown会被当作false处理。所以那些name2是NULL的行——也就是b和c——全被筛掉了。
这就导致一个结果:LEFT JOIN加上WHERE右表条件,最终的效果跟INNER JOIN一模一样。左表不匹配的行一个都留不住。
然后第二层来了。KES的优化器在生成执行计划的时候,会做等价变换检查。它发现了一个事实——WHERE里针对右表的这个过滤条件,会把外连接产生的所有NULL行都干掉。既然如此,"外连接+过滤"和"内连接+过滤"的结果是完全等价的,那干嘛还费劲做外连接呢?内连接代价更低、可选执行路径更多,优化器自然就选了内连接。
从纯技术角度看,这个优化是合理的,甚至是聪明的。但问题在于,你写SQL的时候心里想的业务逻辑是"保留左表全部数据",而这条SQL的实际语义跟你的预期根本不是一回事儿。
金仓官方的产品手册里其实提过这个机制。在查询优化器简介那部分,明确写了"外连接消除"是逻辑优化阶段的一项技术,转换的条件是看WHERE子句中与内表相关的条件是否满足"空值拒绝条件"(reject-NULL条件)。说白了就是,只要WHERE里的条件能把NULL行全部拒绝掉,外连接就会被消除。
关于外连接消除的研究演进:从理论到工程实践的争鸣
外连接消除这个东西,说它新也不新,说它旧也不旧。它涉及到一个根本性的问题——优化器到底应该在多大程度上"自主决策"改写用户写的SQL。这个争论从关系代数理论时代就开始了,到今天的工程实践里依然没有完全收敛。
早期的研究主要关注的是关系代数等价变换的数学基础。上世纪八九十年代,有一批学者从形式化角度证明了外连接在某些条件下可以被安全地转换为内连接,核心依据就是所谓的"空值拒绝"概念——如果存在一个谓词能保证过滤掉所有由外连接生成的NULL行,那么这个转换在数学上是等价的。这个结论在当时看来非常优雅,也给后续的工程实现提供了理论支撑。
但理论到落地之间隔着一道巨大的鸿沟。不同数据库厂商在实现的时候,策略差异其实蛮大的。MySQL的做法相对保守,它虽然也有外连接消除的能力,但触发条件比较严格,很多时候并不会主动改写,这就导致同一个SQL在MySQL里跑出来数据是全的,迁到别的库就变少了。而KES的优化器在这块更加激进——我的意思是说更积极,它会主动检查等价性并做改写。这本身不是坏事,从性能角度来说甚至值得鼓励,但在迁移场景下就会造成行为差异。
这里我必须说一点批判性的看法。很多讨论这个问题的文章,都把它归结为"KES优化器太激进了",这个说法其实有失偏颇。你仔细想一下,如果SQL本身的语义就有问题——你把右表过滤条件写在了WHERE里,那不管哪个数据库,最终结果在数学上都是一样的,只是有的库没做改写所以你"看起来"数据没丢。换句话说,这不是KES做错了什么,而是原来的SQL写法本身就有隐患,只是MySQL恰好没触发这个改写,把问题给掩盖了。当然话又说回来,从迁移体验的角度讲,如果源库不消除而目标库消除了,对于业务方来说确实就是"换了个数据库数据就少了",这个感知差异是真实存在的。
还有一个被忽视的点是,关于优化器应该"激进"还是"保守"的争论,本质上反映了两种不同的设计哲学。一派认为优化器应该尽可能智能,主动发现等价变换的机会来提升性能;另一派则认为优化器不应该改变用户SQL的"表面语义",即使这个改写在数学上是等价的。这两派的观点都有道理,但在实际工程中,大多数主流数据库最终都选择了前者——包括一些我这里不便具名的开源数据库——只是触发的激进程度不同而已。
近年来出现了一些新的研究方向,关注的是如何在优化器做改写的同时,给用户提供更透明的可观测性。比如让执行计划更清晰地标注"这里发生了外连接消除",或者提供hint机制让用户可以手动关闭这个优化。KES在这方面其实做得还行,你可以通过EXPLAIN看到改写后的计划,也有手段控制优化器行为。但问题是大多数迁移团队根本不知道要去查执行计划,他们以为SQL能跑就行。
概念界定上也有值得深究的地方。“外连接消除"这个术语本身就有一定的模糊性——它到底指的是"将外连接转为内连接"还是"完全消除连接操作”?在金仓官方文档的语境下,它特指前者。但学术界有时候会用"join elimination"来描述后者,比如当存在主外键约束时,优化器可以直接跳过对主键表的扫描。这两个概念虽然都叫"消除",但机制和影响完全不同,迁移的时候容易搞混。
什么时候不会被消除
好消息是,不是所有写在WHERE里的右表条件都会触发消除。最典型的例外就是IS NULL。
SELECT * FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t2.name2 IS NULL;
这条SQL,优化器不会动它。原因也很好理解——IS NULL的目的就是去找那些外连接补出来的NULL行,也就是右表没有匹配的记录。如果把外连接消除了,这些NULL行就永远不会出现,那这个查询就彻底废了。所以优化器很聪明,它识别得出这种"查空"的语义,会老老实实保留外连接。
这也是LEFT JOIN配合WHERE IS NULL实现反连接的经典手法。你要查"t1中有但t2中没有的记录",这么写是完全安全的,迁移到KES行为也不会变。
还有一个容易搞混的场景——条件作用在左表上:
SELECT * FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2
WHERE t1.name1 = 'a';
这个不会触发消除。左表是驱动表,对左表的WHERE过滤只是业务前置筛选,不会改变连接的性质。这个行为在所有数据库里都是一致的,不用担心。
但有个坑要注意,如果你在ON子句里写左表的条件:
SELECT * FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2 AND t1.name1 = 'a';
这时候t1的全部数据仍然会返回,只是不满足name1='a’的行不参与连接而已。这跟把条件放WHERE里效果完全不同。我见过有人把这俩搞混的,查出来的数据怎么都不对。
Oracle (+) 语法的坑
从Oracle体系迁移过来的同学要特别注意。KES兼容Oracle的(+)外连接语法,但这个语法在跟过滤条件搭配的时候,行为很容易出错。
-- 这种写法会触发外连接消除
SELECT * FROM t1, t2
WHERE t1.id1 = t2.id2(+)
AND t2.name2 = 'cc';
上面这个写法,(+)只标在了连接条件上,过滤条件t2.name2 = 'cc’没有带(+),效果等同于把过滤写在了WHERE里——会丢数据。
-- 这种写法不会触发消除
SELECT * FROM t1, t2
WHERE t1.id1 = t2.id2(+)
AND t2.name2(+) = 'cc';
过滤条件也带上(+),语义就等同于把条件写进ON子句,外连接不会被消除。原则就是(+)要么全带、要么别带,漏带就是坑。从Oracle迁移的时候,这个是重点排查项。
怎么修
核心原则就一句话:ON决定连接的规则,WHERE决定最终结果的筛选。
针对右表的过滤条件,除非是查空(IS NULL),否则都应该放到ON里面去:
-- 正确写法:条件放ON,左表数据全保留
SELECT * FROM t1
LEFT JOIN t2 ON t1.id1 = t2.id2 AND t2.name2 = 'cc';
这个写法的效果是:系统先按id1 = id2 AND name2 = 'cc’来判定匹配,然后再对t1做外连接。id1=2的行虽然能跟id2=2关联上,但name2是’dd’不满足ON条件,所以视为未匹配,右表字段补NULL。id1=3压根没匹配,也补NULL。t1三行完整保留。
如果业务逻辑确实就是要"只返回t1和t2都有的且满足条件的记录",那你直接用INNER JOIN就行了,别用LEFT JOIN假装是外连接,这样语义更清晰:
-- 如果确实只需要匹配的数据,直接用内连接
SELECT * FROM t1
INNER JOIN t2 ON t1.id1 = t2.id2
WHERE t2.name2 = 'cc';
还有一种写法,逻辑复杂的时候看着可能更清楚——用子查询先把右表过滤好:
-- 先过滤右表,再外连接
SELECT * FROM t1
LEFT JOIN (
SELECT * FROM t2 WHERE name2 = 'cc'
) t2_filtered ON t1.id1 = t2_filtered.id2;
这种写法也能避免外连接消除,但多了一层子查询,执行计划可能不如直接写ON条件高效。不过好处是逻辑特别清晰,对于复杂查询来说可读性更好。
给一个更贴近真实业务的例子。假设有个订单系统和用户系统:
-- 订单表
CREATE TABLE orders (
order_id INT,
user_id INT,
amount DECIMAL(10,2),
create_time TIMESTAMP
);
-- 用户表
CREATE TABLE users (
user_id INT,
user_name VARCHAR(50),
status VARCHAR(10)
);
-- 需求:查所有订单,关联显示VIP用户的信息
-- 危险写法(会丢非VIP用户的订单)
SELECT o.*, u.user_name, u.status
FROM orders o
LEFT JOIN users u ON o.user_id = u.user_id
WHERE u.status = 'VIP';
-- 正确写法(所有订单都保留,非VIP用户信息显示NULL)
SELECT o.*, u.user_name, u.status
FROM orders o
LEFT JOIN users u
ON o.user_id = u.user_id AND u.status = 'VIP';
你看,就差一个条件放哪儿的问题,结果天差地别。
迁移的时候怎么排查
说几个实操经验吧。
第一,凡是涉及LEFT JOIN或者RIGHT JOIN的SQL,迁移过来之后都得过一遍。重点看WHERE子句里有没有针对右表(可空侧)的过滤条件。有的话,先确认业务逻辑——是真的只想要匹配的数据,还是要保留左表全部行。如果是后者,条件挪到ON里去。
第二,用EXPLAIN看执行计划。这是最直接的判断方法。你SQL里写的是LEFT JOIN,执行计划里如果出现的是Hash Join或者Nested Loop,而没有"Left"字样,那大概率就是发生了外连接消除。当然了,Nested Loop Left Join变成Nested Loop也是同理。
-- 迁移后必做的检查:对比执行计划
EXPLAIN SELECT * FROM your_table_a a
LEFT JOIN your_table_b b ON a.id = b.aid
WHERE b.some_col = 'xxx';
第三,如果不确定SQL改写后行为对不对,最保险的办法是先在源库和KES上分别跑一遍,对比结果集的行数。行数不一致就说明有问题。
第四,批量排查可以用脚本扫。把所有含LEFT JOIN的SQL捞出来,正则匹配WHERE子句里是否引用了右表的列名。这种自动化排查在大型迁移项目里特别有必要,靠人工一个个看根本看不过来。
第五,建立团队开发规范。简单记几条就行:
- LEFT JOIN的右表过滤条件一律放ON,不要放WHERE
- WHERE里只能出现左表(驱动表)的过滤条件
- 除非业务确实只需要交集数据,否则不要在LEFT JOIN的WHERE里写右表条件
- 用IS NULL查空是唯一安全的右表WHERE条件
- 从Oracle迁移时检查(+)语法是否完整
再深想一层
其实这个坑背后反映的是一个更深层的问题——异构数据库迁移的时候,你以为SQL是标准的、可移植的,但实际上每个数据库的优化器行为都不一样。同一条SQL,在不同的数据库上可能走完全不同的执行路径,而执行路径的差异会直接影响结果集。
这不是KES独有的问题。你从任何一个数据库迁到另一个,都可能碰到类似的行为差异。只是外连接消除这个坑特别隐蔽,因为它不报错、不警告、不日志,就是默默地把你的数据给筛没了。要不是业务方发现数据对不上,你可能永远都不知道。
说到这里,前阵子我在金仓社区看到有个"同行者计划"的活动,说白了就是推荐商机赢好礼——你手头要是有朋友、客户在做信创选型或者数据库替换的,推荐过去成了就有奖励( https://bbs.kingbase.com.cn/forumDetail?articleId=1d09d598f414ab764eda4907e8f54758 )。像我这种天天跟LAC授权打交道的人,身边问金仓方案的朋友还真不少,顺手推荐一下两边都落好。
回到正题。我后来反思了一下,这个坑的本质其实是——开发人员对SQL语义的理解不够深。很多人写LEFT JOIN的时候,心里想的是"保留左表全部数据",但把右表过滤条件放在WHERE里的时候,实际上已经改变了这个语义。在MySQL里可能恰好没触发改写所以结果看起来是对的,但这只是运气好,不是写法对。换一个优化策略更积极的数据库,问题就暴露了。
所以我的建议是,不管你用不用KES,不管你迁不迁移,只要写了LEFT JOIN,就应该养成一个习惯——右表过滤条件放ON,左表过滤条件放WHERE。这不是为了某个特定数据库写的最佳实践,这是SQL标准本身就推荐的写法。
最后再补充一个实战中碰到的小坑。有时候你的SQL里不只有一个JOIN,可能有多个表串联。比如t1 LEFT JOIN t2 LEFT JOIN t3,然后WHERE里同时对t2和t3做了过滤。这种情况下,外连接消除的判断会更复杂,因为优化器需要逐个检查每个外连接是否满足消除条件。但原理是一样的——只要WHERE里有针对某个可空侧的reject-NULL条件,对应的外连接就可能被消除。
-- 多表关联的复杂场景
SELECT t1.*, t2.info2, t3.info3
FROM t1
LEFT JOIN t2 ON t1.id = t2.t1_id
LEFT JOIN t3 ON t2.id = t3.t2_id
WHERE t2.status = 'active' -- 这个条件可能导致t1-t2的外连接被消除
AND t3.type = 'normal'; -- 这个条件可能导致t2-t3的外连接被消除
-- 修正写法
SELECT t1.*, t2.info2, t3.info3
FROM t1
LEFT JOIN t2 ON t1.id = t2.t1_id AND t2.status = 'active'
LEFT JOIN t3 ON t2.id = t3.t2_id AND t3.type = 'normal';
多表场景的排查难度更大,建议每个JOIN都单独看一遍,确认右表条件都放在了ON里。如果嵌套层级太深,也可以考虑拆成多个子查询或者CTE,可读性会好很多。
说到底,外连接消除是优化器的一个正常功能,它不是Bug。真正的问题出在SQL写法上——你把本该放在ON里的条件放到了WHERE里,只是以前用的数据库没那么"聪明"所以没帮你发现这个错误。KES的优化器帮你发现了,虽然方式有点"粗暴"——直接把数据给你删没了。
所以与其说是避坑,不如说是纠正一个长期存在的不良编码习惯。趁迁移这个机会好好梳理一遍SQL,把不规范的写法都改过来,以后不管迁到哪个数据库都不用担心了。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)