MySQL/PostgreSQL 迁移金仓 KES:LEFT JOIN 丢数据排查与避坑指南

MySQL/PostgreSQL 迁移金仓 KES:LEFT JOIN 丢数据排查与避坑指南 前言前阵子有个团队把订单系统从 MySQL 搬到金仓 KingbaseES下面统一叫 KES结构转完了SQL 也改完了回归一路过。结果上线第二天财务找过来说对账报表少了好几个客户。查了一圈最后定位到一句看起来很普通的查询问题出在 LEFT JOIN 上。这篇就把这个坑掰开揉碎讲一下——它怎么产生的、在 KES 里怎么亲手验证以及上线前怎么把它拦住。目录前言一、先看翻车现场二、LEFT JOIN 到底保的是什么三、真凶右表条件触发了外连接消除四、动手验证4.1 先备一桌数据4.2 同一份数据三种写法4.3 用 EXPLAIN 逮现行4.4 再补一锤看真实行数五、报表里最容易翻车的地方LEFT JOIN 配 COUNT六、迁移到 KES 还要注意的几个坑MySQL 大小写不敏感KES 默认敏感隐式类型转换KES 比 MySQL 较真空串不等于 NULL顺带提一下从 Oracle 来的 ()七、修法条件别放错地方八、上线前的自查清单写在最后一、先看翻车现场报表背后的 SQL 长这样SELECTc.cust_name,o.order_no,o.amountFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAID;需求其实挺好懂把所有客户都列出来每人带上自己已支付PAID的订单有些人可能压根没下过单或者只下过未支付的那这种人客户信息也得留着订单那几列空着就行。SQL 里写得明明白白是 LEFT JOIN按道理左表一行都漏不掉。可一跑——好家伙没订单的、只有未支付订单的客户全没了。当时开发第一反应是KES 是不是有 bug。这里先把结论撂下真不是 KES 的问题。这条 SQL 你原封不动扔到 MySQL、Oracle 里一样少这几行。根子在 SQL 自己的语义上只不过迁移那阵子做了回归比对才把这个一直潜伏的 bug 给照出来。下面慢慢拆。二、LEFT JOIN 到底保的是什么很多人有个下意识的认知觉得只要 SQL 里写了 LEFT JOIN左表的行就稳了。其实只对了一半。LEFT JOIN 那句左表全保留的承诺只认它自己 ON 后面那个条件。WHERE 不归它管——WHERE 是等连接做完之后再对结果做的一次筛选它分不清什么外连接内连接。可以这么想LEFT JOIN 就像食堂打饭你来了我就给你配菜没菜可配的也给你个空盘子WHERE 呢是门口的保安不管你盘子里有没有菜不达标就不放进去。麻烦就在这——当 WHERE 里冒出来一个专门冲着右表也就是 orders会被填 NULL 的那一侧去的条件时那些靠 LEFT JOIN 勉强留下、右表是 NULL 的行一算NULL PAID得到的是未知自然就被保安挡外头了。FROM customers -- 先把左表拿来 LEFT JOIN orders ON ... -- 连一下左表全留右表没匹配的补 NULL WHERE o.status PAID -- 再筛右表是 NULL 的行条件算出来 UNKNOWN被过滤数据就丢在最后这一步。三、真凶右表条件触发了外连接消除这现象有个名字叫外连接消除Outer Join Elimination通俗讲就是外连接被优化器偷偷改写成了内连接。道理其实挺朴素的。只要 WHERE 里出现一个针对右表、并且天生排斥空值的条件——比如o.status PAID、o.amount 0、o.order_id IS NOT NULL——优化器就琢磨这一侧反正不可能有 NULL有的话早被 WHERE 干掉了那这 LEFT JOIN 跟 INNER JOIN 还有啥区别于是它顺手改写了一下-- 你写的看着像外连接SELECTc.cust_name,o.order_noFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAID;-- 优化器眼里的等价形式其实就是内连接SELECTc.cust_name,o.order_noFROMcustomers cINNERJOINorders oONc.cust_ido.cust_idWHEREo.statusPAID;重点在这这步改写不动结果一行不多一行不少。优化器没改你的语义只是把一个挂着外连接名头、其实早没作用的写法还原成本来面目顺便让执行计划跑得快点。所以真相就一句——这几行数据本来就该丢不是 KES 给弄没的。优化器只不过比你坦白直接告诉你这 LEFT JOIN 压根没起作用。想通这个你也就理解了为啥同一条 SQL 在老库 MySQL 里也少数据只是那会儿数据少、又没人挨个对就一直没被发现。四、动手验证讲道理不如动手。我们在 KES 里建张小表让数据丢一回给你看再用 EXPLAIN 把优化器这步操作逮住。4.1 先备一桌数据留意一下 3 号客户王五他名下一笔订单都没有就是待会儿要消失的那位。CREATETABLEcustomers(cust_idINTPRIMARYKEY,cust_nameVARCHAR(50));CREATETABLEorders(order_idINTPRIMARYKEY,cust_idINT,amountNUMERIC(10,2),statusVARCHAR(10));INSERTINTOcustomersVALUES(1,张三),(2,李四),(3,王五);INSERTINTOordersVALUES(101,1,100.00,PAID),(102,1,50.00,UNPAID),(103,2,200.00,PAID);-- 王五没订单4.2 同一份数据三种写法写法 A纯 LEFT JOIN不去过滤右表客户全在SELECTc.cust_name,o.order_no,o.statusFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_id;cust_name | order_no | status ----------------------------- 张三 | 101 | PAID 张三 | 102 | UNPAID 李四 | 103 | PAID 王五 | (null) | (null)王五保住了订单那列给他填 NULL。写法 B把statusPAID挪到 WHERE 里这就是翻车的那个写法SELECTc.cust_name,o.order_no,o.statusFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAID;cust_name | order_no | status ----------------------------- 张三 | 101 | PAID 李四 | 103 | PAID王五没了张三那条未支付的也跟着没了。写法 C条件放回 ONSELECTc.cust_name,o.order_no,o.statusFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idANDo.statusPAID;cust_name | order_no | status ----------------------------- 张三 | 101 | PAID 李四 | 103 | PAID 王五 | (null) | (null)王五回来了。这才是列出所有客户、带上已支付订单该有的样子。4.3 用 EXPLAIN 逮现行写法 B 到底是不是被改成了内连接EXPLAIN 一跑就知道。EXPLAINSELECTc.cust_name,o.order_noFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAID;QUERY PLAN -------------------------------------------------------------- Hash Join Hash Cond: (c.cust_id o.cust_id) - Seq Scan on customers c - Hash - Seq Scan on orders o Filter: (status PAID::text)看第一行是Hash Join没有 Left。再对比写法 A不写 WHERE外连接还活着EXPLAINSELECTc.cust_name,o.order_noFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_id;QUERY PLAN -------------------------------------------------------------- Hash Left Join Hash Cond: (c.cust_id o.cust_id) - Seq Scan on customers c - Hash - Seq Scan on orders o这回是Hash Left Join带着 Left。信号其实挺明显只要发现我明明写的 LEFT JOIN计划里却是个没 Left 的内连接基本就能断定——哪个冲着右表的 WHERE 条件把外连接给消除掉了。嵌套循环和归并连接同理Nested Loop Left Join会变成Nested LoopMerge Left Join会变成Merge Join。4.4 再补一锤看真实行数要是觉得看节点名还不够直观那就上 ANALYZE看实际跑出来的行数EXPLAINANALYZESELECTc.cust_name,o.order_noFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAID;QUERY PLAN -------------------------------------------------------------- Hash Join ( ... ) (actual ... rows2 ...) Hash Cond: (c.cust_id o.cust_id) - Seq Scan on customers c (actual rows3 ...) - Hash - Seq Scan on orders o (actual rows3 ...) Filter: (status PAID::text) Rows Removed by Filter: 1左表明明扫出 3 行最后连接只吐了 2 行少掉那一行就是王五。actual rows 一比丢没丢心里就有数了。五、报表里最容易翻车的地方LEFT JOIN 配 COUNT做报表的同学对这种写法肯定不陌生LEFT JOIN 接一个 COUNT。需求通常长这样——统计每个客户有几笔已支付订单没买过的也显示个 0。条件放 ON 的时候是正常的SELECTc.cust_name,COUNT(o.order_id)ASpaid_cntFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idANDo.statusPAIDGROUPBYc.cust_nameORDERBYc.cust_name;cust_name | paid_cnt --------------------- 张三 | 1 李四 | 1 王五 | 0COUNT 数的是右表非空的行王五没匹配上自然算 0没问题。可一旦又把statusPAID顺手塞回 WHERE王五就又消失了这回连统计成 0 的资格都没了SELECTc.cust_name,COUNT(o.order_id)ASpaid_cntFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAIDGROUPBYc.cust_name;cust_name | paid_cnt --------------------- 张三 | 1 李四 | 1这里有个细节迁移之后要是发现统计数字对不上先别急着翻数据——先看看 COUNT(*) 和 COUNT(右表某列) 有没有用混。前者数所有行后者只数右表非空的那部分口径完全不一样。六、迁移到 KES 还要注意的几个坑WHERE 和 ON 这事是标准 SQL 的通病换哪家库都一样。但从 MySQL 或者 PostgreSQL 搬到 KES还有几处差异会把这问题放大让人觉得 LEFT JOIN 更容易丢数据得单拎出来说说。MySQL 大小写不敏感KES 默认敏感这个踩的人最多。MySQL 的字符串列默认走大小写不敏感的排序规则像utf8mb4_general_ci这种下面这条能匹配上 ‘PAID’WHEREo.statuspaid-- MySQL 里能命中 PAIDKES 默认是大小写敏感的paid PAID直接就不成立。右表匹配不上补个 NULL再被 WHERE 一挡整行又没了。修起来有几招-- 转小写再比WHERElower(o.status)paid-- 或者用不区分大小写的匹配WHEREo.statusILIKEpaid更省心的办法是在迁移那阵就把这类枚举值统一成大写或小写从根上断了歧义。隐式类型转换KES 比 MySQL 较真MySQL 在连接条件、WHERE 里对跨类型比较特别宽容一个 INT 列跟字符串 ‘123’ 也能比对上-- MySQLa.id 是 INTb.code 是 VARCHAR 123照样匹配FROMaJOINbONa.idb.codeKES 在这上面就较真多了字符型和数值型混着用轻的匹配率下降重的直接报错表现出来还是右表匹配不上、数据变少。稳妥起见显式把类型对齐FROMaJOINbONa.idb.code::int空串不等于 NULL有些从 MySQL 迁过来的数据没填的地方存的是空字符串不是 NULL。你要是写WHERE o.remark IS NULL在 KES 里对空串是不命中的——空串它不是 NULL。排查的时候得把空串也带上WHEREo.remarkISNULLORo.remark顺带提一下从 Oracle 来的 ()要是源头是 OracleKES 是兼容 () 外连接写法的。但这符号特别容易写错多条件的时候每个条件都得加 ()还不能跟 OR、IN 搭一起搞不好就又变成本想外连接、结果成了内连接。迁移的时候建议直接全改成LEFT JOIN ... ON (...)干净也好维护。七、修法条件别放错地方口诀就一句想过滤右表、又想保住左表的条件放 ON真打算从结果里删掉整行的才放 WHERE。条件放 ON 是最常用的SELECTc.cust_name,o.order_noFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idANDo.statusPAIDANDo.amount100;右表的筛选逻辑要是比较复杂就先在子查询里筛干净再连可读性好很多SELECTc.cust_name,t.order_noFROMcustomers cLEFTJOIN(SELECTcust_id,order_noFROMordersWHEREstatusPAIDANDamount100)tONc.cust_idt.cust_id;还有一种情况业务上希望右表是空也算满足条件那就得显式把 NULL 处理一下SELECTc.cust_name,o.order_noFROMcustomers cLEFTJOINorders oONc.cust_ido.cust_idWHEREo.statusPAIDORo.statusISNULL;八、上线前的自查清单迁移的回归流程里把下面这些事安排上后面能省掉大量排查时间。先把所有 LEFT JOIN 过一遍看 WHERE 里有没有引用右表的列有的话确认是不是真打算因为这个条件丢掉左表的行。核心那几条报表 SQL顺手拿 EXPLAIN 跑一下只要计划里 LEFT JOIN 变成了不带 Left 的内连接就重点复核。别只盯着跑不报错——同一份数据迁移前后对核心 SQL 做结果比对行数和抽样内容都得对上。这块最容易被忽略但也最能提前把问题兜住。数据层面的几个点也别落下MySQL 那边大小写不敏感的列在 KES 这边给个明确的大小写策略JOIN 和 WHERE 里做比较的两边类型显式对齐别让隐式转换偷偷改命中率空串和 NULL 要摸一遍确认 IS NULL 不会漏掉空串数据。聚合那块单独提一下COUNT(*) 和 COUNT(右表列) 别用混前者数所有行后者只数右表非空的部分。要是源头是 Oracle() 统一改成标准 LEFT JOIN。SQL 多到一条条看不过来的话先让脚本把嫌疑大的挑出来再人工细看# 扫一遍代码把带 LEFT JOIN 的语句都列出来grep-rniEleft[[:space:]](outer[[:space:]])?join\src/--include*.sql--include*.xml--include*.java# MyBatis 的 XML 里最爱藏这种 SQL单独盯一下grep-rniEleft[[:space:]](outer[[:space:]])?join\src/main/resources/mapper/--include*.xml写在最后说到底LEFT JOIN 丢数据这事根子是过滤条件放错了地方——写在了 WHERE 里又恰好作用在会被填 NULL 的那一侧于是 KES 做了外连接消除把外连接改成了内连接左表没匹配上的行就跟着没了。这不是 KES 的锅标准 SQL 就这么定义的MySQL、PostgreSQL、Oracle 都一个样只是迁移时的回归测试把它抖了出来。排查的时候EXPLAIN 是最好用的家伙连接节点从Hash Left Join变成Hash Join就是外连接被消除的信号。真正的坑除了 WHERE 和 ON主要集中在大小写敏感、隐式类型转换、空串和 NULL、还有 () 这几样上按前面那份清单逐个过一遍基本就稳了。迁移遇到问题别上来就怀疑数据库先 EXPLAIN 看一眼——多数时候优化器比咱们的直觉要诚实。