
前两天帮着看一个从 MySQL 迁过来的老需求业务原话是这么说的“把所有订单都列出来如果这笔订单有审核中的售后申请把售后原因也带出来”。这种需求太常见了我第一反应就是LEFT JOIN——左表订单表保底右表售后表能匹配上就带出来匹配不上就是 NULL教科书式写法。结果这条 SQL 跑出来的结果让我在屏幕前愣了好几秒。这篇就把这次踩坑的过程整个记下来怎么发现的、怎么排查的、最后怎么定位到根子上、KingbaseESKES这边要怎么改。环境是KES V009R001C010连接用普通业务账号app_user不是管理员ksql-h127.0.0.1-p54321-Uapp_user-dapp_db场景还原订单表 售后表先把场景搭起来。两张表订单表t_pit_order售后服务单表t_pit_service一笔订单可能有 0 条、1 条甚至多条售后记录createtableapp_schema.t_pit_order(order_idintegerprimarykey,order_novarchar(40)notnullunique,user_idintegernotnull,order_statusvarchar(20)notnull,amountnumeric(10,2)notnull,created_attimestampnotnull);createtableapp_schema.t_pit_service(service_idintegerprimarykey,order_novarchar(40)notnull,service_statusvarchar(20)notnull,reasonvarchar(100),created_attimestampnotnull);插入 5 笔订单全部是已支付状态insertintoapp_schema.t_pit_order(order_id,order_no,user_id,order_status,amount,created_at)values(1,PIT20260701001,1001,paid,199.00,2026-06-28 09:00:00),(2,PIT20260701002,1002,paid,88.00,2026-06-28 10:00:00),(3,PIT20260701003,1003,paid,256.00,2026-06-28 11:00:00),(4,PIT20260701004,1004,paid,320.00,2026-06-28 12:00:00),(5,PIT20260701005,1005,paid,150.00,2026-06-28 13:00:00);售后记录只给两笔订单配了订单 1 是审核中订单 3 是已完成。订单 2、4、5 压根没提交过售后insertintoapp_schema.t_pit_service(service_id,order_no,service_status,reason,created_at)values(1,PIT20260701001,审核中,尺码不合适,2026-06-29 09:00:00),(2,PIT20260701003,已完成,质量问题,2026-06-29 10:00:00);这个数据设计不是随便凑的特意留了三种情况有售后且是审核中的订单1、有售后但不是审核中的订单3、压根没售后的订单2、4、5。等下就知道为什么这三种情况都得覆盖到。第一版 SQL看起来完全没毛病按业务需求写的第一版就是标准LEFT JOIN加个过滤条件selecto.order_no,o.order_status,o.amount,s.service_status,s.reasonfromapp_schema.t_pit_order oleftjoinapp_schema.t_pit_service sons.order_noo.order_nowheres.service_status审核中;我当时的预期是5 笔订单一条不少只有订单 1 的service_status、reason两列有值其余 4 行应该是 NULL。左连接嘛左表保底这是常识。实际跑出来是这样只有 1 行。订单 2、3、4、5 全部消失了包括压根没申请过售后的订单 2、4、5也包括有售后记录但状态是已完成的订单 3。说白了这条 SQL 表现得完全像一个INNER JOINLEFT这个关键字像是白写了。这时候脑子里冒出来的第一个念头是是不是数据插错了第二个念头是是不是 KES 这边 LEFT JOIN 有兼容性问题。事后证明这两个念头都是错的但排查的时候没少走弯路就顺着这个思路记录下来。先排除数据问题主表到底是不是 5 条最笨也最靠谱的办法先确认主表本身没问题selectcount(*)astotal_orderfromapp_schema.t_pit_order;selecto.order_no,o.order_statusfromapp_schema.t_pit_order oleftjoinapp_schema.t_pit_service sons.order_noo.order_no;count(*)确认是 5 条去掉WHERE之后重新跑一遍连接5 笔订单一条不少地全出来了。这一步很关键它证明了两件事数据本身没问题ON里的连接条件也没写错。问题百分之百出在加了WHERE s.service_status 审核中之后。到这一步KES 的 LEFT JOIN 有 bug这个猜测基本可以排除了——如果真是语法层面的问题去掉 WHERE 也应该有问题但它没有。真正诡异的地方在于只有加上那一句过滤行为才会变。定位执行计划里根本没有 LEFT JOIN翻了下之前存的一些技术资料才想起来这种加了 WHERE 之后 LEFT JOIN 变内连接的现象有个名字叫外连接消除Outer Join Elimination。先不管名字直接上EXPLAIN ANALYZE看看执行计划里到底发生了什么explainanalyzeselecto.order_no,o.order_status,o.amount,s.service_status,s.reasonfromapp_schema.t_pit_order oleftjoinapp_schema.t_pit_service sons.order_noo.order_nowheres.service_status审核中;看执行计划的第一行Hash Join (cost1.04..2.12 rows1 width49)。注意这里写的是Hash Join不是Hash Left Join。KES 的执行计划里如果一个连接真的按外连接执行通常会明确标出Left这个字样这里没有说明这条 SQL 提交上去之后优化器压根就没按外连接去跑它直接把整个查询当成了一个普通的内连接来处理。再往下看细节t_pit_service这张表被Seq Scan扫描带了个Filter: ((service_status)::text 审核中::text)后面还跟着一行Rows Removed by Filter: 1——这张表总共 2 行数据过滤之后只剩 1 行订单1那条审核中的记录订单3的已完成被过滤掉了。然后这 1 行拿去跟订单表做Hash Join订单表那边是全量 5 行参与哈希探测最终匹配上的只有 1 行。这就是问题的真相优化器把WHERE s.service_status 审核中这个条件理解成了只有t_pit_service里状态是审核中的行才有资格参与连接那些原本因为左连接才应该保留下来的、t_pit_service一侧是 NULL 的行订单2、4、5 没有售后记录订单3 的售后记录被过滤掉全都在这一步被连带着筛没了。原因很简单NULL 和 ‘审核中’ 做等值比较结果既不是真也不是假是未知在 WHERE 里未知等于被扔掉。既然这些左连接产生的 NULL 行反正都要被 WHERE 干掉那优化器算了一笔账外连接加这个过滤跟直接内连接加这个过滤结果集是完全一样的内连接开销更小于是它就悄悄把外连接换成了内连接去跑。这个换算过程不会报错、不会有任何提示SQL 文本里写的还是LEFT JOIN只是最终执行的已经不是那么回事了。这套换算规则背后其实有一套明确的判断标准比如什么样的条件一定会触发这种改写、什么样的条件不会细节挺多这里不整个铺开讲先把这次踩坑的现象和定位方法记牢——遇到LEFT JOIN 结果比预期少第一反应就是去看执行计划里连接节点到底是不是真的按外连接在跑。修复把过滤条件挪到 ON 里原因搞清楚了修复思路也就明确了真正要保留左连接语义过滤右表的条件不能写在 WHERE得写进 ONselecto.order_no,o.order_status,o.amount,s.service_status,s.reasonfromapp_schema.t_pit_order oleftjoinapp_schema.t_pit_service sons.order_noo.order_noands.service_status审核中;这次 5 行全部老老实实地回来了。只有订单 1 的service_status/reason有值订单 2、3、4、5 全是空。这里特别要盯一眼订单 3——它明明有一条售后记录“已完成”但因为不满足and s.service_status 审核中这个条件它的售后字段依然是空。这一点很能说明问题这不是运气好凑对了行数是逻辑真的对了。ON决定的是怎么连先按审核中这个条件筛一遍右表筛完了再拿去跟左表做外连接WHERE决定的是连完了再筛掉什么加在 WHERE 里就等于是连接做完之后才动手为时已晚NULL 行早被判了死刑。我自己的理解是这么记的只要是左连接右表相关的过滤条件除非目的就是顺便把左表也筛掉一批不然一律先考虑放 ON 里别图省事直接甩进 WHERE。顺手纠正一个容易搞混的需求查没有审核中售后的订单排查这条 SQL 的时候同事顺嘴问了一句“那如果我就是想查哪些订单没有审核中的售后是不是直接把等号换成!就行”——这也是个常见的误区赶紧一起验证了一下selecto.order_no,o.order_statusfromapp_schema.t_pit_order oleftjoinapp_schema.t_pit_service sons.order_noo.order_noands.service_status审核中wheres.service_idisnull;结果返回订单 2、3、4、5四条。注意订单 3 也在里面——它虽然有售后记录但状态是已完成在这次以审核中为条件的关联里等于没匹配上所以service_id也是 NULL也算进了没有审核中售后这一批。这条 SQL 的关键在于IS NULL判断的是右表整体有没有匹配上而不是某个值等不等于什么用它才能真正抓住外连接产生的空行。如果真按同事说的把等号换成不等号写进 WHERE会重新掉进最开始那个坑里这里就不重复验证了逻辑和前面一样。这次踩坑的收获整理成几条能直接抄进 Code Review 清单的东西看到LEFT/RIGHT JOIN后面又跟着WHERE 右表字段 xxx先停一下。这种写法十有八九是把过滤条件放错了地方先问自己一句这个条件是要筛掉左表一部分数据还是单纯想看右表某个值前者才适合放 WHERE。右表的过滤条件只要目的是保留外连接语义一律下推到 ON。这条不是看情况是默认动作。怀疑外连接有问题直接跑EXPLAIN ANALYZE别猜。看执行计划里连接节点是不是明确带着Left字样不带的话十有八九是被优化器改写了。查缺失用IS NULL别用不等号。不等号一样会被 WHERE 的 NULL 语义坑一遍IS NULL才是唯一可靠的判断没匹配上的方法。这个问题从 MySQL/PostgreSQL 迁移过来的人尤其容易中招因为写法上跟以前一模一样SQL 一个字没改行为却因为优化器的这个动作变了代码审查根本看不出来只能靠跑一遍执行计划确认。这条坑最恶心的地方就在这儿语法完全合法SQL 正常执行不报任何错误只是安安静静地把你以为该在的数据丢掉了。真要命的问题从来不是报错的那种是这种看起来什么都没发生的。