MySQL DISTINCT去重:执行原理、索引优化与踩坑排查
做数据这行久了,会发现一个很有意思的现象:同样是写 SQL,有的人写出来的DISTINCT又快又准,有的人一加DISTINCT查询就从几百毫秒掉到十几秒,还有人加完DISTINCT后数据是对的、业务逻辑却是错的。DISTINCT这个关键字在 MySQL 里出现频率极高,语法简单到几乎是入门第一课的内容,但真正把它用明白、用对、用快的人并不多。它既是去重工具,也是结果集语义的重新定义,还牵扯到临时表、索引扫描方式、排序规则、JOIN 行放大等一堆底层机制。这篇内容围绕 MySQL 的DISTINCT展开,从它到底做了什么讲起,一路拆到执行原理、典型写法、性能优化和踩坑排查,适合刚写完第一个GROUP BY的新手,也适合已经写过几年业务 SQL、但一遇到去重慢查询就靠"加索引试试"的老手参考。
1. DISTINCT 不是"消除重复",而是"重新定义结果集"
1.1 它作用的位置,是整个 SELECT 列表
很多人对DISTINCT的第一印象来自教材上的那句"用于去除重复记录",这个描述其实丢掉了最关键的信息:DISTINCT作用于SELECT 列表的整体,而不是某一列。你写SELECT DISTINCT a, b FROM t,它的语义是"取出所有不重复的 (a, b) 组合",而不是"把 a 列去重,然后随便配上 b 列"。这两者的差别在实际业务里非常致命,因为后者根本没有确定答案。
举个具体场景。某订单表里有user_id、city、created_at三个字段,有人想统计"有多少个不同的用户下过单",顺手写了SELECT DISTINCT user_id, city FROM orders,看到结果有 3000 行就当成用户数报上去了。可如果这个系统允许用户在不同城市下单,同一个user_id会配上多个city,结果行数直接虚高。这里的问题不在于DISTINCT写错了,而在于把"组合去重"当成了"单列去重"。
记住一句话:
DISTINCT后面跟几列,去重的粒度就是这几列的组合,少一列多一列,语义全变。
真正只针对一列去重的写法是SELECT DISTINCT user_id FROM orders,或者SELECT COUNT(DISTINCT user_id) FROM orders。如果既想按某一列去重、又想带出其他列的信息,这就不是DISTINCT能解决的问题了,需要GROUP BY配合聚合函数,或者 MySQL 8.0 的窗口函数,后面会详细讲。
1.2 一个常被忽略的事实:DISTINCT 不是函数
写代码的时候,函数是可以嵌套、可以作用在单个参数上的,比如UPPER(name)、ROUND(price, 2)。DISTINCT长得像函数,但它是 SELECT 子句的一个修饰符,位置固定在SELECT关键字后面,不能塞进某个表达式里。所以下面这些写法都是错的:
-- 语法错误,DISTINCT 不能作为函数使用 SELECT UPPER(DISTINCT city) FROM orders; -- 语法错误,不能只作用于某一列 SELECT id, DISTINCT city FROM orders;DISTINCT只能出现在SELECT之后、列清单之前。如果要对某一列"先转换再去重",正确写法是SELECT DISTINCT UPPER(city) FROM orders,去重发生在表达式计算之后,粒度是新生成的这一列。
唯一的例外是聚合函数里的COUNT(DISTINCT col)、SUM(DISTINCT col)这类写法,这里的DISTINCT是聚合函数的参数修饰符,语义是"先对参数值去重,再聚合"。COUNT(DISTINCT col)和SELECT COUNT(*) FROM (SELECT DISTINCT col FROM t) x在结果上等价,但执行路径完全不同,前者往往能借助索引直接完成,后者要物化一张派生表,性能差距可能是一个数量级。
1.3 三个高频误解,先在这里澄清
第一个误解是"DISTINCT会保留第一条出现的记录"。MySQL 的DISTINCT不承诺保留任何特定行,它只承诺结果集中每个组合出现一次。想"每组保留最新一条"这种需求,用DISTINCT是做不到的,必须靠窗口函数ROW_NUMBER()或者带聚合的子查询。
第二个误解是"DISTINCT会自动排序"。在老版本 MySQL 里,DISTINCT的实现常常依赖临时表加排序,结果看起来是有序的,于是有人写代码时就不加ORDER BY了。MySQL 8.0 之后内部实现调整,GROUP BY的隐式排序被移除,DISTINCT的输出顺序同样没有任何保证。依赖DISTINCT的隐式顺序做分页,是典型的定时炸弹。
第三个误解是"加了DISTINCT就更安全"。恰恰相反,DISTINCT会掩盖数据模型问题。一条 JOIN 查询出现重复行,通常说明关联关系是一对多,正确的处理方式是想清楚要拿哪一条,而不是无脑套一个DISTINCT把重复行压掉。压掉之后行数对了,但业务含义可能已经错了。
2. 执行路径拆解:MySQL 到底怎么把重复行挑掉
2.1 执行计划里那两行字意味着什么
判断一条DISTINCT查询快不快,第一步永远是EXPLAIN。看Extra列,常见的组合有这么几种,含义差别很大:
| Extra 输出 | 含义 | 大致代价 |
|---|---|---|
Using index for group-by | 走松散索引扫描,直接跳过重复值 | 最低,可提前终止 |
Using index | 覆盖索引,无需回表,但要逐行判重 | 较低 |
Using where; Using index | 覆盖索引加条件过滤 | 中等 |
Using temporary | 需要建内部临时表做去重 | 较高 |
Using temporary; Using filesort | 临时表去重后再排序 | 最高 |
最理想的是第一行Using index for group-by,也就是常说的松散索引扫描(Loose Index Scan)。它的思路很像你在纸质通讯录里找不同城市:因为城市这一列已经按字母排好序了,你不需要逐条读,看到"A 市"读完直接跳到下一个不同的值。MySQL 在有合适索引时也会这么做,扫描的索引条目数约等于不同值的个数,而不是总行数。一张一亿行的表如果有 200 个不同城市,松散索引扫描只读 200 次索引条目就完事了。
代价最高的是Using temporary; Using filesort组合,意味着 MySQL 得把所有候选行先拉出来、塞进内部临时表做唯一性判断,然后再排序输出。临时表一旦撑爆内存就会落到磁盘,这时候查询时间会随着数据量非线性上涨,很容易从几百毫秒变成十几秒。
2.2 临时表在内存和磁盘之间怎么切换
内部临时表的去向由几个参数共同决定。传统路径看tmp_table_size和max_heap_table_size,实际可用内存取两者中的较小值,超过阈值就转成磁盘临时表。MySQL 8.0 之后默认启用了 TempTable 引擎(internal_tmp_mem_storage_engine=TempTable),内存上限改由temptable_max_ram控制,默认 1GB,超出后会尝试用 mmap 文件(受temptable_max_mmap限制),再超出才真正落盘。
这里有个很容易被忽略的细节:去重本身需要维护唯一性判断,内存临时表为此要额外维护一份索引结构。也就是说,SELECT DISTINCT占用的内存并不是"结果集大小",而是要比结果集更大一些。一个常见的翻车现场是SELECT DISTINCT后面跟着几个很长的VARCHAR(255)字段,看起来只有几十万行,实际临时表内存占用翻了好几倍,直接触发落盘。
还有一种情况会更糟:DISTINCT的列里包含TEXT、BLOB或者超长VARCHAR。这类字段在部分临时表实现里无法高效存储,可能直接导致临时表走磁盘路径,哪怕总行数并不多。真要做这种去重,优先考虑把大字段排除在SELECT列表之外,或者用SUBSTRING、MD5之类的表达式把长度压下来再做去重判断。
2.3 索引存在时,DISTINCT 走得比你想的聪明
DISTINCT能不能用上索引,取决于索引的最左前缀是否覆盖了去重所需的列。假设在orders表上有联合索引idx_city_status (city, status):
-- 能走松散索引扫描,Extra 显示 Using index for group-by SELECT DISTINCT city FROM orders; -- 需要 (city, status) 组合去重,索引前缀完整匹配,通常也能优化 SELECT DISTINCT city, status FROM orders; -- status 不是索引最左列,前缀断了,只能走临时表 SELECT DISTINCT status FROM orders;第三句是最容易踩的坑。很多人看到联合索引里有status就以为能用,实际上DISTINCT status只能从头扫,索引里status的位置帮不上忙。这也是为什么"给每个字段都建一个联合索引"这种思路行不通,索引的最左前缀原则在DISTINCT场景下体现得格外明显。
松散索引扫描还有一些附加条件:必须是单表查询,聚合只能用MIN/MAX,索引列上不能有范围条件干扰。一旦WHERE里出现city > 'A'这种范围过滤,松散扫描往往就退化成紧凑扫描甚至临时表,执行计划会诚实地反映出来。
3. 场景化写法:几种常见需求的正确表达
3.1 单列、多列与聚合里的 DISTINCT
单列去重是最简单的形态,SELECT DISTINCT city FROM orders或者SELECT COUNT(DISTINCT city) FROM orders,这两种写法在有索引的情况下都能跑到很理想的性能。要注意COUNT(DISTINCT col)会忽略 NULL,也就是说如果city里有 500 行是 NULL,去重计数结果不包含这 500 行中的任何一个。想连 NULL 一起算,得写成COUNT(DISTINCT COALESCE(city, '未知')),但这会破坏索引可用性,变成表达式计算,性能会掉一截。
多列去重的语义是组合去重。SELECT DISTINCT city, status FROM orders取的是所有出现过的 (城市, 状态) 组合。同样的,COUNT(DISTINCT city, status)统计的是组合数量,并且只要组合中任意一列为 NULL,这一行就不计入。这个 NULL 处理规则是很多人算错数据的根源,尤其是在做报表统计时,一个 NULL 就让总数对不上。
聚合函数里的DISTINCT还有一个隐蔽的性能差异:COUNT(DISTINCT a)单列通常能走松散索引扫描,但COUNT(DISTINCT a, b)多列在多数版本里没法用松散扫描,只能建临时表。如果表很大,这个差异会非常明显,实测中单列几十毫秒、多列几秒钟是常见的。遇到多列去重计数,更稳的做法是先落一张按天预聚合的汇总表,把去重成本从查询时转移到写入时。
3.2 DISTINCT 和 GROUP BY 该怎么选
这两个东西在很多场景下结果完全一样,SELECT DISTINCT city FROM orders和SELECT city FROM orders GROUP BY city返回的行集合相同(顺序都不保证)。写哪个更好,取决于你想要表达的意图,以及后续要不要扩展。
| 对比项 | DISTINCT | GROUP BY |
|---|---|---|
| 语义表达 | 强调"去重后的集合" | 强调"按维度分组" |
| 能否带聚合函数 | 能,但一般无意义 | 能,天然搭配 |
| 能否用 HAVING | 不能直接写 | 能 |
| 索引利用 | 支持松散索引扫描 | 同样支持 |
| 扩展性 | 加聚合函数要改结构 | 直接加聚合列即可 |
MySQL 的优化器在 8.0 之后对两者的处理已经非常接近,很多情况下执行计划几乎一模一样,性能上没必要纠结。真正需要区分的是意图:如果这段 SQL 未来大概率要加统计列,一开始就写GROUP BY更合适;如果就是单纯想看看有哪些枚举值,DISTINCT更直白。
还有一个细节值得说:在ONLY_FULL_GROUP_BY模式下,GROUP BY要求SELECT列表里的非聚合列必须出现在GROUP BY里,不满足会直接报错。DISTINCT不受这条规则约束,因为它的语义天然就是"所有列一起去重"。这也意味着SELECT DISTINCT的列表一旦变长,去重粒度就变了,改动时要格外小心。
3.3 JOIN 之后的 DISTINCT 往往是问题信号
一条多表 JOIN 查询出现重复行,八成是因为关联的一方是一对多。这时候补一个DISTINCT确实能让结果"看起来正常",但代价往往是被隐藏起来的。
最典型的是这种写法:
SELECT DISTINCT o.id, o.amount FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.created_at >= '2024-01-01';orders和order_items是一对多,一个订单多个明细,JOIN 后订单行数被放大,DISTINCT再去重压回来。这么写有两个隐患:一是如果order_items数据量大,JOIN 阶段产生的中间结果先膨胀再收缩,磁盘临时表基本跑不掉;二是如果SELECT列表里不小心带了明细表的字段,DISTINCT就失效了,重复行又冒出来。
更稳的写法是改用EXISTS,从根上避免行放大:
SELECT o.id, o.amount FROM orders o WHERE o.created_at >= '2024-01-01' AND EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id);EXISTS只判断存在性,找到第一条就停,不会放大行数,也不需要临时表去重。在明细表数据量大的场景下,这个改写带来的提升经常是数量级的。
还有一种场景是"每组取最新一条"。这类需求用DISTINCT无论如何都表达不出来,因为DISTINCT不决定保留哪一行。正确姿势是 MySQL 8.0 的窗口函数:
SELECT id, user_id, amount FROM ( SELECT id, user_id, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders ) t WHERE rn = 1;3.4 ORDER BY、LIMIT 和 DISTINCT 的相互约束
DISTINCT加ORDER BY是报错重灾区。如果你排序的列没有出现在SELECT列表里,MySQL 会直接拒绝执行,报出类似 "Expression #1 of ORDER BY clause is not in SELECT list ... which is not in SELECT list; this is incompatible with DISTINCT" 的错误。原因很合理:去重后的每一行代表一组原始行,组内不同行的排序列值可能不一样,到底按哪个值排,语义上说不清。
-- 会报错,created_at 不在 SELECT 列表中 SELECT DISTINCT user_id FROM orders ORDER BY created_at DESC; -- 正确写法一:把排序列加进去,但去重粒度随之改变 SELECT DISTINCT user_id, created_at FROM orders ORDER BY created_at DESC; -- 正确写法二:改用聚合,明确取哪一行的值 SELECT user_id, MAX(created_at) AS last_time FROM orders GROUP BY user_id ORDER BY last_time DESC;第二种写法通常是业务真正想要的:"每个用户最近一次下单时间"。用DISTINCT硬凑是凑不出来的。
LIMIT和DISTINCT的配合也有个反直觉的地方。你可能以为SELECT DISTINCT city FROM orders LIMIT 10扫到 10 个不同城市就停了,实际只有走松散索引扫描时才能提前终止。如果走的是临时表路径,MySQL 必须先处理完所有候选行、完成去重、再排序,最后才应用LIMIT。所以这类查询慢起来,LIMIT完全救不了你,加一句LIMIT并不会减少扫描量。
4. 优化实操:从执行计划到 SQL 改写
4.1 索引设计的顺序原则
针对DISTINCT建索引,核心就一条:把去重所需的列按"高区分度在前、等值条件在前"的顺序放进联合索引,保证DISTINCT的列集合是索引的最左前缀。
举个实际调整的例子。某内容社区有个标签统计需求,原始表上有索引idx_status_created (status, created_at),查询是SELECT DISTINCT tag_id FROM articles WHERE status = 1。这条查询用不上索引里的tag_id,因为前缀被status占了,但tag_id在后面,理论上可以走覆盖扫描但没法松散扫描。把索引调成idx_status_tag (status, tag_id)之后,Extra直接变成Using index for group-by,扫描行数从几十万降到几千,查询时间从 1.2 秒降到 30 毫秒以内。
这类调整的判断依据很清晰:看EXPLAIN的key列是否用上、rows估算值是否接近不同值数量、Extra是否出现Using index for group-by。如果rows估算和总行数差不多,说明还在逐行扫描,索引顺序大概率不对。
注意:索引不是越多越好。每加一个联合索引都会增加写入成本和存储开销,如果这张表写多读少,宁可接受临时表去重,也不要为了偶尔一次的统计查询压上一条长期存在的索引。可以考虑定时任务跑预聚合,把结果落到汇总表。
4.2 三种改写手法与实测差异
第一种是COUNT(DISTINCT)拆成派生表。很多人写SELECT COUNT(*) FROM (SELECT DISTINCT a, b FROM big_table) t,在 MySQL 5.7 之后派生表可以被合并,但去重逻辑仍然存在,不一定更快。反过来,把复杂的派生表去重改成COUNT(DISTINCT a, b)通常更省资源,因为少了一层物化。这一点要用EXPLAIN ANALYZE(MySQL 8.0.18 之后可用)实测,看实际行数和实际耗时,别凭感觉。
第二种是DISTINCT换成EXISTS。前面提过,多表 JOIN 后的DISTINCT基本都应该往这个方向改。我试过一个订单列表查询,原写法DISTINCT加三表 JOIN,跑 4 秒多;改成EXISTS加主表驱动后,稳定在 200 毫秒左右。差距来源很明确,一个是先膨胀再收缩,一个是根本不膨胀。
第三种是把实时去重改成预聚合。这条思路适合报表类需求:每天凌晨跑一次INSERT INTO stat_daily SELECT date, COUNT(DISTINCT user_id) FROM events GROUP BY date,白天查询直接读汇总表。代价是数据有延迟,收益是查询性能彻底脱钩于原始数据量。用不用,取决于业务能不能接受这个延迟。
有时还可以用优化器提示做局部试验,比如临时把临时表内存调大看看效果:
SELECT /*+ SET_VAR(tmp_table_size = 268435456) */ DISTINCT city FROM orders;这个写法只影响当前语句,不影响全局配置,非常适合在生产环境做"如果内存够大能快多少"的验证。如果调大内存后耗时明显下降,说明瓶颈确实在临时表落盘,那就该从索引或者预聚合入手,而不是长期靠调参硬撑。
4.3 临时表参数的调整边界
看到Using temporary就去调tmp_table_size,是很常见的条件反射,但这条路能解决的问题有限。参数调大只是让更多临时表留在内存里,扫描量和判断逻辑一点没变。数据量再涨一倍,还是会落盘,只是延后了。
更重要的是,tmp_table_size是会话级的,单个连接能吃掉的内存会随并发数线性叠加。几十个并发同时跑大去重查询,内存很快就被吃满,反而容易引发更严重的连锁问题。真要动这个参数,得先算清楚"最大并发数 × 单次临时表峰值"是否在可控范围内。
我的做法是:先在测试环境用SET_VAR提示验证"内存充足时能快多少",如果提升巨大(比如从 8 秒到 800 毫秒),说明问题在临时表落盘,这时候优先想的是怎么让查询走索引,而不是加内存。只有当索引方案确实走不通、且查询频率可控时,才考虑调参数,并且一定要配合监控看磁盘临时表的创建次数(Created_tmp_disk_tables这个状态值),别让它在后台悄悄飙升。
5. 踩坑记录与排查速查
5.1 结果"看起来对但实际错"的几种情形
排序规则导致的误判是最隐蔽的一类。在utf8mb4_0900_ai_ci这类不区分大小写的排序规则下,'Beijing'和'beijing'会被DISTINCT认为是同一个值,只保留一个;同样,带重音符号的字符也可能被判等。如果业务上确实需要区分大小写,得用BINARY或者指定_bin、_as_cs之类的排序规则:
SELECT DISTINCT BINARY city FROM orders;尾部空格是另一个坑。CHAR/VARCHAR在不同排序规则下对尾部空格的处理不一样,utf8mb4_general_ci属于 PAD SPACE,比较时忽略尾部空格,'a'和'a '会被当成同一个;utf8mb4_0900_ai_ci是 NO PAD,两者算不同值。同一套 SQL 在不同排序规则的库上跑出不同结果,排查起来非常费劲。
隐式类型转换也会捣乱。如果DISTINCT的列是字符串类型,但里面有'001'、'1'、' 1'这样的值,在需要数值比较的上下文里可能被判等,去重结果就和预期对不上。这类问题通常伴随着WHERE条件里的类型不匹配一起出现,看到执行计划里type变成ALL就该警觉。
5.2 报错与执行计划异常的排查顺序
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 报 3065 错误 | ORDER BY 列不在 SELECT 列表 | 改聚合写法或补列 |
| 结果行数比预期多 | 去重粒度包含多余列 | 检查 SELECT 列表 |
| 结果行数比预期少 | 排序规则合并了不同值 | 检查 collation,必要时 BINARY |
COUNT(DISTINCT)偏小 | 目标列存在 NULL | 用 COALESCE 或单独统计 |
Extra 出现Using temporary | 索引前缀不完整 | 调整联合索引列顺序 |
| 加了 LIMIT 依然很慢 | 走临时表路径,无法提前终止 | 改索引或改写 SQL |
排查的顺序我一般固定成三步:先看结果对不对(语义层),再看EXPLAIN的rows和Extra(执行层),最后看临时表是否落盘(资源层)。顺序不能反,很多人一上来就调参数,结果语义本身就是错的,优化得再快也没意义。
EXPLAIN ANALYZE在 MySQL 8.0.18 之后非常好用,它会真实执行查询并输出每一步的实际行数和耗时,比传统EXPLAIN的估算值靠谱得多。去重类查询用它,能一眼看出时间到底花在扫描、临时表还是排序上。
5.3 我个人的几条固定习惯
第一条,写DISTINCT之前先问自己"我要的去重粒度到底是哪几列",把这几列明确写出来,绝不用SELECT DISTINCT *。星号写法一旦表结构变更,去重粒度就悄悄变了,而且几乎不可能走覆盖索引。
第二条,多表查询里看到DISTINCT就停下来想三秒,判断它是在表达需求还是在掩盖 JOIN 放大。能改成EXISTS或窗口函数的,优先改。
第三条,DISTINCT的列如果有 NULL,一定单独确认业务口径。是忽略、是归并成"未知"、还是单独统计,这三种处理方式在报表上的差异很大,不能靠默认行为蒙混过关。
第四条,跨库迁移或升级版本后,对涉及DISTINCT的关键查询重跑一遍结果比对。排序规则、临时表引擎、优化器行为在不同版本之间都可能变化,结果集哪怕只差一行,也可能是下游报表全线偏移的起点。
最后分享一个我用了很久的小习惯:给所有涉及DISTINCT的慢查询在注释里写清楚"为什么必须去重、去重粒度是什么、预期行数量级"。半年后回头看这段 SQL,或者换人接手时,这几行注释能省掉大量重新推导的时间。真正难维护的从来不是语法,而是当初那个"为什么这么写"的上下文。