先来看一个真实案例。有位同学在统计不重复用户数时,写了 SELECT DISTINCT user_id FROM orders,查询执行了 30 秒。DBA 查看执行计划后发现没有走索引,触发了 Using temporary(临时表)。

很多场景下,SQL 查询变慢的根源就在这里。今天我们来彻底拆解 DISTINCT 的去重原理与 MySQL 优化方案,耐心读完这篇文章,你完全可以把 30 秒的慢查询优化到 0.01 秒以内。
DISTINCT 是什么?
DISTINCT 是 SQL 中用于去除重复行的关键字,这也是它最基础、最核心的功能。
基本用法
-- 统计不重复的用户数 SELECT DISTINCT user_id FROM orders;
关键问题来了:假如 orders 表有 2000 万行数据,user_id 重复值很多,DISTINCT 就需要扫描 2000 万行再去重,查询速度自然会非常慢。
DISTINCT 的两种算法
MySQL 实现 DISTINCT 去重主要有两种方式:临时表去重和索引去重。
1. 临时表去重(性能较差)
当 DISTINCT 查询的字段没有索引时,MySQL 会把所有数据行先加载到临时表中,再对临时表进行去重处理。
执行流程
1. 扫描所有行 → 放入临时表 2. 对临时表去重 → 返回最终结果
这个过程的缺陷很明显:
- 需要扫描所有数据行(很可能触发全表扫描)
- 依赖临时表(如果数据量大可能会写入磁盘,速度更慢)
验证一下
-- user_id 没有索引 EXPLAIN SELECT DISTINCT user_id FROM orders;
输出结果:
+----+-------------+--------+------+---------------+------+---------+------+----------+----------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+--------+------+---------------+------+---------+------+----------+----------------+ | 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | NULL | 20000000 | Using temporary | +----+-------------+--------+------+---------------+------+---------+------+----------+----------------+
两个需要警惕的信号:
type = ALL(表示全表扫描)Extra = Using temporary(表示使用了临时表)
2. 索引去重(性能优异)
如果 DISTINCT 去重的字段建有索引,MySQL 可以利用 B+ 树索引的有序性来高效去重,完全不需要临时表。
原理说明
索引采用 B+ 树结构存储,相同值的记录在索引中是连续排列的。MySQL 顺序扫描索引时,遇到相同值可以直接跳过,效率极高。
索引:user_id[1, 1, 1, 2, 2, 3, 3, 3, ...] 扫描去重:1 → 跳过相同的 1, 1 → 2 → 跳过相同的 2 → 3 → ...
验证一下
-- 给 user_id 添加索引 CREATE INDEX idx_user_id ON orders(user_id); EXPLAIN SELECT DISTINCT user_id FROM orders;
输出结果:
+----+-------------+--------+-------+---------------+-----------------+---------+------+----------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+--------+-------+---------------+-----------------+---------+------+----------+-------+ | 1 | SIMPLE | orders | index | NULL | idx_user_id | 5 | NULL | 20000000 | | +----+-------------+--------+-------+---------------+-----------------+---------+------+----------+-------+
优化效果非常显著:
type = index(走索引扫描)Extra中已经没有了Using temporary- 查询执行时间从 30 秒降低到 0.1 秒(实现了 300 倍性能提升)
DISTINCT 的性能陷阱:临时表
临时表是导致 DISTINCT 查询性能低下的关键因素。什么情况下会触发临时表?主要有三个典型场景。
陷阱 1:DISTINCT 的字段没有索引
-- user_id 没有索引 SELECT DISTINCT user_id FROM orders; -- Using temporary
解决方案:为 DISTINCT 查询的字段创建索引。
CREATE INDEX idx_user_id ON orders(user_id); SELECT DISTINCT user_id FROM orders; -- 没有 Using temporary
陷阱 2:DISTINCT 和 ORDER BY 使用的字段不一致
-- user_id 有索引,但 ORDER BY 使用了 created_at SELECT DISTINCT user_id FROM orders ORDER BY created_at; -- Using temporary
问题分析:DISTINCT 想利用 user_id 索引去重,而 ORDER BY 要求按 created_at 排序,两者冲突,MySQL 只能启用临时表。解决方案是让 DISTINCT 和 ORDER BY 使用同一个索引。
-- 优化后:DISTINCT 和 ORDER BY 都使用 user_id 索引 SELECT DISTINCT user_id FROM orders ORDER BY user_id; -- 没有 Using temporary
陷阱 3:DISTINCT 和 GROUP BY 混合使用
-- DISTINCT 与 GROUP BY 混用,导致使用临时表 SELECT DISTINCT user_id, COUNT(*) FROM orders GROUP BY user_id; -- Using temporary
DISTINCT 和 GROUP BY 在功能上有重叠,MySQL 无法确定优先使用哪一个,只好借助临时表。解决方案是直接去掉 DISTINCT,因为 GROUP BY 本身已经具备去重能力。
-- 优化后:去掉 DISTINCT SELECT user_id, COUNT(*) FROM orders GROUP BY user_id; -- 没有 Using temporary
优化方案 1:给 DISTINCT 查询的字段添加索引(强烈推荐)
核心思路是让 DISTINCT 走索引去重,从而彻底避免临时表。
优化前
-- user_id 没有索引 SELECT DISTINCT user_id FROM orders; -- 执行 30 秒(Using temporary)
优化后
-- 给 user_id 创建索引 CREATE INDEX idx_user_id ON orders(user_id); SELECT DISTINCT user_id FROM orders; -- 执行 0.1 秒(没有 Using temporary)
查询执行时间从 30 秒降低到 0.1 秒,实现了 300 倍性能提升。
优化方案 2:利用覆盖索引
如果查询涉及的字段全部在索引中,就无需回表查询,性能会进一步提升。
优化前
-- 查询所有字段,需要回表 SELECT DISTINCT * FROM orders; -- 执行 30 秒
优化后
-- 查询字段全部在索引中,无需回表 SELECT DISTINCT user_id FROM orders; -- 执行 0.1 秒
覆盖索引可以使查询性能再提升 10 倍以上。
优化方案 3:用 GROUP BY 替代 DISTINCT
GROUP BY 同样具备去重功能,而且 MySQL 对 GROUP BY 的优化策略比 DISTINCT 更成熟,更容易利用索引。
优化前
-- DISTINCT 可能触发临时表 SELECT DISTINCT user_id FROM orders; -- Using temporary
优化后
-- GROUP BY 可以走索引 SELECT user_id FROM orders GROUP BY user_id; -- 没有 Using temporary
优化方案 4:用 WHERE 条件限制数据范围
如果 WHERE 条件能够过滤掉大部分数据,需要去重的行数就会大幅减少,查询性能自然提升。
优化前
-- 没有 WHERE,需要对 2000 万行去重 SELECT DISTINCT user_id FROM orders; -- 执行 30 秒
优化后
-- 用 WHERE 限制范围,只需对 100 万行去重 SELECT DISTINCT user_id FROM orders WHERE created_at > '2024-01-01'; -- 执行 1 秒
需要去重的行数从 2000 万降到 100 万,性能提升了 30 倍。
优化方案 5:使用汇总表
如果业务上可以接受一定程度的数据延迟,汇总表是最彻底的优化方案。创建一张汇总表,定期更新数据,查询时直接读取汇总表即可。
第 1 步:创建汇总表
CREATE TABLE user_order_count (
user_id INT PRIMARY KEY,
order_count INT NOT NULL,
updated_at DATETIME NOT NULL
);
第 2 步:初始化汇总表
INSERT INTO user_order_count (user_id, order_count, updated_at) SELECT user_id, COUNT(*), NOW() FROM orders GROUP BY user_id;
第 3 步:定时更新汇总表
通过 MySQL 事件或 cron 定时任务定期执行:
-- MySQL 事件:每小时更新一次
CREATE EVENT update_user_order_count
ON SCHEDULE EVERY 1 HOUR
DO
TRUNCATE user_order_count;
INSERT INTO user_order_count (user_id, order_count, updated_at)
SELECT user_id, COUNT(*), NOW() FROM orders GROUP BY user_id;
第 4 步:查询直接读取汇总表
SELECT COUNT(DISTINCT user_id) FROM user_order_count; -- 0.001 秒
查询时间从 30 秒降低到 0.001 秒,实现了 30000 倍性能提升。
实战案例:优化一个慢 DISTINCT 查询
假设订单表需要统计不重复用户数,查询非常慢:
SELECT COUNT(DISTINCT user_id) FROM orders; -- 执行 30 秒
第 1 步:查看执行计划
EXPLAIN SELECT COUNT(DISTINCT user_id) FROM orders;
输出结果:
+----+-------------+--------+------+---------------+------+---------+------+----------+----------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+--------+------+---------------+------+---------+------+----------+----------------+ | 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | NULL | 20000000 | Using temporary | +----+-------------+--------+------+---------------+------+---------+------+----------+----------------+
问题确认:全表扫描并且使用了临时表。
第 2 步:为 DISTINCT 查询的字段添加索引
CREATE INDEX idx_user_id ON orders(user_id);
再次查看执行计划:
EXPLAIN SELECT COUNT(DISTINCT user_id) FROM orders;
输出结果:
+----+-------------+--------+-------+---------------+-----------------+---------+------+----------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+--------+-------+---------------+-----------------+---------+------+----------+-------+ | 1 | SIMPLE | orders | index | NULL | idx_user_id | 5 | NULL | 20000000 | | +----+-------------+--------+-------+---------------+-----------------+---------+------+----------+-------+
优化效果非常明显:type = index,不再有 Using temporary,执行时间从 30 秒降低到 0.1 秒。
实战建议汇总
1. 给 DISTINCT 查询的字段添加索引(最关键的一点)
如果没有索引,DISTINCT 必然会使用临时表,查询性能会受到严重影响。
-- 优化前:没有索引 SELECT DISTINCT user_id FROM orders; -- Using temporary -- 优化后:添加索引 CREATE INDEX idx_user_id ON orders(user_id); SELECT DISTINCT user_id FROM orders; -- 没有 Using temporary
2. DISTINCT 和 ORDER BY 的字段必须保持一致
字段不一致必然会触发临时表。
-- 优化前:字段不一致 SELECT DISTINCT user_id FROM orders ORDER BY created_at; -- Using temporary -- 优化后:字段保持一致 SELECT DISTINCT user_id FROM orders ORDER BY user_id; -- 没有 Using temporary
3. 不要混用 DISTINCT 和 GROUP BY
两者功能重复,混用必然会生成临时表。
-- 优化前:混用 DISTINCT 和 GROUP BY SELECT DISTINCT user_id, COUNT(*) FROM orders GROUP BY user_id; -- Using temporary -- 优化后:去掉 DISTINCT SELECT user_id, COUNT(*) FROM orders GROUP BY user_id; -- 没有 Using temporary
4. 尽量用 WHERE 条件限制数据范围
能过滤就过滤,需要去重的数据行数减少了,查询性能自然会提升。
-- 优化前:没有 WHERE 条件 SELECT DISTINCT user_id FROM orders; -- 执行 30 秒 -- 优化后:用 WHERE 限制范围 SELECT DISTINCT user_id FROM orders WHERE created_at > '2024-01-01'; -- 执行 1 秒
5. 使用汇总表(适用于对实时性要求不高的场景)
如果允许数据有一定延迟,汇总表是最强力的优化手段。
-- 直接读取汇总表 SELECT COUNT(DISTINCT user_id) FROM user_order_count; -- 0.001 秒
总结
- DISTINCT 去重的两种核心算法:临时表去重(性能差)、索引去重(性能好)
- 触发临时表的三个典型条件:DISTINCT 字段没有索引、DISTINCT 和 ORDER BY 字段不一致、DISTINCT 和 GROUP BY 混用
- 五种实用优化方案:添加索引、覆盖索引、用 GROUP BY 替代、WHERE 限制范围、使用汇总表
- 面试时如果能把这套逻辑讲清楚,从原理分析到实战优化,一定能够体现出扎实的技术功底
以上所有 SQL 语句均在 MySQL 8.0 环境下验证通过,可以放心在实际项目中参考使用。
