MySQL DISTINCT 去重原理与慢查询优化实战
线上一条报表 SQL 跑了 42 秒,单表八百多万行,SELECT DISTINCT三个字段外加一个ORDER BY。我把它拆成GROUP BY再配上一条覆盖索引,耗时掉到 1.2 秒。这类事情我碰到过太多次,问题很少出在 MySQL DISTINCT 这个关键字本身,而在于写的人并不清楚它背后到底做了什么。MySQL DISTINCT 看起来只是“去重”两个字,真跑起来却可能建临时表、可能触发 filesort、可能把索引彻底废掉,也可能什么都没干——因为优化器已经把它揉进了别的执行路径里。这篇把 DISTINCT 从语义、执行路径、索引配合、COUNT(DISTINCT)变体一直讲到改写手法,适合已经会写基本 SQL、被慢查询折腾过的同学,也适合刚接手别人留下的报表代码、想知道“这一大段到底慢在哪”的朋友。下面所有结论都基于常见版本(5.7 与 8.0)的实际行为,版本差异我会单独标出来。
1. DISTINCT 去重的是“整行”,不是“某一列”
1.1 一个被误解了很多年的写法
很多人第一次用 DISTINCT,脑子里想的是“我要把 city 这一列去重”。于是写下SELECT DISTINCT city, user_id FROM addr,然后发现结果里 city 还是重复的,就断定 DISTINCT “不好使”。其实 DISTINCT 的判定单位永远是 SELECT 列表里所有列的组合,(city, user_id)这一对只要有一列不同,就算两行。想要“每个 city 只留一行”,必须明确告诉数据库“留哪一行”,这已经不是去重问题,而是取样问题——要么用GROUP BY city配聚合函数,要么用窗口函数ROW_NUMBER()挑一条。
我见过更隐蔽的版本:SELECT DISTINCT a.id, b.status FROM a JOIN b ...,开发者的意图是“a 表去重”,但因为 SELECT 列表里带了 b 表的字段,只要 b 有多条记录,a.id 依然会重复出现。这时候正确的做法不是加 DISTINCT,而是先想清楚:我到底要 a 表的行,还是要 a 和 b 的组合行?如果要的是前者,就该用 EXISTS 或者先在子查询里把 b 收敛成一行,DISTINCT 在这里只是把问题掩盖了,代价是白建一张临时表。
判断方法很简单:把你 SELECT 列表里的列全部列出来,问自己“这些列完全相同就算重复吗”,如果答案是“不是,我只在乎其中一列”,那你要的根本不是 DISTINCT。
1.2 多列组合、NULL 与排序规则:判定“相同”的三条隐含规则
DISTINCT 说“相同”的时候,背后有三条规则在起作用,不理解这三条,结果就会莫名其妙对不上。
第一条是NULL 相等。SQL 里NULL = NULL是 UNKNOWN,但在 DISTINCT 和 GROUP BY 的语境下,所有 NULL 被视为同一个值,只会保留一条。这点和 WHERE 条件里的比较逻辑完全不同,很多人第一次发现“去重后 NULL 只剩一行”时会愣一下。
第二条是排序规则(collation)决定字符串是否相等。utf8mb4_general_ci这类_ci结尾的排序规则是大小写不敏感的,'abc'和'ABC'会被判定为同一行,DISTINCT 会合并它们;换成utf8mb4_bin就区分大小写。这不是 DISTINCT 的锅,是列定义决定的。如果业务上要求大小写敏感去重,去重前必须显式改写:SELECT DISTINCT BINARY name FROM t或者SELECT DISTINCT name COLLATE utf8mb4_bin FROM t,但注意加了函数或 COLLATE 之后索引基本就用不上了,要权衡。
第三条是尾部空格的填充语义。8.0 默认的utf8mb4_0900_ai_ci是 NO PAD 类型,'a'和'a '被认为是不同的值;而 5.7 常见的utf8mb4_general_ci属于 PAD SPACE,比较时会忽略尾部空格,两者被判为同一行。同一个库从 5.7 迁到 8.0,DISTINCT 出来的条数变多,八成就是这个原因,而不是数据被谁改了。
1.3 作用域:DISTINCT 永远贴着 SELECT 列表
DISTINCT 的作用范围只覆盖 SELECT 列表,它对 WHERE、JOIN、GROUP BY 的中间结果不产生任何“提前去重”的效果。也就是说SELECT DISTINCT ... FROM a JOIN b ON ...的执行顺序依然是:先按连接条件把结果集铺开,再在投影阶段对最终列组合去重。连接放大一百倍,你就是要处理一百倍的行再去重,DISTINCT 一点忙都帮不上。
这也是为什么“在 JOIN 后面加 DISTINCT”是我最不建议的写法。它不是错的,它是贵的。真正省事的做法是在数据放大的源头拦掉,这一点在第 6 节会详细展开。
还有一个容易被忽略的细节:SELECT DISTINCT COUNT(*) FROM t这种写法里,聚合之后只有一行,DISTINCT 没有任何作用,属于纯冗余。同理SELECT DISTINCT * FROM t在单表上是毫无意义的开销,因为主键保证行唯一,只有存在 JOIN 或者视图展开时才可能产生重复。
2. DISTINCT 的执行路径:临时表、排序,还是索引
2.1 三条路径与 EXPLAIN 里的信号
MySQL 实现 DISTINCT 大致有三条路,开销差着数量级。
第一条是建临时表去重。扫描驱动表,把每一行的去重列扔进一张临时表,插入前先查重(内存临时表上通常靠哈希索引,磁盘临时表上靠唯一索引或排序)。EXPLAIN 的 Extra 里会看到Using temporary。这条路的成本跟你扫了多少行强相关,跟最终剩多少行关系不大。
第二条是排序去重。把结果集按去重列排序,然后顺序扫描相邻行比较。Extra 里会看到Using temporary; Using filesort。排序是有内存上限的,sort_buffer_size装不下就会退化成磁盘归并,这时候磁盘 IO 会直接把查询拖垮。
第三条是利用索引天然有序。如果 DISTINCT 的列正好能用上某个索引的前缀,优化器可以只扫描索引、跳过重复值,Extra 里会出现Using index for group-by(也就是常说的松散索引扫描),或者至少是Using index的覆盖扫描。这条路基本是免费的——扫描的 IL 数量级从“表有多少行”降到“有多少个不同的值”。
所以判断一条 DISTINCT 快不快,第一步永远是EXPLAIN看 Extra,而不是凭感觉调参数。
EXPLAIN SELECT DISTINCT city, province FROM user_addr WHERE province = 'ZJ';如果结果是Using where; Using temporary,那这条 SQL 在大表上基本没有救,必须改索引或改写法。如果能看到Using index for group-by,说明已经在最优路径上了,别再折腾。
2.2 用 Handler 计数器验证它到底扫了多少行
EXPLAIN 只给估算值,行数一多估得就不准。我习惯用 Handler 状态变量做一次实测,它能告诉你这次查询在存储引擎层真实读了多少行,比rows那一列可信得多:
FLUSH STATUS; SELECT DISTINCT city FROM user_addr WHERE province = 'ZJ'; SHOW SESSION STATUS WHERE Variable_name IN ('Handler_read_key','Handler_read_next','Handler_read_rnd_next');Handler_read_next是顺着索引扫的行数,Handler_read_rnd_next是全表顺序扫的行数。如果一条“只返回 30 个城市”的去重查询,Handler_read_next显示读了几十万,那说明它虽然在扫索引,但扫的是密集索引扫描(把每一行都读了一遍再比较相邻值),而不是松散扫描。这时候把city放到索引更靠前的位置,往往能立刻见效。这个方法在排查“为什么加了索引还是慢”时特别管用,因为它能区分“索引没用上”和“索引用上了但用错了方式”两种完全不同的情况。
2.3 临时表什么时候会从内存掉到磁盘
真走到临时表那条路的时候,还有一个关键分水岭:临时表是在内存里还是落盘了。
内存临时表的容量上限取tmp_table_size和max_heap_table_size里的较小值。一旦估算超出,或者表里出现了 BLOB/TEXT 字段,MySQL 会直接放弃内存路线,转成磁盘临时表。8.0 之后内部临时表默认走 TempTable 引擎,内存上限由temptable_max_ram控制(默认 1GB),超出部分会先尝试用 mmap 文件,再退到 InnoDB 磁盘临时表。
想确认自己的查询有没有踩这条线,看两个全局状态就够了:
SHOW GLOBAL STATUS LIKE 'Created_tmp_tables'; SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables';两个值相除,磁盘临时表占比如果超过 10%,说明有大量查询在落盘。这时候要么优化 SQL 消灭临时表,要么把tmp_table_size调大——但后者只是缓解,治不了根。我个人的经验是:单价超过 200ms 的报表查询里,八成都能通过改索引或改写把Using temporary干掉,比调参数收益大得多。
3. DISTINCT 与 GROUP BY 的取舍
3.1 语义上什么时候等价、什么时候不等价
SELECT DISTINCT a, b FROM t和SELECT a, b FROM t GROUP BY a, b在 8.0 上基本等价,优化器甚至会在内部互相改写。但“基本等价”不等于“随便选”,有两个边界必须清楚。
第一个边界是要不要写聚合函数。SELECT a, COUNT(*) FROM t GROUP BY a这件事 DISTINCT 做不到,你必须用 GROUP BY。反过来,如果只是想对整行去重,两者在结果上没有区别。
第二个边界是输出的确定性。如果你写SELECT a, b FROM t GROUP BY a,在ONLY_FULL_GROUP_BY模式下会直接报错,因为 b 既不在 GROUP BY 里也没被聚合——这其实是好事,它帮你拦住了一个模糊语义。而SELECT DISTINCT a, b从一开始就明确了“按组合去重”,不会产生这种歧义。所以我的偏好是:纯粹的行去重用 DISTINCT,需要分组计算用 GROUP BY,不要为了“听说 GROUP BY 更快”故意把 DISTINCT 改写成 GROUP BY,那只是换了个壳。
3.2 8.0 之后排序行为的变化与 ORDER BY 的坑
这里有一个历史包袱必须说清楚。在 5.7 及更早的版本里,DISTINCT 的实现高度依赖排序(临时表或 filesort),所以很多人观察到的结果是“DISTINCT 出来的数据是排好序的”,甚至有人写代码依赖这个顺序。
这个顺序从来不是承诺,在 8.0 里更是彻底不可靠。一旦 DISTINCT 走上松散索引扫描或者哈希去重路径,输出顺序就完全变了。所以只要你的业务对顺序有要求,就必须显式写ORDER BY,一个字符都不能省。
而写ORDER BY的时候又会踩另一个坑:MySQL 从 5.7 开始,对 DISTINCT 查询的 ORDER BY 列有硬性要求——排序列必须出现在 SELECT 列表里,否则直接报错 3065:
-- 报错:ORDER BY clause is not in SELECT list, references column 't.status' -- which is not in SELECT list; this is incompatible with DISTINCT SELECT DISTINCT city FROM user_addr ORDER BY status;解决办法只有两个:要么把status加进 SELECT 列表(但这样就改变了去重粒度,结果会变多),要么把排序放到外层去做——先用 DISTINCT 拿到确定的结果集,再在应用层或者包一层子查询排序。后者在报表导出的场景里很常用。
3.3 选型判断的四个问题
我在评审别人的 SQL 时,如果看到 DISTINCT,通常会问四个问题,基本能覆盖绝大多数误用。
一是“你真正想要的粒度是什么”,回答不出来就说明 SELECT 列表没想清楚。二是“这张表预计扫多少行”,如果扫行数远超最终结果行数,说明索引方向没优化。三是“加了 DISTINCT 之后结果行数变化多少”,如果几乎没变化(比如只从 10 万降到 9.9 万),那 DISTINCT 基本是在做无用功,说明上游连接产生了重复,应该从连接侧解决。四是“业务能不能接受近似值”,这个问题决定要不要上概率统计算法,在计数场景里非常关键。
4. 让 DISTINCT 走索引:字段顺序与覆盖
4.1 松散索引扫描的硬条件
松散索引扫描是 DISTINCT 最快的执行方式,但它有相当严格的前提条件,我列成表看得更清楚:
| 条件 | 说明 | 常见的破坏方式 |
|---|---|---|
| 只涉及单表 | 参与去重的列必须来自同一张表 | 多表 JOIN 后去重,优化器直接放弃 |
| 使用复合索引 | 单列索引上效果有限,需要多列组合 | 只给单列建了索引 |
| 去重列是索引前缀 | 顺序可以不同,但中间不能有缺口 | 索引是 (a,b,c),去重的是 (a,c) |
| 没有范围查询干扰 | WHERE里的范围条件会让前缀失效 | WHERE dt > '2024-01-01'加去重列不在同一前缀 |
| 不能有函数包裹 | 去重列必须是裸列 | DISTINCT DATE(create_time) |
举个具体的例子。表上建了索引idx_prov_city (province, city),那么:
-- 能走松散索引扫描 SELECT DISTINCT province FROM user_addr; -- 也能,顺序不一致不影响 SELECT DISTINCT city, province FROM user_addr; -- 走不了,因为 city 单独在前缀之外(8.0.13 之前) SELECT DISTINCT city FROM user_addr;最后这条在 8.0.13 之后有了新解法:索引跳跃扫描(Skip Scan)。优化器可以在缺少最左列的情况下,用“先枚举 province 的取值,再在每个取值里扫 city”的方式命中索引。代价是最左列的基数不能太大,如果 province 有三千个不同值,跳跃的收益就没了。所以别指望 Skip Scan 兜底,索引该建对还是要建对。
4.2 复合索引字段顺序怎么排
排索引字段顺序有个很实用的判断标准:把 DISTINCT 里的字段全放进去,然后按“等值查询字段 → 去重字段 → 排序字段”的顺序排。
比如查询是SELECT DISTINCT city, province FROM user_addr WHERE country = 'CN' ORDER BY province,最合适的索引是(country, province, city)。country是等值条件放最前,province既是去重列又是排序列放中间,city放最后。这样一次索引扫描就能同时满足过滤、去重、排序三个需求,Extra 里不会再出现Using temporary或Using filesort。
反过来的失败案例我也见过不少:索引建成(city, province, country),看着字段齐全,结果country的等值条件用不上,只能全索引扫一遍,去重还是得建临时表。排顺序这件事,差一个位置,性能差一个数量级。
还有一个容易被忽视的点:索引必须是覆盖的。如果查询只需要这三个字段,索引里全部包含,那就是纯索引扫描,连回表都省了。一旦你在 SELECT 列表里多加一个不在索引里的字段,每一行都要回表,松散扫描瞬间退化成密集扫描,性能立刻掉下来。所以我会养成习惯:写完 DISTINCT 语句,数一下 SELECT 列表里的列数,跟索引覆盖的列数对一遍。
4.3 表达式 DISTINCT 与函数索引
SELECT DISTINCT DATE_FORMAT(create_time, '%Y-%m') FROM orders这种按月去重的写法,在 8.0.13 之前是彻底没救的——函数包裹让索引完全失效,只能全表扫加临时表。8.0.13 引入了函数索引(也叫表达式索引),可以直接给表达式建索引:
ALTER TABLE orders ADD INDEX idx_month ((DATE_FORMAT(create_time, '%Y-%m')));建完之后,这个 DISTINCT 就能命中索引了。注意语法上必须用双层括号,这是函数索引的固定写法,少一层会报错。另外函数索引对写入有一点额外开销,只在对查询性能有明确要求的场景用。
如果版本不支持函数索引,退而求其次的方案是加一个冗余的生成列(generated column),把表达式结果落成一列,再给这一列建普通索引。这个方案在 5.7 上就能用,代价是多占一点存储。
5. COUNT(DISTINCT) 的深水区
5.1 单列与多列的执行差异
COUNT(DISTINCT col)是我见过被用出最多花样的地方。从原理上说,它必须知道每个不同值的出现次数吗?不需要,它只需要知道有多少个不同的值,所以理论上可以走松散索引扫描。但实际能不能走,版本差异非常大。
单列COUNT(DISTINCT)相对友好:8.0 上只要列上有合适的索引,Extra 里就能看到Using index for group-by,扫描的是不同值的个数而不是行数。5.7 上的支持范围就窄一些,很多时候退化成扫描索引全部条目。
多列COUNT(DISTINCT a, b)在 5.7 上会被改写成派生表:先物化一个去重的临时表,再在外面 count。你能在 EXPLAIN 里看到derived那一行,rows动辄上百万,开销非常可观。8.0 对这条路做了优化,满足条件时也能走索引,但前提依然是“去重列构成索引前缀”。所以如果你在 5.7 上写多列COUNT(DISTINCT)觉得很慢,那不是错觉,是执行计划决定了的。
-- 5.7 上大概率物化派生表 SELECT COUNT(DISTINCT province, city) FROM user_addr; -- 用拆分的方式绕开,两列各建合适索引后分别统计 SELECT COUNT(*) FROM ( SELECT province, city FROM user_addr GROUP BY province, city ) t;5.2 超大数据量下的精确与近似方案
当数据量到了“扫一遍索引都要几十秒”的级别,就该考虑换思路了。这里没有银弹,只有两套权衡。
精确方案是预聚合。建立一个按天或者按小时的汇总表,每天定时任务跑一次GROUP BY把结果固化下来,查询时直接读汇总表。代价是实时性差、维护成本高,好处是结果绝对准确、查询毫秒级返回。报表类需求我几乎都推荐这条路,因为报表本来就不要求秒级实时。
近似方案是概率基数统计算法,比如 HyperLogLog 这类思路:用极小的固定内存估算不同值的个数,误差一般在 1% 以内。适合“UV 统计”这种业务上能接受少量误差的场景。落地的做法可以是在应用层用集合型数据结构累加,也可以是定期跑批把结果写进估算结构。选之前一定要跟业务方确认误差范围,统计数字对不上是要背锅的。
还有一种折中:位图。如果去重列的取值范围很小(比如状态、城市、渠道这种枚举型字段),可以为每个值维护一个 bitmap,用位运算算基数,既精确又极快。缺点是只适用于低基数字段,高基数字段的内存会爆炸。
5.3 分片统计的跨片去重
数据分片之后,COUNT(DISTINCT)会遇到一个数学上的坑:各分片的去重数相加,不等于全局去重数。同一个 province 在三个分片都出现,简单相加会多算两次。
正确的做法取决于业务容忍度。要求精确的话,只能把去重键拉到一个地方做全局去重,比如把所有分片的province, city组合拉到中间层用哈希集合去重,代价是网络传输量和内存占用。要求不严格的话,可以用估算算法:各分片算出本地的哈希素描,再合并素描得到全局估算值,HyperLogLog 天生支持这种合并操作,这也是它在分布式统计里流行的原因。
我在实际项目里更倾向于“把去重键收敛到一张全局维表”这个笨办法。维表数据量小(去重键的组合数通常远小于事实表行数),维护成本可控,查询时直接对维表 count,省掉了所有跨片去重的麻烦。
6. 把 DISTINCT 拆掉的四种改写手法
6.1 EXISTS 改写:消灭 join 放大
最常见的 DISTINCT 来源是“一对多连接后去重”。比如查“下过订单的用户”,很多人会写:
SELECT DISTINCT u.id, u.name FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 1;一个用户下十单,就要在结果集里处理十行再去重。改成 EXISTS 之后,每命中一个用户就直接短路,根本不会产生放大的行:
SELECT u.id, u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 1 );前提是orders(user_id, status)上有联合索引或者orders(user_id)上有索引,否则子查询会变成全表扫,反而更慢。改写前先看一眼orders表的索引,这是必须做的功课。实测下来,在十万级用户、百万级订单的量级上,这个改写通常有 5 到 20 倍的提升,而且结果集越大差距越明显。
6.2 窗口函数改写:取每组第一条
“每个城市取最新一条记录”这类需求,很多人会用 DISTINCT 加自连接硬凑,写出来又长又慢。8.0 之后直接用窗口函数,语义清晰,执行也更快:
SELECT city, user_id, created_at FROM ( SELECT city, user_id, created_at, ROW_NUMBER() OVER (PARTITION BY city ORDER BY created_at DESC) AS rn FROM user_addr ) t WHERE rn = 1;这里的核心变化是:从“先放大再去重”变成了“排序后按组取第一条”。前者需要处理全部行,后者虽然也要排序,但排序是分区内进行的,配合(city, created_at)索引可以大幅减少排序量。
需要注意的是,如果业务上允许并列(同一城市同一时刻有多条),ROW_NUMBER()会随便挑一条,结果不稳定;要稳定就用RANK()或者把ORDER BY的字段补全到唯一。这个细节我在生产上踩过,同一份报表两次导出结果不一样,查了半天才发现是并列值没处理。
6.3 预聚合与冗余字段
如果某个 DISTINCT 查询被高频调用(比如首页的城市列表),与其每次都去重,不如把结果固化下来。两种做法:
一是建汇总表,定时任务刷新。适合变化不频繁、实时性要求不高的维度数据。二是加冗余字段,比如在用户表里冗余一个“最后下单时间”,查询时直接过滤,不需要每次都去 orders 表做去重扫描。冗余字段的代价是写入时要维护一致性,通常放在事务里或者靠异步补偿。
我倾向于用汇总表,因为它的数据链路是单向的——只读不写,出问题好回滚。冗余字段一旦维护逻辑出 bug,脏数据很难追溯和修正。
6.4 UNION ALL 加外层去重
当 DISTINCT 的列很少、但过滤条件很复杂时,可以拆成几个UNION ALL的子查询各自走最优索引,再在外层统一去重:
SELECT DISTINCT city FROM ( SELECT city FROM user_addr WHERE province = 'ZJ' UNION ALL SELECT city FROM user_addr WHERE province = 'JS' ) t;这样每个子查询都能用上(province, city)索引做覆盖扫描,外层只需要对少量结果去重。比一条大 SQL 走 OR 条件或者跨多个省份全表扫要快得多。
但有个坑必须提:UNION(不带 ALL)本身就会去重,而且是排序或者哈希去重,成本不低。如果你已经在外层写了 DISTINCT,内层还写 UNION,等于去重了两次。我见过有人为了“保险”两个都写,结果性能直接砍半。
7. 踩坑实录与排查速查表
7.1 加了 DISTINCT 反而更慢的三种情况
情况一:本来就没有重复。表上主键唯一,查询只涉及单表,加 DISTINCT 完全是白建临时表。这种写法我见过很多,动机通常是“防止万一有重复”,但代价是每次查询都多一次全量去重。
情况二:SELECT 列表里有大字段。SELECT DISTINCT *或者带了 TEXT、JSON 列,内存临时表不支持这类字段,直接落盘。八百多万行的表,落盘去重能跑出分钟级。解决办法要么是只选需要的列,要么改用GROUP BY加预聚合。
情况三:DISTINCT 挡住了索引提前终止。带LIMIT的分页查询里,如果没有 DISTINCT,MySQL 扫够 N 行就能停;加了 DISTINCT 之后,为了确保去重结果正确,有时候不得不把所有候选行都处理完才知道前 N 个去重值是什么。除非排序和去重列跟索引顺序完全一致,优化器才能恢复提前终止的能力。
7.2 结果对不上:排序规则与尾部空格
线上出现过一类问题:同一份 SQL,测试环境去重后 1200 行,生产环境 1250 行,数据量级差不多,就是数不对。排查下来是两个库的字符集排序规则不一致——测试库是utf8mb4_general_ci(PAD SPACE,忽略尾部空格),生产库是 8.0 默认的utf8mb4_0900_ai_ci(NO PAD)。数据里有一批带尾部空格的脏数据,两个库判定的“相同”标准不同,去重结果自然不同。
排查这类问题的顺序是:先SHOW CREATE TABLE对比两边列的字符集和排序规则,再抽样看看有没有大小写混杂、首尾空格、全角半角混用的数据。如果确认是数据质量问题,优先清洗数据,而不是在 SQL 里到处加TRIM()或者BINARY——那会让索引彻底失效,把性能问题变成更大的性能问题。
7.3 排查速查表
| 现象 | 最可能的原因 | 排查动作 | 处理方向 |
|---|---|---|---|
| EXPLAIN 出现 Using temporary | 去重列用不上索引前缀 | 看索引定义与 SELECT 列顺序 | 调整复合索引顺序或补索引 |
| EXPLAIN 出现 Using filesort | 排序走了内存/磁盘排序 | 看 ORDER BY 列是否在索引里 | 让排序列进入索引,或去掉不必要的排序 |
| 加了 DISTINCT 没变快 | 上游 join 放大严重 | 对比加/不加 DISTINCT 的返回行数 | 改 EXISTS 或预聚合 |
| 去重结果条数对不上 | 排序规则或尾部空格差异 | 对比两库表定义,抽样查脏数据 | 统一排序规则,清洗数据 |
| COUNT(DISTINCT) 很慢 | 多列去重物化了派生表 | 看 EXPLAIN 是否有 derived | 改写成子查询 GROUP BY 或建索引 |
| DISTINCT 结果顺序不可靠 | 8.0 不再保证隐式排序 | 检查 SQL 是否显式 ORDER BY | 显式加 ORDER BY,或在外层排序 |
| 内存临时表落盘 | 结果集超 tmp_table_size 或含大字段 | 查 Created_tmp_disk_tables 占比 | 减少 SELECT 列,或改预聚合 |
我个人在实际操作中的体会是,DISTINCT 本身并不复杂,复杂的是它前面那一长串 FROM、JOIN、WHERE 决定的“输入有多脏”。真正要治的不是 DISTINCT 这个关键字,而是它上游产生重复的那一环。下次再看到一条慢的去重 SQL,先别急着调参数,先看 EXPLAIN 的 Extra、再数一数加了 DISTINCT 前后行数差多少,大概率就能定位到问题在索引、在连接、还是在数据本身。