前兩天幫忙看一個從 MySQL 遷過來的老需求,業務原話是這麼說的:「把所有訂單都列出來,如果這筆訂單有審核中的售後申請,把售後原因也帶出來。」這種需求太常見了,我第一反應就是 LEFT JOIN——左表訂單表保底,右表售後表能匹配上就帶出來,匹配不上就是 NULL,教科書式寫法。結果這條 SQL 跑出來的結果,讓我在螢幕前愣了好幾秒。
這篇就把這次踩坑的過程整個記下來:怎麼發現的、怎麼排查的、最後怎麼定位到根子上、KingbaseES(KES)這邊要怎麼改。環境是 KES V009R001C010,連線用普通業務帳號 app_user,不是管理員:
css 代碼解讀複製代碼ksql -h 127.0.0.1 -p 54321 -U app_user -d app_db
先把場景搭起來。兩張表,訂單表 t_pit_order,售後服務單表 t_pit_service,一筆訂單可能有 0 條、1 條甚至多條售後紀錄:
sql 代碼解讀複製代碼create table app_schema.t_pit_order (
order_id integer primary key,
order_no varchar(40) not null unique,
user_id integer not null,
order_status varchar(20) not null,
amount numeric(10,2) not null,
created_at timestamp not null
);
create table app_schema.t_pit_service (
service_id integer primary key,
order_no varchar(40) not null,
service_status varchar(20) not null,
reason varchar(100),
created_at timestamp not null
);
插入 5 筆訂單,全部是已付款狀態:
sql 代碼解讀複製代碼insert into app_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 根本沒提交過售後:
sql 代碼解讀複製代碼insert into app_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)。等下就知道為什麼這三種情況都得覆蓋到。
按業務需求寫的第一版,就是標準 LEFT JOIN 加個過濾條件:
ini 代碼解讀複製代碼select
o.order_no,
o.order_status,
o.amount,
s.service_status,
s.reason
from app_schema.t_pit_order o
left join app_schema.t_pit_service s
on s.order_no = o.order_no
where s.service_status = '審核中';
我當時的預期是:5 筆訂單一條不少,只有訂單 1 的 service_status、reason 兩欄有值,其餘 4 行應該是 NULL。左連接嘛,左表保底,這是常識。
實際跑出來是這樣:

只有 1 行。 訂單 2、3、4、5 全部消失了,包括根本沒申請過售後的訂單 2、4、5,也包括有售後紀錄但狀態是「已完成」的訂單 3。說白了,這條 SQL 表現得完全像一個 INNER JOIN,LEFT 這個關鍵字像是白寫了。
這時候腦子裡冒出來的第一個念頭是「是不是資料插錯了」,第二個念頭是「是不是 KES 這邊 LEFT JOIN 有相容性問題」。事後證明這兩個念頭都是錯的,但排查的時候沒少走彎路,就順著這個思路記錄下來。
最笨也最可靠的辦法,先確認主表本身沒問題:
csharp 代碼解讀複製代碼select count(*) as total_order from app_schema.t_pit_order;
csharp 代碼解讀複製代碼select o.order_no, o.order_status
from app_schema.t_pit_order o
left join app_schema.t_pit_service s
on s.order_no = o.order_no;
count(*) 確認是 5 條,去掉 WHERE 之後重新跑一遍連接,5 筆訂單一條不少地全出來了。這一步很關鍵,它證明了兩件事:資料本身沒問題,ON 裡的連接條件也沒寫錯。問題百分之百出在加了 WHERE s.service_status = '審核中' 之後。
到這一步,「KES 的 LEFT JOIN 有 bug」這個猜測基本可以排除了——如果真是語法層面的問題,去掉 WHERE 也應該有問題,但它沒有。真正詭異的地方在於:只有加上那一句過濾,行為才會變。
翻了下之前存的一些技術資料才想起來,這種「加了 WHERE 之後 LEFT JOIN 變內連接」的現象有個名字,叫「外連接消除」(Outer Join Elimination)。先不管名字,直接上 EXPLAIN ANALYZE 看看執行計畫裡到底發生了什麼:
ini 代碼解讀複製代碼explain analyze
select
o.order_no,
o.order_status,
o.amount,
s.service_status,
s.reason
from app_schema.t_pit_order o
left join app_schema.t_pit_service s
on s.order_no = o.order_no
where s.service_status = '審核中';
看執行計畫的第一行:Hash Join (cost=1.04..2.12 rows=1 width=49)。注意,這裡寫的是 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 結果比預期少」,第一反應就是去看執行計畫裡連接節點到底是不是真的按外連接在跑。
原因搞清楚了,修復思路也就明確了:真正要保留左連接語義,過濾右表的條件不能寫在 WHERE,得寫進 ON:
ini 代碼解讀複製代碼select
o.order_no,
o.order_status,
o.amount,
s.service_status,
s.reason
from app_schema.t_pit_order o
left join app_schema.t_pit_service s
on s.order_no = o.order_no
and s.service_status = '審核中';
這次 5 行全部老老實實地回來了。只有訂單 1 的 service_status/reason 有值,訂單 2、3、4、5 全是空。這裡特別要盯一眼訂單 3——它明明有一條售後紀錄(「已完成」),但因為不滿足 and s.service_status = '審核中' 這個條件,它的售後欄位依然是空。這一點很能說明問題:這不是運氣好湊對了行數,是邏輯真的對了。ON 決定的是「怎麼連」,先按「審核中」這個條件篩一遍右表,篩完了再拿去跟左表做外連接;WHERE 決定的是「連完了再篩掉什麼」,加在 WHERE 裡就等於是連接做完之後才動手,為時已晚,NULL 行早被判了死刑。
我自己的理解是這麼記的:只要是左連接,右表相關的過濾條件,除非目的就是「順便把左表也篩掉一批」,不然一律先考慮放 ON 裡,別圖省事直接甩進 WHERE。
排查這條 SQL 的時候,同事順嘴問了一句:「那如果我就是想查哪些訂單沒有審核中的售後,是不是直接把等號換成 != 就行?」——這也是個常見的誤區,趕緊一起驗證了一下:
csharp 代碼解讀複製代碼select o.order_no, o.order_status
from app_schema.t_pit_order o
left join app_schema.t_pit_service s
on s.order_no = o.order_no
and s.service_status = '審核中'
where s.service_id is null;
結果回傳訂單 2、3、4、5,四條。注意訂單 3 也在裡面——它雖然有售後紀錄,但狀態是「已完成」,在這次以「審核中」為條件的關聯裡,等於沒匹配上,所以 service_id 也是 NULL,也算進了「沒有審核中售後」這一批。這條 SQL 的關鍵在於 IS NULL 判斷的是右表整體有沒有匹配上,而不是某個值等不等於什麼,用它才能真正抓住「外連接產生的空行」。如果真按同事說的把等號換成不等號寫進 WHERE,會重新掉進最開始那個坑裡,這裡就不重複驗證了,邏輯和前面一樣。
整理成幾條能直接抄進 Code Review 清單的東西:
LEFT/RIGHT JOIN 後面又跟著 WHERE 右表欄位 = xxx,先停一下。 這種寫法十有八九是把過濾條件放錯了地方,先問自己一句:這個條件是要「篩掉左表一部分資料」,還是單純想看右表某個值,前者才適合放 WHERE。EXPLAIN ANALYZE,別猜。 看執行計畫裡連接節點是不是明確帶著 Left 字樣,不帶的話十有八九是被優化器改寫了。IS NULL,別用不等號。 不等號一樣會被 WHERE 的 NULL 語義坑一遍,IS NULL 才是唯一可靠的判斷「沒匹配上」的方法。這條坑最煩的地方就在這裡:語法完全合法,SQL 正常執行,不報任何錯誤,只是安安靜靜地把你以為該在的資料丟掉了。真要命的問題從來不是報錯的那種,是這種「看起來什麼都沒發生的」。