前言
前阵子有个团队把订单系统从 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 > 0、o.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 Loop,Merge 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 看一眼------多数时候,优化器比咱们的直觉要诚实。