名單排除查詢本來正常,封鎖表多了一筆資料後,結果突然一列都沒有。你可能寫了WHERE id NOT IN子查詢,預期把封鎖的編號移除;真正改變結果的,卻是子查詢裡混入了一個NULL。
這不是MySQL把所有編號都視為相同。NULL代表未知,普通比較可能得到NULL這個未知結果;WHERE只保留條件為真的列。要修正查詢,先確認欄位是否允許NULL,再決定排除規則是在比較值,還是在問「有沒有一筆符合條件的紀錄」。
用三個編號重現空結果
以下在新的隔離資料庫中建立示範表。items的id是主鍵,不允許NULL;blocked的item_id刻意允許NULL,模擬一筆尚未填入編號的封鎖紀錄。不要在正式資料庫直接建立或覆蓋同名表。
CREATE TABLE items(id INT PRIMARY KEY);
CREATE TABLE blocked(item_id INT NULL);
INSERT INTO items VALUES (1),(2),(3);
INSERT INTO blocked VALUES (2),(NULL);
SELECT id FROM items
WHERE id NOT IN (
SELECT item_id FROM blocked
)
ORDER BY id;
在隔離MySQL 8.4.9實跑,這個查詢返回零列。封鎖編號只有2,卻連1和3也沒留下。先直接檢查子查詢結果,會看到2與NULL;不要先懷疑排序、頁面分頁或快取,這個最小案例在資料層就能重現。
NULL讓否定比較也無法成立
對一般非NULL值,可以把NOT IN的含意理解成「不同於名單裡每一個值」。對1而言,1不同於2是真的,但1與NULL的普通比較得到未知;兩者結合後無法成為真。對2而言,已經命中名單的2,否定結果為假。於是沒有任何一列通過WHERE。
SELECT
1 NOT IN (2, NULL) AS unknown_result,
2 NOT IN (2, NULL) AS blocked_result;
-- unknown_result = NULL
-- blocked_result = 0
這裡的NULL不是空字串,也不是0。不要把資料中的NULL直接換成某個你猜測「不會用到」的編號,可能日後和真資料衝突。也不要使用= NULL查找缺值,普通等號仍會得到未知;檢查NULL應使用IS NULL或IS NOT NULL。
子查詢的輸出即使只有一筆NULL,也足以影響其他沒有命中的值。這個現象不需要大量資料或特殊交易隔離才能發生,先把查詢縮成幾列,就比較容易看出哪個值帶來未知條件。
排除名單不應含NULL時,先過濾缺值
如果業務規則明確說「沒有編號的紀錄不構成封鎖」,可以在子查詢排掉NULL。這個修正保留NOT IN的值比較意義,而不是把缺值當成另一個有效編號。
SELECT id FROM items
WHERE id NOT IN (
SELECT item_id FROM blocked
WHERE item_id IS NOT NULL
)
ORDER BY id;
-- 本例返回1、3
這只處理內層名單的NULL。本例外層id是主鍵,因此沒有外層缺值問題;若你實際用的是可以為NULL的欄位,還需要另定它的處理方式。不能看到範例返回正確,就把這段過濾當成所有NOT IN查詢的通用保證。
若封鎖編號在資料模型上本來就必填,長期可以評估NOT NULL約束與輸入驗證。但修改正式表之前,要盤點既有缺值、寫入流程與遷移方式。本文只比較查詢結果,沒有更動正式schema,也沒有把全部NULL紀錄刪掉。
用NOT EXISTS直接問是否有符合的紀錄
另一個清楚的寫法,是針對每筆外層資料,查看內層是否存在相同編號。NOT EXISTS只看子查詢是否返回任何列,不把內層的所有值組成否定名單。
SELECT i.id
FROM items AS i
WHERE NOT EXISTS (
SELECT 1
FROM blocked AS b
WHERE b.item_id = i.id
)
ORDER BY i.id;
-- 本例返回1、3
對i.id=1,內層沒有相等的列,因此NOT EXISTS為真;對2,內層找得到item_id=2,因此為假。blocked裡的NULL不會透過普通等號匹配1或3,也不會讓存在測試本身變成未知。
內層SELECT 1只是表達這裡不需要讀取完整欄位值;EXISTS判斷的是有沒有列,而不是數字1的內容。若漏了b.item_id = i.id這個關聯條件,只要封鎖表有任何列,就可能把所有外層列排除。抄寫時最需要核對的是實際對應的鍵與條件。
外層欄位也有NULL時,兩種寫法不一定等價
另建一個允許NULL的outer_keys表,放入1與NULL。對過濾掉內層NULL的NOT IN,結果只留下1;用普通等號關聯的NOT EXISTS,則留下1與NULL,因為外層NULL也找不到普通等號成立的內層列。
因此把NOT IN改成NOT EXISTS之前,要先回答「外層缺值是否應保留」。若應排除外層缺值,可以在外層加IS NOT NULL;若NULL應與封鎖表裡的NULL視為相同,在MySQL可以考慮NULL安全等號<=>。這是另一種明確的業務規則,不是普通等號的替代拼法。
SELECT o.k FROM outer_keys AS o
WHERE NOT EXISTS (
SELECT 1 FROM blocked AS b
WHERE b.item_id <=> o.k
);
-- 本例blocked含NULL,結果只留下1
本文的補充實跑也核對了這三種結果,避免只用非NULL主鍵案例宣稱兩者完全等價。實際表若有複合鍵、不同型別或字符比對規則,還要核對每個比較條件;一個最小案例不能替那些資料模型做決定。
先確認語意,再談執行效率
NOT EXISTS看起來較長,不表示一定慢;NOT IN較短,也不表示一定快。MySQL可以按條件與查詢形式做最佳化,真正執行方式還取決於索引、資料量與NULL條件。不要用這個三列示範的速度判定正式表性能。
修正前先列出預期保留的編號,再讓兩個查詢對同一組小型資料跑一次;加入內層NULL與外層NULL,確認結果符合規則。確認語意後才使用EXPLAIN檢查正式查詢計畫,並按授權環境評估索引。本文沒有在正式資料庫執行任何SQL。
這類排除查詢最容易漏掉的不是語法,而是缺值的含意。把「未填編號」、「不在封鎖名單」與「不存在相同紀錄」分開定義,查詢就比較容易被其他維護者看懂,也不會在下一筆NULL加入後突然改變全部結果。
把缺值案例留在查詢測試裡
測試排除名單時,至少準備一筆應留下、一筆應排除,以及一筆沒有填入關聯編號的資料。只用完整編號測試,兩種寫法可能一直返回相同結果,直到正式資料出現NULL才暴露不同語意。若外層欄位也可缺值,把這一種情況單獨加入,不要把所有缺值都混成同一筆樣本。
封鎖表的重複編號不會讓存在測試產生多筆外層結果,因為這裡沒有把內層列連接到輸出。若後來把查詢改成JOIN,還要注意一對多關係是否放大列數;不能只看這個範例的三個編號,推論另一種寫法在所有資料上都相同。
資料清理也應有明確依據。缺值紀錄可能是匯入未完成、外部關聯尚待建立,或原本允許的狀態;查詢暫時忽略它,不代表它可永久刪除。先找出產生缺值的寫入流程,再決定是否補資料或增加約束,避免排除查詢修好了,另一段業務卻失去仍需要的紀錄。
交接時可以直接寫下預期結果:本例封鎖2,內層NULL不構成封鎖,外層NULL如何處理由產品規則決定。這幾句話和最小測試資料一起保留,比只記「換成NOT EXISTS就好」更能讓日後修改的人理解原因,也方便驗證之後新增條件沒有改掉原本排除規則。

評論0