一、语义:从“集合”到“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 时,想起这篇长文,然后自信地按下执行——因为你知道,镜子里的每一个“唯一”,都早已在设计的蓝图里生根。