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

前言

前阵子有个团队把订单系统从 MySQL 搬到金仓 KingbaseES(下面统一叫 KES),结构转完了,SQL 也改完了,回归一路过。结果上线第二天,财务找过来说对账报表少了好几个客户。
查了一圈,最后定位到一句看起来很普通的查询,问题出在 LEFT JOIN 上。这篇就把这个坑掰开揉碎讲一下------它怎么产生的、在 KES 里怎么亲手验证,以及上线前怎么把它拦住。

目录

  • 前言
    • 一、先看翻车现场
    • [二、LEFT JOIN 到底保的是什么](#二、LEFT JOIN 到底保的是什么)
    • 三、真凶:右表条件触发了"外连接消除"
    • 四、动手验证
      • [4.1 先备一桌数据](#4.1 先备一桌数据)
      • [4.2 同一份数据,三种写法](#4.2 同一份数据,三种写法)
      • [4.3 用 EXPLAIN 逮现行](#4.3 用 EXPLAIN 逮现行)
      • [4.4 再补一锤:看真实行数](#4.4 再补一锤:看真实行数)
    • [五、报表里最容易翻车的地方:LEFT JOIN 配 COUNT](#五、报表里最容易翻车的地方:LEFT JOIN 配 COUNT)
    • [六、迁移到 KES 还要注意的几个坑](#六、迁移到 KES 还要注意的几个坑)
      • [MySQL 大小写不敏感,KES 默认敏感](#MySQL 大小写不敏感,KES 默认敏感)
      • [隐式类型转换,KES 比 MySQL 较真](#隐式类型转换,KES 比 MySQL 较真)
      • [空串不等于 NULL](#空串不等于 NULL)
      • [顺带提一下从 Oracle 来的 (+)](#顺带提一下从 Oracle 来的 (+))
    • 七、修法:条件别放错地方
    • 八、上线前的自查清单
    • 写在最后

一、先看翻车现场

报表背后的 SQL 长这样:

sql 复制代码
SELECT c.cust_name, o.order_no, o.amount
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID';

需求其实挺好懂:把所有客户都列出来,每人带上自己已支付(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 > 0o.order_id IS NOT NULL------优化器就琢磨:这一侧反正不可能有 NULL,有的话早被 WHERE 干掉了,那这 LEFT JOIN 跟 INNER JOIN 还有啥区别?

于是它顺手改写了一下:

sql 复制代码
-- 你写的,看着像外连接
SELECT c.cust_name, o.order_no
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID';

-- 优化器眼里的等价形式,其实就是内连接
SELECT c.cust_name, o.order_no
FROM   customers c
INNER JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID';

重点在这:这步改写不动结果,一行不多一行不少。优化器没改你的语义,只是把一个挂着外连接名头、其实早没作用的写法还原成本来面目,顺便让执行计划跑得快点。

所以真相就一句------这几行数据本来就该丢,不是 KES 给弄没的。优化器只不过比你坦白,直接告诉你这 LEFT JOIN 压根没起作用。

想通这个,你也就理解了,为啥同一条 SQL 在老库 MySQL 里也少数据,只是那会儿数据少、又没人挨个对,就一直没被发现。

四、动手验证

讲道理不如动手。我们在 KES 里建张小表,让数据丢一回给你看,再用 EXPLAIN 把优化器这步操作逮住。

4.1 先备一桌数据

留意一下 3 号客户"王五",他名下一笔订单都没有,就是待会儿要消失的那位。

sql 复制代码
CREATE TABLE customers (
    cust_id   INT PRIMARY KEY,
    cust_name VARCHAR(50)
);

CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    cust_id  INT,
    amount   NUMERIC(10,2),
    status   VARCHAR(10)
);

INSERT INTO customers VALUES (1,'张三'), (2,'李四'), (3,'王五');

INSERT INTO orders VALUES
    (101, 1, 100.00, 'PAID'),
    (102, 1,  50.00, 'UNPAID'),
    (103, 2, 200.00, 'PAID');
-- 王五没订单

4.2 同一份数据,三种写法

写法 A,纯 LEFT JOIN,不去过滤右表,客户全在:

sql 复制代码
SELECT c.cust_name, o.order_no, o.status
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id;
复制代码
 cust_name | order_no | status
-----------+----------+--------
 张三      | 101      | PAID
 张三      | 102      | UNPAID
 李四      | 103      | PAID
 王五      | (null)   | (null)

王五保住了,订单那列给他填 NULL。

写法 B,把 status='PAID' 挪到 WHERE 里,这就是翻车的那个写法:

sql 复制代码
SELECT c.cust_name, o.order_no, o.status
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID';
复制代码
 cust_name | order_no | status
-----------+----------+--------
 张三      | 101      | PAID
 李四      | 103      | PAID

王五没了,张三那条未支付的也跟着没了。

写法 C,条件放回 ON:

sql 复制代码
SELECT c.cust_name, o.order_no, o.status
FROM   customers c
LEFT JOIN orders o
       ON c.cust_id = o.cust_id
      AND o.status = 'PAID';
复制代码
 cust_name | order_no | status
-----------+----------+--------
 张三      | 101      | PAID
 李四      | 103      | PAID
 王五      | (null)   | (null)

王五回来了。这才是"列出所有客户、带上已支付订单"该有的样子。

4.3 用 EXPLAIN 逮现行

写法 B 到底是不是被改成了内连接?EXPLAIN 一跑就知道。

sql 复制代码
EXPLAIN
SELECT c.cust_name, o.order_no
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID';
复制代码
                    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,外连接还活着):

sql 复制代码
EXPLAIN
SELECT c.cust_name, o.order_no
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.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,看实际跑出来的行数:

sql 复制代码
EXPLAIN ANALYZE
SELECT c.cust_name, o.order_no
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID';
复制代码
                          QUERY PLAN
--------------------------------------------------------------
 Hash Join ( ... ) (actual ... rows=2 ...)
   Hash Cond: (c.cust_id = o.cust_id)
   ->  Seq Scan on customers c (actual rows=3 ...)
   ->  Hash
         ->  Seq Scan on orders o (actual rows=3 ...)
               Filter: (status = 'PAID'::text)
               Rows Removed by Filter: 1

左表明明扫出 3 行,最后连接只吐了 2 行,少掉那一行就是王五。actual rows 一比,丢没丢心里就有数了。

五、报表里最容易翻车的地方:LEFT JOIN 配 COUNT

做报表的同学对这种写法肯定不陌生:LEFT JOIN 接一个 COUNT。需求通常长这样------统计每个客户有几笔已支付订单,没买过的也显示个 0。

条件放 ON 的时候,是正常的:

sql 复制代码
SELECT c.cust_name, COUNT(o.order_id) AS paid_cnt
FROM   customers c
LEFT JOIN orders o
       ON c.cust_id = o.cust_id
      AND o.status = 'PAID'
GROUP BY c.cust_name
ORDER BY c.cust_name;
复制代码
 cust_name | paid_cnt
-----------+----------
 张三      | 1
 李四      | 1
 王五      | 0

COUNT 数的是右表非空的行,王五没匹配上,自然算 0,没问题。

可一旦又把 status='PAID' 顺手塞回 WHERE,王五就又消失了,这回连统计成 0 的资格都没了:

sql 复制代码
SELECT c.cust_name, COUNT(o.order_id) AS paid_cnt
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID'
GROUP BY c.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':

sql 复制代码
WHERE o.status = 'paid'      -- MySQL 里能命中 'PAID'

KES 默认是大小写敏感的,'paid' = 'PAID' 直接就不成立。右表匹配不上,补个 NULL,再被 WHERE 一挡,整行又没了。修起来有几招:

sql 复制代码
-- 转小写再比
WHERE lower(o.status) = 'paid'
-- 或者用不区分大小写的匹配
WHERE o.status ILIKE 'paid'

更省心的办法是在迁移那阵就把这类枚举值统一成大写或小写,从根上断了歧义。

隐式类型转换,KES 比 MySQL 较真

MySQL 在连接条件、WHERE 里对跨类型比较特别宽容,一个 INT 列跟字符串 '123' 也能比对上:

sql 复制代码
-- MySQL:a.id 是 INT,b.code 是 VARCHAR '123',照样匹配
FROM a JOIN b ON a.id = b.code

KES 在这上面就较真多了,字符型和数值型混着用,轻的匹配率下降,重的直接报错,表现出来还是"右表匹配不上、数据变少"。稳妥起见,显式把类型对齐:

sql 复制代码
FROM a JOIN b ON a.id = b.code::int

空串不等于 NULL

有些从 MySQL 迁过来的数据,"没填"的地方存的是空字符串,不是 NULL。你要是写 WHERE o.remark IS NULL,在 KES 里对空串是不命中的------空串它不是 NULL。排查的时候得把空串也带上:

sql 复制代码
WHERE o.remark IS NULL OR o.remark = ''

顺带提一下从 Oracle 来的 (+)

要是源头是 Oracle,KES 是兼容 (+) 外连接写法的。但这符号特别容易写错,多条件的时候每个条件都得加 (+),还不能跟 OR、IN 搭一起,搞不好就又变成"本想外连接、结果成了内连接"。迁移的时候建议直接全改成 LEFT JOIN ... ON (...),干净,也好维护。

七、修法:条件别放错地方

口诀就一句:想过滤右表、又想保住左表的,条件放 ON;真打算从结果里删掉整行的,才放 WHERE。

条件放 ON 是最常用的:

sql 复制代码
SELECT c.cust_name, o.order_no
FROM   customers c
LEFT JOIN orders o
       ON c.cust_id = o.cust_id
      AND o.status = 'PAID'
      AND o.amount >= 100;

右表的筛选逻辑要是比较复杂,就先在子查询里筛干净再连,可读性好很多:

sql 复制代码
SELECT c.cust_name, t.order_no
FROM   customers c
LEFT JOIN (
    SELECT cust_id, order_no
    FROM   orders
    WHERE  status = 'PAID' AND amount >= 100
) t ON c.cust_id = t.cust_id;

还有一种情况,业务上希望"右表是空也算满足条件",那就得显式把 NULL 处理一下:

sql 复制代码
SELECT c.cust_name, o.order_no
FROM   customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE  o.status = 'PAID' OR o.status IS NULL;

八、上线前的自查清单

迁移的回归流程里,把下面这些事安排上,后面能省掉大量排查时间。

先把所有 LEFT JOIN 过一遍,看 WHERE 里有没有引用右表的列;有的话,确认是不是真打算因为这个条件丢掉左表的行。核心那几条报表 SQL,顺手拿 EXPLAIN 跑一下,只要计划里 LEFT JOIN 变成了不带 Left 的内连接,就重点复核。

别只盯着"跑不报错"------同一份数据,迁移前后对核心 SQL 做结果比对,行数和抽样内容都得对上。这块最容易被忽略,但也最能提前把问题兜住。

数据层面的几个点也别落下:MySQL 那边大小写不敏感的列,在 KES 这边给个明确的大小写策略;JOIN 和 WHERE 里做比较的两边,类型显式对齐,别让隐式转换偷偷改命中率;空串和 NULL 要摸一遍,确认 IS NULL 不会漏掉空串数据。

聚合那块单独提一下,COUNT(*) 和 COUNT(右表列) 别用混,前者数所有行,后者只数右表非空的部分。要是源头是 Oracle,(+) 统一改成标准 LEFT JOIN。

SQL 多到一条条看不过来的话,先让脚本把嫌疑大的挑出来,再人工细看:

bash 复制代码
# 扫一遍代码,把带 LEFT JOIN 的语句都列出来
grep -rniE "left[[:space:]]+(outer[[:space:]]+)?join" \
    src/ --include="*.sql" --include="*.xml" --include="*.java"

# MyBatis 的 XML 里最爱藏这种 SQL,单独盯一下
grep -rniE "left[[: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 看一眼------多数时候,优化器比咱们的直觉要诚实。

相关推荐
2401_8414956420 小时前
【数据结构】B+树
数据结构·数据库·c++·b+树·概念·结构·操作原理
Hardworking6661 天前
第7章 软硬件系统集成
数据库·软硬件系统集成
名字还没想好☜1 天前
Go 的 time.Ticker 陷阱:定时任务里被忽略的内存泄漏与正确关闭
java·数据库·golang·go·定时器
关关长语1 天前
Wsl解决MySQL容器跨域权限问题
mysql·docker·容器
熊猫钓鱼>_>1 天前
Redis 突发缓存穿透:一次完整的定位复盘
数据库·人工智能·redis·缓存·ai·agent·智能
wenb1n1 天前
MySQL诊断系列(3/6):索引分析——5个SQL揪出“僵尸索引”
数据库·人工智能·编程语言
这个DBA有点耶1 天前
索引碎片与统计信息维护:执行计划突然变差的隐形杀手
mysql·程序员·架构
段一凡-华北理工大学1 天前
向量数据库实战:选型、调优与落地~系列文章03:向量相似度算法全解:余弦、欧氏、内积,到底该用哪个?
大数据·数据库·人工智能·算法·机器学习·向量相似度·高炉炼铁
ClouGence1 天前
CloudDM 数据库管理平台,全新 UI,更清晰、更高效!
数据库·开源