因为一条LEFT JOIN,差点把客户的数据搞丢了
因为一条LEFT JOIN我差点搞丢了客户的数据事情是这样的去年给一个客户做迁移数据库从MySQL换到金仓。代码改完、数据导完、测试环境跑了一遍都正常。上线那天业务方看了一眼报表说数据不对少了十几条。我赶紧查SQL发现是这么写的SELECT*FROMt1LEFTJOINt2ONt1.id1t2.id2WHEREt2.name2cc;业务方说这个报表要统计所有用户那些没下过单的用户也要显示暂无订单。左表是用户表右表是订单表。用LEFT JOIN没订单的用户应该保留订单相关的字段显示为空。结果那十几条没下过单的用户全没了。查了半天问题出在优化器身上我把这条SQL拿到数据库里单独跑了一遍又把执行计划拉出来看。发现执行计划里压根没有LEFT JOIN变成了普通的Hash Join。优化器把LEFT JOIN自动转成了INNER JOIN。我当时第一反应是它凭什么替我做这个决定后来理了一遍SQL的执行顺序才发现问题在WHERE条件上。正常的执行顺序是先做JOIN生成中间结果再用WHERE过滤。对LEFT JOIN来说右表没匹配上的行右表字段会是NULL。然后WHERE条件一过滤t2.name2 ccNULL等于’cc’是false所以这些行就被扔掉了。优化器一看反正你LEFT JOIN产生的NULL行最后都会被WHERE干掉那我直接帮你换成INNER JOIN算了效率更高。逻辑上说得通但业务语义变了。那些没匹配上的行在LEFT JOIN阶段本来应该保留但因为WHERE条件里有右表字段的过滤它们在最终结果里消失了。等于说LEFT JOIN白写了。什么情况下会踩这个坑我开始翻代码发现类似的写法还挺多-- 这些都会触发优化器把LEFT JOIN变成INNER JOINSELECT*FROMt1LEFTJOINt2ONt1.idt2.idWHEREt2.status1;SELECT*FROMt1LEFTJOINt2ONt1.idt2.idWHEREt2.amount100;SELECT*FROMt1LEFTJOINt2ONt1.idt2.idWHEREt2.nameLIKE张%;只要WHERE条件里包含了右表的非空列并且这个条件在NULL上返回false优化器就会认为外连接可以消除。有两种情况不会触发-- 查右表没有匹配的行IS NULL不会被消除SELECT*FROMt1LEFTJOINt2ONt1.idt2.idWHEREt2.idISNULL;-- 条件作用在左表不会改变外连接的性质SELECT*FROMt1LEFTJOINt2ONt1.idt2.idWHEREt1.status1;查右表缺失本来就是LEFT JOIN的核心用途优化器不会动它。条件放在左表上相当于先从用户表里筛选出一部分人再去看这些人有没有订单也不会消除外连接。怎么改才靠谱那次翻车之后我养成了一个习惯凡是LEFT JOIN的SQL都会单独过一遍确认不会把数据弄丢。改法其实就两种。如果业务需要保留左表所有行把右表的过滤条件从WHERE挪到ON里SELECT*FROMt1LEFTJOINt2ONt1.id1t2.id2ANDt2.name2cc;这样执行顺序变了先对t2做过滤只取name2cc’的行再跟t1做LEFT JOIN。t1的所有行都会保留右表没有匹配的就填空。ON子句里加右表的条件跟WHERE里放右表的条件语义完全不同。前者是在连接的时候就把右表过滤了后者是连接完了再过滤。用错了数据就丢了。如果业务只需要有匹配的行那根本不该用LEFT JOIN直接改成INNER JOIN就行了SELECT*FROMt1INNERJOINt2ONt1.id1t2.id2WHEREt2.name2cc;这种写法意图明确优化器不会做多余的转换。这其实是两套数据库的行为差异说实话这个优化本身没毛病。从执行效率看把外连接转成内连接确实更快。但在不同数据库上表现不一样。我后来在原MySQL上跑了一遍同样的SQL它没做这个优化结果是对的。到了金仓上优化器聪明了一点替用户做了决定反而把业务搞错了。问题就出在这——同样的SQL在不同数据库上结果不同。如果迁移之前没有专门针对这类SQL做数据量核对上线后才发现少了数据那场面就很尴尬了。后来我总结了一套检查方法现在遇到涉及外连接的SQL我会过一遍这个清单第一步看执行计划跑EXPLAIN如果原本定义的是LEFT JOIN执行计划里却变成了Hash Join或Nested Loop就要警惕是不是发生了外连接消除。第二步找一条右表无匹配的数据做验证-- 先确认左表有、右表没有的记录SELECT*FROMt1WHERENOTEXISTS(SELECT1FROMt2WHEREt1.idt2.id)LIMIT10;-- 再用完整的查询跑一遍看这条记录还在不在SELECT*FROMt1LEFTJOINt2ONt1.idt2.idWHERE...;如果查不到了说明外连接被消掉了SQL需要重构。第三步确认迁移目标模式金仓支持Oracle和MySQL两种兼容模式。不同模式下优化器对外连接消除的处理策略可能略有差异。SHOWdatabase_mode;如果迁移过程中发现行为差异较大先检查一下当前模式是不是跟业务预期一致。写在最后那次翻车之后我跟开发团队定了个规矩所有涉及外连接的查询代码评审的时候必须单独过一遍确认右表的条件放在哪里、执行计划对不对。跨数据库迁移这件事最难的不是SQL语法兼容而是这种隐性的行为差异。同样的代码在不同数据库上跑出不同的结果上线了才知道。那条LEFT JOIN少了的十几条数据让我们加了一整天的班。后来每次写LEFT JOIN我都会想起那天晚上。