一、語義:從“集合”到“bag”的回歸
關系代數中,表是“bag”——允許重復行;業務卻常把表當“集合”——只關心不同值。DISTINCT 便是把 bag 轉換為集合的“顯式運算符”。與 GROUP BY 不同,它不對列進行聚合,僅剔除重復行;與窗口函數 ROW_NUMBER() 不同,它不保留“原始順序”信息。這一“只去重、不聚合”的極簡語義,讓它成為“最輕量級”的排重方式,也隱藏了“看似無消耗”的性能陷阱:去重需要排序或哈希,而排序與哈希都是 CPU 與內存的雙料大戶。
二、算法:排序、哈希與位圖的“三重門”
1. 排序去重:最古老也最通用
全表掃描→按列排序→相鄰行比較→跳過重復。復雜度 O(n log n),需要額外內存或磁盤臨時文件。優勢:支持任意數據類型、任意長度;劣勢:大數據量下觸發外部排序,磁盤 I/O 飆高。
2. 哈希去重:內存換速度
建立哈希表→逐行插入→沖突即重復→丟棄重復。復雜度 O(n) 平均,最壞 O(n²) 碰撞。優勢:速度線性,內存可控;劣勢:哈希表膨脹時觸發 rehash,CPU 緩存命中率下降;長字符串哈希計算本身也是開銷。
3. 位圖去重:整數世界的“閃電戰”
僅適用于整型且值域稠密場景:每個值對應一位,出現即置1,天然去重。復雜度 O(n) 且常數極小,內存僅與值域上限成正比。劣勢:值域稀疏時內存浪費;不支持字符串、浮點、日期。
現代優化器會基于統計信息、數據分布、內存預算,在三種算法間自動切換,但“自動”不代表“無代價”——理解算法差異,才能讀懂執行計劃里的“Sort”或“HashAggregate”含義。
三、索引與執行計劃:從“全表掃描”到“索引跳躍”
若去重列存在索引,優化器可選擇“索引唯一掃描”:按索引順序讀取,遇到重復值直接跳過,避免排序。代價:索引需要覆蓋 DISTINCT 列,且數據分布不能過于傾斜;若復合索引前綴不匹配,仍需回表+排序。
另一路徑“索引跳躍掃描”(Index Skip Scan)適用于復合索引:先按索引前綴分組,再在每組內做索引范圍掃描,避免全表排序。代價:前綴分組需離散值較少,否則跳躍成本高于順序掃描。
執行計劃里的“Using index for group-by”或“Using index for distinct”即此類優化。讀懂關鍵詞,才能判斷“加索引是否真能提高 DISTINCT 性能”,而非“盲目加索引”。
四、內存與磁盤:排序溢出的“冰山效應”
排序去重時,內存工作區(work area)不足會觸發“外部排序”:數據分片寫入磁盤臨時文件,多路歸并后再去重。冰山效應:表面只有“DISTINCT”八個字母,水下卻是“多路歸并+磁盤讀寫+臨時空間”。
癥狀:CPU 等待 I/O,磁盤 QPS 飆升,其他查詢被阻塞。
診斷:執行計劃出現“external sort”“temporary file”;OS 層面看到 /tmp 或表空間目錄大量寫。
緩解:增大內存工作區、拆分大查詢、使用并行哈希、或改用“增量位圖”方案。
五、分布式 DISTINCT:跨節點的“重復獵人”
在分片架構下,同一值可能散落在不同節點。全局去重需兩階段:
1. 局部去重:每個節點先 DISTINCT,生成局部唯一集;
2. 全局合并:協調節點收集局部集,再次去重,返回最終集合。
代價:網絡傳輸量 = 各節點唯一集大小之和;若值域巨大,網絡成為新瓶頸。
優化思路:
- 預聚合:節點端用布隆過濾器或 HyperLogLog 估算基數,過濾明顯重復值;
- 增量合并:邊傳輸邊合并,避免“全部拉取再重排”;
- 并行哈希:按哈希值范圍分片,讓相同值落入同一節點,避免二次 shuffle。
分布式 DISTINCT 的終極難題是“網絡 vs. 內存 vs. CPU”的三方博弈,需在“業務容忍誤差”與“資源預算”之間權衡。
六、近似去重:HyperLogLog 與布隆過濾器的“概率魔法”
當“精確去重”成本過高,可用“近似去重”:
- HyperLogLog:0.81% 標準誤差,內存固定 16 KB,支持合并;
- 布隆過濾器:可控誤報率,內存隨插入元素線性增長,不支持刪除(Counting Bloom 除外)。
使用場景:UV 統計、實時大屏、日志去重。
注意:近似結果需向業務方明示“誤差范圍”,避免“小數點差異”引發決策失誤。
七、實戰案例:一條 DISTINCT 引發的血案
案例背景:電商大促,運營需要“當日支付成功的不同用戶 ID”統計。
原始 SQL:SELECT DISTINCT user_id FROM pay_log WHERE status='SUCCESS' AND pay_time BETWEEN '2024-11-11 00:00:00' AND '2024-11-11 23:59:59';
數據量:100 億行,user_id 高基數 2 億;
執行計劃:全表掃描 → 排序 → 外部排序 → 磁盤寫滿 → 其他查詢被阻塞 → 監控告警 → 業務方無法刷新大屏。
根因:pay_time 范圍條件無法充分利用 user_id 索引,導致“索引回表+全量排序”。
優化路徑:
1. 預聚合表:按小時分區,提前計算每小時 UV,再 UNION ALL 匯總;
2. 增量位圖:按天構建 RoaringBitmap,區間查詢轉“位圖 OR”;
3. 近似 HyperLogLog:業務容忍 1% 誤差,內存占用從 100 GB 降到 16 KB。
結案:預聚合 + 位圖混合方案,查詢時間從 300 秒降到 3 秒,內存峰值從 500 GB 降到 2 GB。
教訓:DISTINCT 不是“加個索引”就能解決,需在“模型層”預先設計“去重友好”結構。
八、誤區與踩坑:那些“看似合理卻爆炸”的暗礁
- “SELECT DISTINCT *”——去重所有列,導致索引無法覆蓋,回表爆炸;
- “DISTINCT + ORDER BY 不同列”——排序列與去重列不一致,觸發額外排序;
- “DISTINCT + LIMIT”——先排序去重再 LIMIT,內存峰值與 LIMIT 大小無關;
- “DISTINCT + GROUP BY”——語義重復, optimizer 可能無法消除冗余階段;
- “DISTINCT 列上有 NULL”——NULL 被視為唯一值,業務方卻以為“空不算重復”。
把這些暗礁貼在 Code Review 清單,能攔住 80% 的“ DISTINCT 反模式”。
九、測試與驗證:讓“去重”可測量
- 數據生成:用 Poisson 分布制造“重復熱點”,模擬真實傾斜;
- 結果驗證:用窗口函數 ROW_NUMBER() 與 DISTINCT 結果交叉校驗,確保“零誤差”;
- 性能基準:記錄“執行時間、內存峰值、磁盤 I/O”三指標,形成基線;
- 混沌注入:隨機刪除索引、調整內存參數,觀察 DISTINCT 的“退化曲線”。
可測量才能讓“優化”有據可依,而非“感覺更快”。
十、未來趨勢:從“精確去重”到“概率近似”再到“實時流”
實時流計算(Flink、Spark Streaming)把 DISTINCT 推向新維度:
- 窗口 UV:用 RoaringBitmap 或 HyperLogLog 做增量合并;
- 會話去重:用 Session Window + DISTINCT KEY 計算“每人每次”;
- 持續查詢:DISTINCT 結果隨時間變化,需要“版本化”輸出;
- AI 預測:用機器學習預測“去重后基數”,提前分配內存資源。
未來的 DISTINCT 不再是“一條 SQL”,而是“持續算子”,需要“內存管理 + 近似算法 + 增量合并”三駕馬車并行。
DISTINCT 看似簡單,卻貫穿“算法、索引、內存、分布式、近似算法、實時流”整條技術棧。它像一面鏡子:照出數據模型的瑕疵,照出索引設計的缺陷,照出內存與網絡的天花板。理解它,你才能在面對“重復數據”時,不再只是“SELECT DISTINCT”,而是提前在模型層設計“去重友好”的結構,在索引層預留“唯一友好”的路徑,在內存層預留“排序友好”的預算。讓“唯一”成為設計,而不是 SQL 后綴;讓“去重”成為架構,而不是事后補丁。愿你下一次寫下 DISTINCT 時,想起這篇長文,然后自信地按下執行——因為你知道,鏡子里的每一個“唯一”,都早已在設計的藍圖里生根。