MySQL中DISTINCT語句去重優(yōu)化機制詳解
寫 SQL 的人,對 DISTINCT 實在太熟悉了。統(tǒng)計不重復(fù)的值、列出唯一組合,第一反應(yīng)都是加個 DISTINCT。語法簡單,意思也清楚,可數(shù)據(jù)量一上去,它就可能悄悄變成拖慢查詢的那個點。這篇文章想拆解的,是數(shù)據(jù)庫內(nèi)核給 DISTINCT 做的一套自動優(yōu)化,不用你改一行 SQL,內(nèi)核自己就把低效去重換成了高效查詢。原理并不復(fù)雜,但它正好能講清楚一件挺有意思的事:優(yōu)化器是怎么從一堆條件里"想明白"結(jié)果唯一的。
一、DISTINCT 憑什么慢
先看最樸素的一條:
SELECT DISTINCT a, b FROM s1;
意思是從 s1 里把那些不重復(fù)的 (a,b) 組合挑出來。要是內(nèi)核沒什么特殊處理,執(zhí)行路徑基本是固定的:全表掃一遍,再排序或者建個哈希表,然后在這上面去掉重復(fù),輸出。
數(shù)據(jù)量一大,掃描和排序這兩步哪個都不便宜。更可惜的是另一種情況,查詢里明明帶著很強的過濾條件,目標列的值早就被鎖死了,結(jié)果集其實最多就一行???DISTINCT 不懂這些,它照樣掃完全表、排完序、再去一次重,整段去重,成了純粹的空轉(zhuǎn)。
二、內(nèi)核想了兩條路
這種空轉(zhuǎn),內(nèi)核想了辦法對付,歸納起來就兩條路。
第一條,把 DISTINCT 改寫成 GROUP BY。GROUP BY 這條路平時就跑得比較成熟,背后站著鍵值消除和并行執(zhí)行兩樣現(xiàn)成的本事。鍵值消除說穿了也簡單,分組鍵里要是包含主鍵,主鍵已經(jīng)能唯一確定一行了,分組自然可以化簡。
-- 改寫前 SELECT DISTINCT a, b FROM s1; -- 改寫后 SELECT a, b FROM s1 GROUP BY a, b;
第二條更狠,把 DISTINCT 或 GROUP BY 改寫成 LIMIT 1。要是能判定目標列已經(jīng)被常量卡死、結(jié)果最多一行,完整去重就完全沒必要了,找到一條滿足條件的,馬上返回。
-- 改寫前 SELECT a, b FROM s1 WHERE a=1 AND b=1 GROUP BY a, b; -- 改寫后 SELECT a, b FROM s1 WHERE a=1 AND b=1 LIMIT 1;
第一條好理解。第二條的關(guān)鍵和難點,全在"要是能判定"這幾個字上,目標列到底有沒有被常量卡住,往往不是一眼能看出來的。下面重點拆這一條。
三、目標列被"釘死",是怎么回事
所謂目標列被常值固定,意思就是經(jīng)過分析能確定,目標列的取值已經(jīng)是具體常量了。一旦所有目標列都成了常量,結(jié)果集頂多一行,去重自然就多余了。
常量可能從幾個地方冒頭。WHERE 里直接給的,比如 a=1。JOIN 等值條件里間接推導(dǎo)出來的,比如 s1.a = s2.b。幾個謂詞之間互相傳遞出來的,A 等于 B,B 又等于 C,那 A 自然就等于 C。還有一種最省事的,目標列本身就是常量,連列都沒引用。
這背后其實是編譯原理里的兩門老手藝,一個叫常量傳遞,也有人叫常量傳播,另一個叫謂詞傳遞。
3.1 常量傳遞:把已經(jīng)知道的值往前推
SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;
優(yōu)化器會把 WHERE 拆成一棵邏輯表達式樹:
AND
/ \
a=1 b=1
從這棵樹一眼能讀出來,a 這會兒是常量 1,b 也是常量 1。兩個目標列都釘死了,(a,b) 這個組合頂多一種取值,就是 (1,1)。結(jié)果集的行數(shù)被推理卡在了"至多一行",于是可以放心改寫成 LIMIT 1。改寫之后,掃描時撞見第一條滿足條件的記錄就返回,排序和去重節(jié)點整個消失。
3.2 謂詞傳遞:從等值關(guān)系里擠出約束
常量傳遞只管那種直接賦值的。可真實查詢里,約束常常藏得很深,得靠等值關(guān)系一層層往下挖。它靠的是個叫等價類的概念,說白了就是,彼此相等的一組列歸在同一類里,這一類里只要有誰被綁到了常量,整組人也就全跟著綁了。
SELECT s1.a, s2.b FROM s1 INNER JOIN s2 ON s1.a = s2.b AND s1.a = 5 GROUP BY s1.a, s2.b;
推演過程是這樣。第一步,從 s1.a = s2.b 看出 s1.a 和 s2.b 屬于同一個等價類。第二步,s1.a = 5 又把這個等價類整體綁到了常量 5 上。第三步,靠謂詞傳遞就能得出,s2.b 也必然等于 5。
s1.a = s2.b ┐
├─ 等價類 { s1.a, s2.b }
s1.a = 5 ──────────┘ 常量 5 擴散到全體成員
⇒ s2.b 也等于 5
這里有個細節(jié)值得注意,原始 SQL 里誰也沒直接說"s2.b 等于某個常量",這個結(jié)論是優(yōu)化器自己推出來的。兩個目標列都被固定之后,分組去重就能改寫成 LIMIT 1。
3.3 說穿了,它是一道證明題
這套判斷,可以當(dāng)成一道證明題來看。已知條件是 WHERE 的全部謂詞、JOIN 的全部等值條件、目標列的常量。推理規(guī)則就是常量傳遞、謂詞傳遞、等價類合并。要證的結(jié)論是,每個目標列是不是都能被綁到一個具體常量上。用偽代碼描述,大概是這樣:
function 可否改寫為_LIMIT_1(查詢 Q):
常量綁定表 = {}
# 1. 常量傳遞:記下 WHERE 里的直接賦值
for 謂詞 in Q.WHERE:
if 謂詞 形如 (列 = 常量):
常量綁定表[列] = 常量
# 2. 謂詞傳遞:建等價類,常量向同類成員擴散
等價類 = 由所有等值條件合并而成()
for 類 in 等價類:
if 類中任一成員 已在 常量綁定表:
把該常量綁定擴散給類內(nèi)全部成員
# 3. 逐個檢查目標列,看是不是全部被卡死
for 目標列 in Q.目標列:
if 目標列 不在 常量綁定表:
return False # 有一列沒固定,改寫不安全
return True # 全部固定,可以放心改寫結(jié)論成立,"結(jié)果唯一"就是個被嚴格證明出來的事實,不是拍腦袋猜的。
四、改寫的安全底線:語義等價
任何 SQL 改寫的前提,都是不能改變結(jié)果。DISTINCT 要變 GROUP BY,再變 LIMIT 1,每一步都在動 SQL 的結(jié)構(gòu)。可真實業(yè)務(wù)里的語句,常常帶著 JOIN、子查詢、聚合,亂改一下結(jié)果就錯了。所以內(nèi)核必須立一套很嚴的約束條件,只有能證明"改寫前后結(jié)果完全一致"的時候才動手。這一下,就把干這活的難度從"會套規(guī)則",提到了"得會做證明"。
五、實際收益有多大
落到真實執(zhí)行計劃上,改寫的效果很直觀。下面是兩組最小化測試的數(shù)據(jù),具體數(shù)字會隨數(shù)據(jù)規(guī)模和硬件變化。
路徑一,DISTINCT 改寫成 GROUP BY,查詢時間從大約 464 毫秒降到了 249 毫秒,收益主要來自更成熟的并行執(zhí)行和鍵值裁剪。
路徑二,改寫成 LIMIT 1,效果尤其明顯。簡單場景從大約 30 毫秒降到 0.03 毫秒,帶 JOIN 的復(fù)雜場景從大約 12 毫秒降到 0.08 毫秒,差不多三個數(shù)量級的提升。
-- 改寫前執(zhí)行計劃(節(jié)選):掃描 + 排序去重 -- Sort -- Sort Key: a, b -- -> Seq Scan on s1 -- 改寫后執(zhí)行計劃(節(jié)選):命中即返回,去重節(jié)點消失 -- Limit -- -> Seq Scan on s1 -- Filter: ((a = 1) AND (b = 1))
排序和去重這些節(jié)點,從執(zhí)行計劃里整個抹掉,這就是改寫落在執(zhí)行層面的樣子。
六、看懂了這一點,也就看懂了現(xiàn)代優(yōu)化器
傳統(tǒng)優(yōu)化器靠的是"比代價"。它把若干候選執(zhí)行計劃列出來,挨個估個代價,CPU、IO、內(nèi)存各多少,然后挑最便宜的那個。它會挑,但不會"想",規(guī)則庫里沒有的能力,它變不出來,代價算得再準,也只會在幾種排列里挑個相對不那么差的。
現(xiàn)代優(yōu)化器則額外多了一手"做推理"。它在算代價之前,先判定一件事,這個操作到底要不要發(fā)生。DISTINCT 改寫成 LIMIT 1 就是這種"先推理、后算賬"的典型,它不是把去重做得更快,而是判定去重這個動作根本不用做。
七、知識擴展
在 MySQL 中,DISTINCT 的執(zhí)行原理本質(zhì)上是排序去重或臨時表去重。如果數(shù)據(jù)量較大,這兩個操作都會帶來顯著的 I/O 和內(nèi)存開銷(文件排序、創(chuàng)建隱式臨時表)。
優(yōu)化的核心邏輯是:讓 DISTINCT 操作盡可能利用索引(有序性),或通過改寫 SQL 邏輯來避免對全表進行無謂的排序去重。
以下是 7 條高效的優(yōu)化技巧與實戰(zhàn)方案:
1. 黃金法則:利用索引規(guī)避排序(Index Skip Scan)
這是最高效的優(yōu)化方式。如果 DISTINCT 的字段有索引,MySQL 可以利用索引的有序性,直接掃描索引去重,無需創(chuàng)建臨時表。
- 場景:查詢所有不重復(fù)的
category_id。 - 優(yōu)化前:
SELECT DISTINCT category_id FROM products;(全表掃描,創(chuàng)建臨時表) - 優(yōu)化后:
ALTER TABLE products ADD INDEX idx_category (category_id); - 原理:MySQL 執(zhí)行 松散索引掃描(Loose Index Scan),跳過索引中重復(fù)的鍵值,僅讀取每組第一個值,性能極高。
注意:如果 DISTINCT 涉及多個字段(如 SELECT DISTINCT col1, col2),必須建立 (col1, col2) 的聯(lián)合索引才能觸發(fā)松散掃描。
2. 邏輯改寫:用 EXISTS 替代 DISTINCT(針對多表關(guān)聯(lián))
當(dāng)使用 JOIN 查詢時,DISTINCT 通常是為了消除因“一對多”關(guān)系產(chǎn)生的重復(fù)主表數(shù)據(jù)。這種情況非常消耗資源,因為你先關(guān)聯(lián)了龐大的數(shù)據(jù)集,再去重。
場景:查詢“下過訂單的所有用戶”。
優(yōu)化前(低效):
SELECT DISTINCT u.id, u.name FROM users u JOIN orders o ON u.id = o.user_id;
(MySQL 需關(guān)聯(lián)所有數(shù)據(jù),再對結(jié)果集去重)
優(yōu)化后(高效):
SELECT u.id, u.name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
(半連接(Semi-Join)機制,一旦找到一條匹配記錄立即返回,無需排序去重)
3. 邏輯改寫:用 GROUP BY 替代 DISTINCT(特定場景)
在某些復(fù)雜查詢(如涉及聚合函數(shù))或特定 MySQL 版本中,GROUP BY 的優(yōu)化器路徑可能更優(yōu),且顯式分組更容易利用索引。
場景:查詢指定條件下的唯一用戶 ID。
寫法對比:
-- 方式一 SELECT DISTINCT user_id FROM orders WHERE status = 1; -- 方式二(通常等價且更利于索引排序) SELECT user_id FROM orders WHERE status = 1 GROUP BY user_id;
如果 (status, user_id) 有聯(lián)合索引,GROUP BY 可以直接利用索引順序完成分組,無需回表。
4. 利用 SQL_BIG_RESULT / SQL_SMALL_RESULT 提示優(yōu)化器
MySQL 允許你向優(yōu)化器提供提示,告知它結(jié)果集的大小,從而決定使用內(nèi)存臨時表還是磁盤臨時表。
- 場景:結(jié)果集非常大(如幾百萬行)。
- 語法:
SELECT SQL_BIG_RESULT DISTINCT user_id FROM huge_table; - 作用:強制 MySQL 直接使用磁盤臨時表(基于文件排序)而不是內(nèi)存臨時表,避免內(nèi)存不足時頻繁轉(zhuǎn)換到磁盤,從而提升吞吐量。
5. 縮小數(shù)據(jù)范圍:先過濾,再去重
將 DISTINCT 放在最內(nèi)層子查詢中,先利用索引縮小數(shù)據(jù)范圍,再對外層進行去重,可以極大減少臨時表的大小。
優(yōu)化前:SELECT DISTINCT user_id FROM logs;
優(yōu)化后:
SELECT DISTINCT user_id
FROM (
SELECT user_id FROM logs
WHERE create_time > '2026-01-01'
AND create_time < '2026-06-01'
) AS recent_logs;6. 參數(shù)調(diào)優(yōu):增大臨時表閾值
如果無法完全避免臨時表,可以通過調(diào)整配置參數(shù)來降低磁盤 I/O 開銷:
tmp_table_size:內(nèi)存臨時表的最大大小。max_heap_table_size:用戶創(chuàng)建的內(nèi)存表最大大小。- 建議:在內(nèi)存充足的情況下適當(dāng)調(diào)大這兩個值(例如設(shè)為
256M),可以有效避免臨時表“溢出”到磁盤,從而大幅提升DISTINCT性能。
7. 特殊場景:使用 DISTINCT 與 LIMIT 結(jié)合
如果你只需要前 N 個不重復(fù)的值,LIMIT 配合索引可以讓 MySQL 在找到滿足條件的 N 條記錄后立即停止掃描。
- 示例:
SELECT DISTINCT category_id FROM products LIMIT 10; - 前提:
category_id必須有索引。MySQL 會按索引順序掃描,找到 10 個不同的值后直接返回,極大地減少了掃描行數(shù)。
八、寫在最后
DISTINCT 優(yōu)化這件事,表面是讓去重變快,骨子里是優(yōu)化器在做邏輯推理。它用常量傳遞把已知值推到目標列上,用謂詞傳遞從等值關(guān)系里擠出隱藏的約束,再證明結(jié)果唯一,最后用 LIMIT 1 把去重整個短路掉。傳統(tǒng)優(yōu)化器和現(xiàn)代優(yōu)化器的差距,也就在這兒,一個會算代價,一個還會做證明。
以上就是MySQL中DISTINCT語句去重優(yōu)化機制詳解的詳細內(nèi)容,更多關(guān)于MySQL DISTINCT優(yōu)化的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
MySQL?DDL從入門到精通,包含索引視圖分區(qū)表等全操作解析
本文詳細介紹DDL(數(shù)據(jù)定義語言)的核心概念、分類及常見操作,涵蓋創(chuàng)建、修改、刪除數(shù)據(jù)庫對象的方法,以及InnoDB存儲引擎的的高級特性,通過實例解析,幫助讀者掌握高效管理數(shù)據(jù)庫的技術(shù)與策略,感興趣的朋友跟隨小編一起看看吧2026-06-06
MySQL中On duplicate key update的實現(xiàn)示例
ON DUPLICATE KEY UPDATE是一種MySQL的語法,它在插入新數(shù)據(jù)時,如果遇到唯一鍵沖突,則會執(zhí)行更新操作,而不是拋出異?;蚝雎栽摋l數(shù)據(jù),下面就具體來介紹一下如何使用2025-08-08

