LEFT JOIN 凭空消失的背后,是优化器偷偷帮你做了决定
一个让我懵了的执行计划
上个月帮同事排查一个慢查询,SQL写得挺规矩:
sql
SELECT * FROM orders o LEFT JOIN refunds r ON o.id = r.order_id WHERE r.status = 1;
业务方的意思是:查所有订单,有退款的把退款信息带出来,没有的就算了。
同事说这个查询跑得慢,让我看看执行计划有没有问题。我拉出来一看,愣住了------执行计划里压根没有LEFT JOIN,就是个普通的Hash Join。
我说你这SQL不对吧?他信誓旦旦说就是LEFT JOIN。我重新跑了一遍计划,确实没有外连接。
当时我就纳闷了,LEFT JOIN哪去了?
查了半天才发现,金仓的优化器把LEFT JOIN自动转成了INNER JOIN。因为WHERE条件里有r.status = 1,优化器算了一笔账:右表没匹配上的行,r.status是NULL,NULL = 1是false,早晚会被WHERE过滤掉,那我干脆先转成INNER JOIN,效率更高。
逻辑上没毛病,但业务语义变了。
那些没退款的订单,因为右表没有匹配行,本该保留下来。结果被优化器这么一搞,全没了。这个SQL其实应该写成:
sql
SELECT * FROM orders o LEFT JOIN refunds r ON o.id = r.order_id AND r.status = 1;
把条件放ON里,先过滤退款表再连接,订单才能全保留。
优化器在想什么
数据库的查询优化器,说穿了就是一门算账的生意。它拿到一条SQL,会生成无数种执行方式,然后挑一个它觉得最便宜的。
外连接消除这件事,本质上就是优化器在算一笔账:外连接(LEFT JOIN)比内连接(INNER JOIN)贵在哪?
内连接的执行逻辑是:两个表匹配上了就返回,匹配不上就扔掉。外连接不一样,左表的行不管右表有没有匹配都得留着,这天然就比内连接多干活。
所以当优化器发现,WHERE条件会把那些"右表没匹配上"的行全部干掉,它就觉得没必要做外连接了。反正结果跟内连接一样,干嘛不用更快的?
这就是优化器的逻辑:它不管你写的是什么SQL,它只关心最终结果集是不是一样。如果一样,它就选最便宜的。
什么样的条件会触发消除
简单说,只要WHERE条件里包含了右表的非空列,并且这个条件在NULL上返回false,优化器就可能动手。
我列几个常见的:
sql
-- 等值条件
WHERE t2.status = 1
-- 范围条件
WHERE t2.amount > 100
-- 模糊匹配
WHERE t2.name LIKE '张%'
-- IN条件
WHERE t2.id IN (1,2,3)
这些条件在NULL上都是false,所以都会触发消除。
那什么时候不会消除?
sql
-- IS NULL 不会触发
WHERE t2.id IS NULL
-- 条件在左表不会触发
WHERE t1.status = 1
查"右表没匹配"本来就是LEFT JOIN的核心用途,优化器不敢动这个。条件放左表相当于先筛选用户,再外连接,也不影响。
金仓在这里做了什么
金仓的优化器在处理外连接消除时,内部走了一个叫"外连接消除检查"的流程。它会扫描WHERE条件里所有引用右表的表达式,然后判断这些条件在NULL上的表现。
具体怎么判断的?
对于每个条件,优化器会问三个问题:
- 这个条件在NULL上返回true还是false?
- 如果返回false,这个条件会不会引用右表的其他列?
- 有没有其他条件能保住这些NULL行?
如果所有条件在NULL上都返回false,并且没有IS NULL这类保底条件,优化器就判定外连接可以消除。
注意这里还有个细节:如果右表的列允许NULL,优化器会更谨慎。比如t2.name如果是可空的,那t2.name = 'cc'这个条件在NULL上是false,但在真实数据中如果有NULL值,WHERE t2.name = 'cc'本来就会过滤掉NULL,所以消除是安全的。
但如果是t2.name IS NOT NULL这种条件,它在NULL上是false,但语义上要求排除NULL值,外连接产生的NULL行确实应该被过滤,所以消除也是安全的。
总的来说,只要条件能准确排除所有NULL行,优化器就敢动。
我见过的一些翻车现场
翻车的原因其实就一个:开发者不知道自己写的WHERE条件会被优化器"解读"成什么。
翻车现场一:迁移过来的老代码
这是最常见的。原来在MySQL上跑得好好的SQL,迁移到金仓上数据就少了。因为MySQL的优化器在早期版本里不会做这个转换。
我见过一个项目,迁移完上线第一天,报表数据就对不上。排查发现全是LEFT JOIN写法和WHERE条件混用。改了几十条SQL才稳住。
翻车现场二:复杂的嵌套视图
视图里面套视图,外层再套WHERE条件。这时候优化器在做视图折叠和外连接消除的时候,条件传递路径复杂,容易产生非预期的消除。
比如A视图里有个LEFT JOIN,外层查询对右表加了条件,优化器可能在外层就把外连接消了,而开发者根本不知道视图里原来写的是外连接。
翻车现场三:OR条件下误判
如果WHERE条件是t2.col = 1 OR t2.col IS NULL,这种情况优化器不会消除,因为IS NULL保住了NULL行。但复杂的OR条件,特别是涉及多个表的,优化器可能分析不到那么深。
怎么应对
说白了就一句话:外连接的右表条件,除非确定要过滤空行,否则放ON里。
如果目标是"查所有左表行,右表只匹配满足条件的行",条件放ON:
sql
LEFT JOIN t2 ON t1.id = t2.id AND t2.status = 1
如果目标是"只查右表有匹配且满足条件的行",干脆用INNER JOIN:
sql
INNER JOIN t2 ON t1.id = t2.id AND t2.status = 1
或者把条件放WHERE也行,但那就别写LEFT JOIN了,容易让人误解。
还有一点,金仓支持Oracle的(+)语法,但用的时候要小心:WHERE里的条件如果不带(+),照样会触发消除。如果条件也带了(+),才等同于放ON里。
金仓优化器的设计取舍
其实金仓可以做两件事来避免这种问题:一是默认不做外连接消除,让用户自己控制;二是加一个开关让用户关掉这个优化。
但这两条路都有问题。不做消除,很多SQL的性能会受损。加开关,99%的用户不会去改默认值。
所以金仓的选择是:默认开启,让优化器帮你省钱,但你得知道这个功能的存在,写SQL的时候注意规范。
这也引出一个更大的话题:数据库优化器到底应该多聪明?
太笨了,性能上不去。太聪明了,用户又不知道它在背后干了什么。
我个人倾向于优化器可以聪明,但得把决策过程告诉用户。比如执行计划里如果能显式标注"OUTER JOIN ELIMINATED",至少出了问题我能知道是优化器干的。
不过目前金仓的执行计划里看不出这个标记,所以只能靠经验和执行计划的形状来判断。
说到底
外连接消除本身不是坏事。它让SQL跑得更快,逻辑上也没错。问题在于它太隐蔽了,开发者很难感知到这条SQL已经悄悄变了味。
跨数据库迁移的时候尤其要小心,不同优化器的行为差异,可能让你的SQL跑出完全不同的结果集。有时候你以为只是换个数据库,结果换掉的是业务的逻辑。