一、DISTINCT 为什么会触发 filesort?
很多人对 DISTINCT 的理解停留在"去掉重复行"这个层面,却忽略了它在 MySQL 内部的真实执行路径。
当你写下一条 SELECT DISTINCT 语句时,MySQL 的优化器会尝试多种去重策略。最理想的情况是利用索引的有序性,边扫描边跳过重复值——这叫"流式去重",不需要额外的排序和临时结构。但如果索引不匹配、字段组合不对、或者数据量太大超出了内存处理能力,优化器就会退而求其次:先把所有结果塞进一个临时表,然后在临时表上做排序或哈希去重。
这就是 Using temporary 和 Using filesort 的由来。
具体来说,触发这两个标志的典型场景包括:
第一,去重字段没有合适的索引。 MySQL 只能全表扫描,把每一行都拉进临时表,再做去重判断。全表扫描加上临时表排序,性能自然一落千丈。
第二,多列去重但索引顺序不对。 比如你要对 col1, col2 两列去重,但索引建的是 col2, col1。MySQL 的索引扫描依赖最左前缀原则,顺序反了就无法利用索引的有序性跳过重复值,临时表在所难免。
第三,SELECT 的字段超出了索引覆盖范围。 即使去重字段有索引,但你还选了其他不在索引里的列,MySQL 就得回表去取数据行。回表操作会让去重的数据集急剧膨胀,临时表的压力随之飙升。
第四,DISTINCT 和 ORDER BY 字段不一致。 这种情况下 MySQL 必须先完成去重,再对结果集做一次额外排序。两次加工叠加,filesort 几乎不可避免。
用 EXPLAIN 查看执行计划时,一旦 Extra 字段出现 Using temporary; Using filesort,就说明 DISTINCT 的去重操作没有走索引捷径,而是走了最耗费资源的那条路。
二、解决方案一:用索引消灭临时表
这是最直接、也是效果最显著的手段。核心思路只有一句话:让 MySQL 能通过索引直接完成去重,而不是先拉数据再排序。
1. 单列去重:建单列索引
如果你的查询只是对单个字段去重,比如只取不重复的用户 ID,那么在这个字段上建一个普通索引就够了。MySQL 会沿着索引树扫描,利用索引本身的有序性,遇到连续相同的值直接跳过,根本不需要临时表。
2. 多列去重:建联合索引,且顺序必须对
多列去重是重灾区。假设你要对 department_id 和 user_id 两个字段的组合去重,那么索引必须是 (department_id, user_id) 这个顺序,不能反过来。原因很简单:MySQL 只能利用联合索引的最左前缀进行有序扫描。索引顺序和去重字段的顺序必须一致,才能实现"边扫边跳"的流式去重。
3. 覆盖索引:让查询完全不碰数据行
这是索引优化的终极形态。所谓覆盖索引,就是索引里包含了查询所需的全部字段。MySQL 只需要读索引页就能拿到所有数据,完全不用回表。
举个例子:你的查询是取不重复的 user_id,同时带了一个 WHERE status = 1 的过滤条件。最理想的索引是 (status, user_id)。这样 MySQL 先通过 status 过滤,再沿着 user_id 的有序性去重,全程只扫索引,不碰数据行,性能可以提升一个数量级。
但要注意:覆盖索引里不要塞多余的字段。如果某个字段不在 SELECT 列表里,加进索引只是增大了索引体积,对去重没有任何帮助,反而拖累写入性能。
怎么验证索引是否生效?
写完 SQL 之后,养成看 EXPLAIN 的习惯。重点关注三个地方:type 是不是 index 或 range(避免 ALL 全表扫描);key 是否命中了你建的索引;Extra 里有没有 Using temporary 和 Using filesort。如果这两个标志消失了,说明索引优化成功。
三、解决方案二:用 GROUP BY 替代 DISTINCT
很多人不知道,在 MySQL 内部,DISTINCT 和 GROUP BY 的执行逻辑几乎是一样的。优化器经常会把 DISTINCT 改写成等价的 GROUP BY 来执行。但在某些场景下,显式使用 GROUP BY 反而能拿到更优的执行计划。
为什么 GROUP BY 有时更快?
关键在于 MySQL 对 GROUP BY 的索引优化路径更成熟。尤其是 5.7 及以后的版本,GROUP BY 支持一种叫"松散索引扫描"(Loose Index Scan)的优化策略:只扫描索引中每个分组的第一条记录,中间的重复项直接跳过。这种优化对 DISTINCT 并不总是生效,但对 GROUP BY 几乎总是可用。
另外,当你的去重需求还伴随着聚合计算(比如统计每个分组的数量、最大值等),GROUP BY 是天然支持的,而 DISTINCT 做不到。
实际怎么用?
如果你原来写的是 SELECT DISTINCT col1, col2 FROM table,可以改写成 SELECT col1, col2 FROM table GROUP BY col1, col2。两者在单列或多列去重时语义等价,但后者在有合适索引的情况下,更容易触发松散索引扫描,从而避免临时表。
但要注意一个坑:如果你的 MySQL 版本是 5.7,并且开启了 only_full_group_by 模式,那么 GROUP BY 中必须包含 SELECT 里出现的所有字段。这有时候会让语句变得冗长,需要根据实际情况调整 sql_mode 配置。
什么时候该用 GROUP BY,什么时候该用 DISTINCT?
原则很简单:如果你只需要去重,不需要聚合,两者都可以,选哪个看 EXPLAIN 结果哪个更优。如果你需要聚合(COUNT、SUM、MAX 等),直接用 GROUP BY,别绕弯子。
四、解决方案三:重构查询,把去重操作前置
这是最容易被忽略、但在复杂查询中最有效的方案。核心思想是:不要让 MySQL 在巨大的结果集上做去重,而是先把数据缩小,再去重。
1. 用 WHERE 提前过滤
这是最基本的操作。去重之前,先用 WHERE 条件把无关数据剔除。比如你要取某个时间段内不重复的用户 ID,那就先加上时间范围过滤,让参与去重的数据量尽可能小。数据越少,临时表越小,排序越快。
2. 子查询分层处理
当 DISTINCT 和 JOIN 混在一起时,问题会被急剧放大。比如你要关联三张表,然后对某个字段去重。这种查询的结果集在去重之前可能已经膨胀了几十倍,临时表的压力可想而知。
优化方式是把去重操作下沉到最内层。先在单表或关联表层面完成去重,拿到一组精简的 ID 列表,再用这些 ID 去关联其他表获取完整信息。这样去重操作处理的数据量大幅缩减,临时表也就不再是瓶颈。
举个思路:原来是先 JOIN 再 DISTINCT,改成先在子查询里 DISTINCT 出唯一 ID,再用这些 ID 去 JOIN 主表。执行计划会从 Using temporary; Using filesort 变成干净的 Using index。
3. 分页场景的特殊处理
DISTINCT 加 LIMIT 分页是另一个重灾区。因为 MySQL 必须先找出所有不重复的记录,才能截取其中的某一段。数据量大的时候,这个"先全部找出来再截取"的过程极其耗时。
优化思路是:先在子查询里完成去重,生成主键列表,再用主键 JOIN 原表获取完整数据并分页。这样避免了对大字段做去重,也让分页操作只作用于精简后的主键集合。
4. 考虑用 EXISTS 替代 JOIN 式去重
在某些业务场景下,你去重的目的只是判断"是否存在",而不是要拿到完整数据。这时候用 EXISTS 子查询往往比 JOIN 加 DISTINCT 更高效,因为 EXISTS 一旦找到匹配就返回,不需要把所有数据都拉出来再去重。
五、额外的调优手段
除了以上三种核心方案,还有几个辅助手段值得一提:
调整排序缓冲区大小。 MySQL 的临时表能否在内存中完成,取决于 tmp_table_size 和 max_heap_table_size 这两个参数。如果临时表超过了这个阈值,就会落盘到磁盘,性能会出现断崖式下跌。适当调大这两个参数,可以让更多临时表在内存中完成,避免磁盘 I/O。
控制 SELECT 的字段数量。 DISTINCT 是对 SELECT 中所有字段的组合去重。字段越多,重复判断的开销越大,临时表也越大。只选真正需要的列,别把整行数据都塞进去。尤其要避免在 DISTINCT 查询中包含 TEXT 等大字段,那会让临时表的内存占用飙升。
关注 NULL 值的影响。 DISTINCT 把所有 NULL 值视为相同值,这和 GROUP BY 一致,但和某些应用层去重逻辑可能冲突。如果业务上 NULL 有特殊含义,需要提前处理。
六、怎么判断你的 DISTINCT 是否需要优化?
不是所有的 DISTINCT 都有问题。如果你的表只有几千行,去重字段有索引,EXPLAIN 显示走的是 index 扫描,没有临时表也没有文件排序,那就完全不需要动。
真正需要警惕的信号是:
- EXPLAIN 中 type 为
ALL,说明在全表扫描 - Extra 出现
Using temporary; Using filesort - 查询响应时间随着数据量增长而线性恶化
- 慢查询日志中频繁出现这条语句
遇到这些信号,就该按照上面三种方案逐一排查了。
写在最后
DISTINCT 引发的 filesort 问题,本质上不是 DISTINCT 这个关键字有什么缺陷,而是它暴露了索引设计和查询结构上的不足。每一次 Using temporary 的出现,都是数据库在告诉你:我找不到更聪明的办法了,只能用笨办法。
与其在运行时纠结该用 DISTINCT 还是 GROUP BY,不如在设计阶段就把索引建对、把查询分层做好。让去重操作发生在索引层面,而不是临时表层面——这才是解决问题的根本之道。
记住一句话:看到 Using temporary,先检查索引,再考虑改写 SQL,最后才去调参数。这个优先级,能帮你省掉百分之八十的排查时间。