報表少了 40%:那條從沒被質疑過的 LEFT JOIN,在遷 KES 時終於露餡了
上個週五吧,大概下午四點左右,營運那邊的人在群裡開始找我了。問的是這個事:「為什麼使用者報表今天的總數,比昨天少了快 40% 啊?」
我一開始想的是,是不是哪個定時任務沒跑成功。就去翻了日誌。翻了半天,沒看到有啥報錯。接著我又去看了那條核心的統計 SQL。語法看過去沒毛病,執行也沒拋異常。而且在我本地搭的測試環境裡面跑,出來的結果也是對的。但是生產的資料它就是少了。
最後搞了兩個小時,才找到原因。原因是什麼呢?是一條 LEFT JOIN 的寫法不對。它把最佳化器的外連接消除給觸發了,直接把一部分資料給過濾掉了,而且沒提示。更無奈的一個情況是,這條 SQL 在以前用的 MySQL 裡面,其實也是這麼跑的。只是說,以前根本就沒有人去仔細對過帳,所以這個問題就一直沒暴露出來。
這種坑吧,其實是我最近在做 MySQL 還有 PostgreSQL 往 KES 遷移的這個專案裡頭,碰到頻率最高的。情況往往是這樣的:SQL 語法能跑通,執行也不報錯,測試的時候你也看不出來,但是業務那邊的實際結果呢,它就是少了點東西。 那麼這篇文章,我就把 LEFT JOIN 這個事兒從前往後拆解一下。順便列個避坑的清單。也就是為了讓大家以後碰到這個情況,能有個參考。 
@[toc]
先說一個我實際碰到的事兒吧。那個專案的背景是一個省級的政務平臺。他們以前用的是 MySQL 8.0。最近這段時間呢,是要整體切到金倉資料庫 KES 去,版本是 V9R1C10。結果業務剛上線的第一週,營運那邊就說了,「使用者訂單綜合報表」裡面的資料總條數,比以前的老系統少了差不多 40%。
我當時把那條有問題的 SQL 拿過來,做了一下去識別化,簡化一下差不多就是下面這樣:
sql 代碼解讀複製代碼-- 業務需求:查詢所有使用者,並附帶展示其"已完成狀態"的訂單資訊;
-- 如果使用者沒有已完成訂單,也需要顯示該使用者,訂單相關欄位為 NULL。
SELECT u.user_id,
u.user_name,
o.order_id,
o.order_amount,
o.order_status
FROM t_user u
LEFT JOIN t_order o
ON u.user_id = o.user_id
WHERE o.order_status = 'FINISHED';
你光看這段程式碼的話,這條 SQL 你丟到 MySQL 裡面,或者 PostgreSQL、KES 裡面,它都能跑出來,語法這關是沒問題的。但是呢,它跑出來的結果集大小,在 MySQL 8.0 跟金倉 KES 裡面居然是完全一樣的。對,你沒看錯。這裡的問題,其實不是「遷移完了之後兩邊行為不一樣」。真正的問題是,這條 SQL 它一開始寫出來就是錯的。只不過是在以前的老系統裡面,做業務的人一直沒發現這個問題而已。
後來讓我確定這裡面有問題的,是我在老系統裡看到還有一條配套的對帳 SQL 在跑:
sql 代碼解讀複製代碼-- 原系統另一處對帳邏輯
SELECT COUNT(DISTINCT u.user_id)
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id;
這條 SQL 它查出來的就是全部的使用者數量。為什麼呢?因為它沒有在 WHERE 那裡去加任何 nullable-side 的條件。這樣的話,LEFT JOIN 本來的意思就還在。這就是為啥以前做業務的人總覺得「使用者總數這欄是對的」,但是那個報表卻一直「少資料」的原因了。其實就是這兩條 SQL 走了不一樣的邏輯路徑。只不過以前沒有人去順著這個邏輯去查過。
我先說結論吧:這事真不是 KES 自己最佳化得太猛了。而是最開始的那種寫法,它本身就有個問題,就是「外連接被偷偷消除了」這麼個語義上的毛病。其實 KES 跟 MySQL 還有 PG 在處理這條路徑的時候,做法是一模一樣的。只不過是因為做遷移的時候,大家的眼睛都盯著新的資料庫看。業務那邊就以為,哦,是新資料庫出毛病了。
要把這個坑說明白,有一件事得先說一下。就是在關係代數這個層面去看的話,你寫 LEFT JOIN 然後後面跟著個 WHERE 去過濾右表不能為空的條件,這玩意兒跟你直接寫 INNER JOIN 加上一樣的條件,它們倆是劃等號的。 最佳化器只要看出來這兩個東西是一樣的,它就會把外連接改成內連接去跑。這個操作呢,有個叫法,叫外連接消除。 
很多人覺得 SQL 的執行順序是這樣的:
vbnet 代碼解讀複製代碼FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
這個順序你要是只談「邏輯上的意思」,那確實是這樣。也就是說,先執行 LEFT JOIN。它會把左邊的表全部行都留著,右邊沒對上的那些行呢,就用 NULL 填進去。然後輪到 WHERE 了,再去對這個結果集做過濾。
那這就會有個問題了。WHERE o.order_status = 'FINISHED' 這個條件,如果碰到了那些「外連接補出來的 NULL 行」,會怎麼樣呢?
情況是這樣的,NULL = 'FINISHED' 在三值邏輯裡面,它出來的結果是個 Unknown。而 WHERE 呢,它看到 Unknown 的行,就直接給過濾掉了。這就導致一個結果,那些靠外連接補出來的 NULL 行,全給刷沒了。
那最後還剩下啥呢?剩下的就僅僅是那些「真正匹配上了,而且 order_status 也是 'FINISHED'」的行。你想想,這不就是 INNER JOIN 的意思嗎。
資料庫的最佳化器在算成本之前,它其實會先做一步 邏輯等價變換 的事情。它去掃一下 WHERE 那一串條件,看看有沒有能證明「外連接補的 NULL 行是不對的」這種條件。它判斷的規則,大概有這麼兩條:
拿我們這條 SQL 來說,o.order_status = 'FINISHED' 這一句,剛好這兩條都占上了。它用了右邊的欄位,而且它排斥 NULL。
於是呢,最佳化器就直接動手,把 LEFT JOIN 改成了 INNER JOIN。改完之後,它再往下走 Hash Join 或者是 Nested Loop 這些實際的執行路徑。
這就是資料不見了的真正原因:不是資料真沒了,而是按照 SQL 的語義來說,那些資料本來就不該在結果集裡面。
這裡得提一嘴,這事真不是某個國產資料庫自己「獨創」的玩法。基本上只要是支援 SQL:2008 標準的關係型資料庫,它都會去搞這個最佳化。你像 MySQL、PostgreSQL、Oracle、SQL Server,還有金倉 KES,它們全都會做外連接消除。因為它們都得按標準 SQL 引擎的那套規矩來。
唯一的區別就在於,最佳化器在什麼時候能「發現」這種等價的情況。比如說你用了子查詢,或者套了檢視,再或者用了 UDF 的時候,不同的資料庫,它認出來的能力是有差別的。這就導致了後面我們會說到的那種,在生產環境裡面特別難發現的坑。
如果單純從「符合 SQL 標準」角度看,上面那條 SQL 被最佳化成 INNER JOIN 完全沒錯。但從「遷移改造工程」角度看,這裡其實有一個非常關鍵的產品設計取捨——新庫到底應不應該複現舊庫的錯誤行為?
金倉 KES 在這裡的設計取捨我個人非常認可:嚴格遵守 SQL 標準語義,絕不「歪打正著」地為錯誤 SQL 兜底,同時提供極其完備的執行計畫視覺化能力,讓開發者在改造階段就能把語義陷阱暴露出來。
在金倉 KES 裡,我第一時間會用 EXPLAIN 或 EXPLAIN ANALYZE 去看執行計畫:
sql 代碼解讀複製代碼EXPLAIN ANALYZE
SELECT u.user_id, u.user_name, o.order_id, o.order_amount, o.order_status
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE o.order_status = 'FINISHED';
如果外連接被消除,返回的執行計畫裡,會看到關鍵字不是 Hash Left Join 或 Nested Loop Left Join,而是直接的 Hash Join(內連接)或 Nested Loop。僅這一個觀察點,就足以在遷移程式碼稽核階段發現絕大部分外連接消除案例。
text 代碼解讀複製代碼-- 被消除後的執行計畫關鍵行(示意)
Hash Join (cost=... rows=...)
Hash Cond: (u.user_id = o.user_id)
-> Seq Scan on t_user u
-> Hash
-> Seq Scan on t_order o
Filter: (order_status = 'FINISHED')
請注意上面這個 Filter: (order_status = 'FINISHED') 已經下推到了 t_order 的掃描節點,而 JOIN 節點直接標記為 Hash Join——外連接消失得乾乾淨淨。
有一種非常常見的業務寫法,是故意用 LEFT JOIN 找出「未匹配」的記錄,比如「哪些使用者從未下過訂單」:
sql 代碼解讀複製代碼SELECT u.user_id, u.user_name
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;
這條 SQL 在 KES 裡就不會被消除,原因很簡單:IS NULL 條件正是為了捕捉「外連接補的 NULL 行」。如果最佳化器強行把它改寫為內連接,業務語義直接被摧毀。 KES 的最佳化器在這裡的判定規則嚴守邊界:只要 WHERE 條件不是 null-rejecting,就不消除外連接。
這個細節說明什麼?說明 KES 的最佳化器實現是語義安全優先的,而不是「能最佳化就最佳化」的粗放模式。這一點在國產化遷移裡非常重要——它意味著你在舊系統裡寫對的 SQL,在 KES 裡也一定跑得對;寫錯的 SQL,KES 會用與主流資料庫一致的方式對待,不會因為「想顯得自己更相容」而犧牲標準。
回到本文開頭那條報表 SQL,業務方的真實意圖是「所有使用者都要出現,只是有匹配的已完成訂單時顯示訂單資訊」。 正確寫法是把過濾條件下推到 ON 子句裡:
sql 代碼解讀複製代碼-- 正確寫法:ON 子句裡過濾右表,左表所有行保留
SELECT u.user_id,
u.user_name,
o.order_id,
o.order_amount,
o.order_status
FROM t_user u
LEFT JOIN t_order o
ON u.user_id = o.user_id
AND o.order_status = 'FINISHED';
這裡的語義差別至關重要:ON 子句控制的是「如何連接」,WHERE 子句控制的是「最終留下哪些行」。 ON 裡加過濾,等價於「先把 t_order 過濾成只剩 FINISHED,再與 t_user 做外連接」——使用者表所有行完整保留,即便對應的訂單不滿足條件,也會以 NULL 出現在結果集裡。
這條改寫完,業務對帳立即回到正確狀態。
除了最典型的「WHERE 裡過濾右表欄位」,實際專案裡還有大量變種寫法會觸發外連接消除。下面這六個是我在近半年遷移專案裡反覆看到的高頻陷阱。
sql 代碼解讀複製代碼-- 反例:>、<、!=、LIKE 等對 NULL 都返回 Unknown
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE t2.amount > 100;
t2.amount > 100 對 NULL 值同樣返回 Unknown,觸發外連接消除。這類寫法在報表 SQL 裡極其常見。
sql 代碼解讀複製代碼-- 反例:函式結果同樣是 NULL
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE UPPER(t2.name) = 'A';
只要函式是「嚴格函式(strict function)」——即遇到 NULL 輸入返回 NULL 輸出——外連接依然會被消除。 KES 裡絕大多數系統函式都是嚴格函式。
sql 代碼解讀複製代碼-- 反例:t2.status 為 NULL 時不會命中任何列表值
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE t2.status IN ('A', 'B', 'C');
IN 本質上是多個 = 的 OR,NULL 與任何值 IN 判斷都是 Unknown。 同樣觸發消除。
sql 代碼解讀複製代碼-- 反例:AND 一旦包含 null-rejecting 條件,整體也變成 null-rejecting
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE t2.status = 'A' AND t2.other_col IS NULL;
看起來有 IS NULL 就「安全」了?不是的。整個 AND 表達式必須對 NULL 輸入至少有可能返回 True,才不會觸發消除。 這裡 t2.status = 'A' 已經把 NULL 全部 reject 掉了,導致整體依然被消除。
sql 代碼解讀複製代碼-- 反例:t3 的過濾條件傳染到 t2,間接把 t1→t2 的外連接也消除掉
SELECT * FROM t1
LEFT JOIN t2 ON t1.id = t2.id
LEFT JOIN t3 ON t2.id = t3.id
WHERE t3.status = 'A';
這條 SQL 裡,t3.status = 'A' 直接消除 t2 LEFT JOIN t3;一旦 t2 → t3 變成內連接,t2.id 就變成 not-null 的 join key,進一步會消除 t1 → t2 的外連接。 一個條件消除兩個外連接,損失慘重。
sql 代碼解讀複製代碼-- 反例:v_user_order 內部是 LEFT JOIN,外層 WHERE 反向消除
CREATE VIEW v_user_order AS
SELECT u.user_id, u.user_name, o.order_id, o.order_amount
FROM t_user u LEFT JOIN t_order o ON u.user_id = o.user_id;
SELECT * FROM v_user_order WHERE order_amount > 500;
檢視內部雖然是外連接,但外層 WHERE 裡帶了 nullable-side 條件,一旦最佳化器「看穿」檢視(View Inline / Query Rewrite),外連接依然會被消除。 這類「跨層陷阱」最難排查,因為檢視定義看起來是對的,問題出在業務側的呼叫方式。
結合前面的分析,我把日常在做遷移改造時執行的「外連接消除」排查動作,整理成一份可直接落地的清單。這份清單我在幾個專案裡跑下來,能在改造階段攔截 90% 以上的類似語義陷阱。
在 CI/CD 流水線裡,可以借助腳本對存量 SQL 做正則掃描,輸出「包含 LEFT/RIGHT JOIN 且 WHERE 子句引用了 Nullable-Side 欄位(且不是 IS NULL)」的可疑 SQL 清單。專案裡我常用的簡化版正則思路是:
regex 代碼解讀複製代碼# 匹配 LEFT JOIN ... WHERE ... right_alias.col = 之類的模式
LEFT\s+JOIN\s+(\w+)\s+(\w+).*?WHERE.*?\2\.\w+\s*(?!IS\s+NULL)
這一步不追求 100% 精準,目的是先把可疑清單圈出來,交給 DBA 與業務共同審查。
對靜態掃描輸出的可疑 SQL 抽樣,在 KES 裡執行 EXPLAIN,重點看:
Left / Right;Hash Join / Nested Loop,則確認外連接已被消除。對確認被消除、但業務意圖確實需要保留外連接的 SQL,進入下一步改寫。
大部分場景推薦把過濾條件下推到 ON:
sql 代碼解讀複製代碼-- 改寫前
LEFT JOIN t2 ON t1.id = t2.id WHERE t2.status = 'A'
-- 改寫後
LEFT JOIN t2 ON t1.id = t2.id AND t2.status = 'A'
如果業務邏輯複雜,條件不能簡單下推(比如條件涉及左右表混合欄位的 CASE 表達式),可以用 COALESCE 手工構造一個「NULL 安全」的比較:
sql 代碼解讀複製代碼-- 用 COALESCE 處理 NULL 值場景
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE COALESCE(t2.status, 'A') = 'A';
COALESCE(t2.status, 'A') 遇到 NULL 時返回 'A',條件對 NULL 輸入返回 True——這是標準的「null-preserving」寫法,KES 最佳化器會識別到它不是 null-rejecting,從而不消除外連接。
對檢視 / CTE 裡的外連接,保持一個原則:不允許呼叫方在外層 WHERE 裡對檢視輸出的 Nullable-Side 欄位做 null-rejecting 過濾。 如果必須過濾,檢視內部要用 COALESCE 把 NULL 兜住,或者在檢視定義裡額外輸出一個 is_matched 布林標誌欄位,讓呼叫方用這個標誌欄位過濾,避免 nullable-side 直接暴露。
改造完成後的關鍵動作,是把改寫前後的 SQL 在同一份測試資料上執行,做 EXCEPT 差集對比:
sql 代碼解讀複製代碼-- 差集驗證:改寫前後結果集是否完全一致
(SELECT * FROM query_before)
EXCEPT
(SELECT * FROM query_after);
(SELECT * FROM query_after)
EXCEPT
(SELECT * FROM query_before);
任何一邊返回非空,都說明改寫引入了語義變化,需要復盤。
坦白講,外連接消除只是國產化遷移裡幾十種語義陷阱中的一種。 每次踩坑,如果只靠自己團隊悶頭搜文件,效率是非常低的。 我這半年養成的習慣是——遇到疑難雜症,先去金倉社群 bbs.kingbase.com.cn 看看有沒有同類問題的貼文,很多時候原廠研發或者前輩們已經把避坑記錄留下來了。 前不久金倉社群還發起了「同行者計畫」,鼓勵第一線工程師把自己所在企業的遷移場景、最佳化案例反哺回社群。 我個人覺得這種「社群共建 → 集體經驗沉澱」的模式,對於國產化落地階段是非常有價值的,尤其是像「外連接消除」這種「官方文件寫得對、但實戰裡最容易踩」的坑,靠第一線的口耳相傳遠比自己啃產品手冊來得快。
咱們回到文章開頭說的那個情況。就是報表少了40%資料的那次。我把改好的SQL上線以後,業務那邊看新報表看得很滿意。他們甚至跑來問我,說這個新資料庫是不是更聰明一點,為什麼老系統就算不出這個數。我當時聽完也就是笑笑沒多說話。其實吧,老系統一直跑的都是錯的資料。只不過以前沒有人去仔細摳過對帳口徑的細節。金倉 KES 的話,它也就是按照SQL標準去跑了。結果呢,把以前藏得很深的語義 Bug 給暴露出來了。這也就是在這次國產化改造的時候剛好碰上了。
我自己平時做這種遷移改造做得比較多。所以關於外連接消除這個事,我個人的話有三點想法。跟大家總結一下。
外連接消除這個點,其實在國產化遷移裡只是很小的一部分。後面的話,我還會接著寫。把遇到的各種遷移坑還有最佳化辦法都發出來。也就是希望能給現在正在做同樣事情的兄弟們一點參考。大家看完的話,可能就能少踩幾個坑了。