MySQL DISTINCT 用法详解:从原理到慢查询优化
发布时间:2026/9/29 19:15:18来源:尧图网络
上周排查一条报表 SQL业务方说最近跑了快两分钟。我一看SELECT COUNT(DISTINCT user_id) FROM order_detail WHERE ...心里大概有数。DISTINCT 可以说是 MySQL 里最常用又被误解最深的写法之一几乎每个项目里都有它但底层走的是临时表还是索引很多人并不清楚。这篇文章我打算把 MySQL 里 DISTINCT 的用法、原理、坑和优化一次讲透适合所有写过 SQL 的开发者特别是被慢查询和去重结果不对折磨过的朋友。1. DISTINCT 的基本用法去重逻辑的边界在哪里1.1 单列去重谁都会但你真的懂吗最简单的 DISTINCT 就是单列去重SELECT DISTINCT city FROM users;返回 users 表里所有不重复的 city。这个例子大家都见过重点在于 MySQL 是怎么判断“不重复”的。它在内部对结果集中的每一行进行比较判断该行的取值是否和之前某行完全相等。这个“相等”的语义和我们直接用等号判断一样但不是走 MySQL 索引的等值比较而是基于排序或哈希分组形成临时数据然后输出每组的第一条记录。这里想提醒一个容易忽略的点DISTINCT 是对结果集去重不是对原始表去重。比如SELECT DISTINCT UPPER(email) FROM users;去重的是UPPER(email)这个表达式的结果而不是原始 email 字段。如果两条 email 大小写不同但 UPPER 后相同DISTINCT 后只剩一条。如果你期望保留不同大小写的 email那就要去掉 UPPER。这个区别经常出现在数据清洗场景里。另一个单列去重容易踩的坑是DISTINCT 出来的结果集默认是“没有顺序保证”的。虽然很多情况下 MySQL 会按索引顺序返回但你不应该依赖它。如果你想按某个顺序返回必须明确使用 ORDER BY并且要注意 DISTINCT 与 ORDER BY 的配合限制这个后面专门讲。1.2 多列去重去重单位是“组合”而不是“每个字段”多列 DISTINCT 的语法是SELECT DISTINCT col1, col2 FROM table;很多人会把 DISTINCT 误解为“只对第一个字段去重其他字段随便取一条”。这是最常见的错误认知。实际上DISTINCT 对 SELECT 列表里的所有字段组成的“组合行”一起去重。比如-- 数据(A, 1), (A, 2), (A, 1) SELECT DISTINCT col1, col2 FROM t;返回 (A, 1) 和 (A, 2) 两行因为 (A, 1) 重复了一次。col1 都是 Acol2 不同不算重复。这个语义决定了它不能实现“按 col1 分组后取每组一条记录”的效果。如果你有这种需求最常见的错误是只 SELECT DISTINCT col1, col2然后发现 col2 的取值并不是预期的更合理的方案是使用 GROUP BY col1 配合 MIN/MAX 或者其他聚合函数或者用窗口函数。初学者很容易把 DISTINCT 和 GROUP BY 混为一谈。举个例子一张学生成绩表里有学生、课程和分数你想知道每个学生都选了哪些课程学生、课程组合去重可以用 DISTINCTSELECT student_name, course_name FROM student_score;但如果你想列出每个学生以及他分数最高的那门课程DISTINCT 就完全无能为力了因为你还需要比较分数、保留分数最高的那行。这种需求必须用窗口函数或 GROUP BY 加 JOIN。1.3 与 SELECT 表达式组合时的行为差异再深入一点DISTINCT 可以出现在 SELECT 子句的开头但它实际作用的对象是谁看一个典型的例子SELECT DISTINCT col1, col2 1 AS col2_plus FROM t;DISTINCT 去重的是 col1 和 col21 这两个结果的组合。这个很好理解。但有些写法会让人困惑比如SELECT DISTINCT * FROM t;这种是去重全字段的组合等价于 SELECT DISTINCT col1, col2, ...。少数情况下我们确实需要这种全字段去重但它也被很多人拿来“洗数据”其实风险很大一旦表里存在 NULL、浮点精度差异、隐式转换等看起来一模一样的行可能因为内部表示不同而没有被去掉。还有一个容易出问题的写法是 DISTINCT 与函数、CASE WHEN 组合。举例SELECT DISTINCT CASE WHEN age 18 THEN 未成年 ELSE 成年 END AS age_group FROM users;去重单位是CASE表达式计算出的字符串。如果你原本以为它会按 age 先去重那就是理解偏了。事实上DISTINCT 只认 SELECT 列表最终投影出的值。关于 DISTINCT 和 NULLMySQL 里 DISTINCT 会把多个 NULL 合并成一个 NULL因为 NULL 在分组去重时被视为“相同”。这和 ORDER BY 里 NULL 的排序位置又有区别。后面我会专门讲 NULL 的坑。2. DISTINCT 与 GROUP BY看似同根生亦有不同命2.1 什么时候可以互相替代在很多情况下SELECT DISTINCT a, b FROM t和SELECT a, b FROM t GROUP BY a, b返回的结果集是一样的。因为 GROUP BY a, b 本身就把每个组合压成一行再 SELECT a, b 自然等价于 DISTINCT a, b。这意味着如果你只是想“去重”两者都能用。但 GROUP BY 能做更多它可以在分组结果上应用聚合函数比如 COUNT、SUM、AVG而 DISTINCT 不能。如果你既要去重又要计数那就必须用 GROUP BY 或者 COUNT(DISTINCT ...)。另外GROUP BY 可以带着 HAVING 对分组结果进行过滤DISTINCT 不行。举个例子SELECT city, COUNT(*) AS cnt FROM users GROUP BY city HAVING cnt 100;你没法用 DISTINCT 写出等价逻辑因为 DISTINCT 根本不会聚合。2.2 GROUP BY 的额外能力可以带聚合函数、排序GROUP BY 的语义其实是“分组”而 DISTINCT 的语义只有“去重”。正因为侧重点不同两者在联合使用聚合、排序、HAVING 时表现出明显差异-- 合法统计每个城市的用户数只保留数量大于100的城市 SELECT city, COUNT(*) AS cnt FROM users GROUP BY city HAVING cnt 100 ORDER BY cnt DESC; -- 不合法DISTINCT 不能直接带聚合 SELECT DISTINCT city, COUNT(*) FROM users; -- 会报错实际上如果你只需要去重有人会建议用 DISTINCT因为它写起来更短也有人会建议用 GROUP BY因为某些版本的优化器对 GROUP BY 的处理更直接。但在 MySQL 8.0 里DISTINCT 和 GROUP BY 的底层执行都涉及临时表或索引性能差异不大。不过 DISTINCT 可能更符合“去重”的业务语义可读性更好。2.3 性能对比DISTINCT 是隐式分组GROUP BY 是显式分组从执行计划的角度看DISTINCT 常常被实现为“分组后取唯一值”和 GROUP BY 走同一条路径。也就是说在 EXPLAIN 里你看到 Using temporary 或 Using filesort往往并不是因为写了 GROUP BY而是因为 DISTINCT 需要额外的排序/哈希去重。这里有一个反直觉的结论在某些场景下DISTINCT 比 GROUP BY 更慢但更多时候两者半斤八两。真正决定性能的不是关键字本身而是数据量、是否命中索引、临时表是否落到磁盘。我自己见过一条 SQL 从 GROUP BY 改成 DISTINCT 后变慢的也见过反过来变快的。如果测试时遇到这种差异大概率是优化器基于统计信息做的执行计划不同而不是关键字本身有魔力的差别。既然底层路径类似该怎么选我个人建议如果只是去重用 DISTINCT语义清晰。如果还要聚合、HAVING、分组排序用 GROUP BY。永远不要以为 DISTINCT 就是“优雅的分组”它做不到按组输出非分组字段。3. DISTINCT 实战中的高频陷阱NULL、COUNT、多列组合3.1 COUNT(DISTINCT col) 的语义与你可能没想到的坑统计不同值的数量最常用的写法是SELECT COUNT(DISTINCT user_id) FROM orders;这个 SQL 的语义是先对 user_id 去重然后数出非 NULL 的唯一值数量。注意COUNT(DISTINCT col) 会忽略 NULL。如果 user_id 为 NULL 的行有 1000 条这 1000 条不会计入结果。如果你的业务中 NULL 表示“未知用户”这个结果也许是对的但如果 NULL 表示“匿名用户”你需要统计匿名用户数那就要特殊处理SELECT COUNT(DISTINCT COALESCE(user_id, anonymous)) FROM orders;这个细节很容易被忽略特别是在数据仓库里大量使用外连接产生 NULL 时统计结果可能会偏小。再举一个例子假设订单表里的 status 字段存在 NULL 和 SUCCESS 两种值业务想统计“出现过多少种状态”如果你直接SELECT COUNT(DISTINCT status)NULL 会被忽略结果只有 1。如果你把 NULL 当成一种状态就要用SELECT COUNT(DISTINCT IF(status IS NULL, UNKNOWN, status)) FROM orders;这种“NULL 算不算一个值”的语义问题在业务统计中常常导致线上数据对不上。建议在设计表时尽量给字段设置 NOT NULL DEFAULT从源头减少这类困扰。3.2 多列 DISTINCT 遇到 NULL结果可能让你意外来看一个多列去重与 NULL 结合的典型场景CREATE TABLE student_score ( student_name VARCHAR(20), course_name VARCHAR(20), score DECIMAL(5,2) ); INSERT INTO student_score VALUES (张三, 数学, 95), (张三, 英语, NULL), (李四, 数学, 88), (李四, 英语, NULL), (王五, 数学, NULL); -- 想找出所有 (student_name, course_name) 组合 SELECT DISTINCT student_name, course_name FROM student_score;按 DISTINCT 对组合去重的逻辑虽然三、四行的 score 都是 NULL但 student_name 和 course_name 的组合分别是 (张三,英语)、(李四,英语)是不同的行所以不会被合并。这说明多列 DISTINCT 的去重对象是整行组合某一列的 NULL 不会让整行被忽略反而可能让“看似相同”的组合因 NULL 未知而被保留——如果这两行的 student_name 和 course_name 也相同那么 NULL 列不会阻碍去重DISTINCT 会把它们合并成一条。这容易给人造成困惑NULL 到底是“相同”还是“不相同”在 DISTINCT 的语境里NULL 值之间视为相同但在多列组合里要看其他列的值是否也相同。另一个坑如果你想对多个列组成的复合键去重并同时要把 COUNT 统计出来用 COUNT(DISTINCT col1, col2) 是可以的。MySQL 8.0 支持多参数 COUNT(DISTINCT)形如SELECT COUNT(DISTINCT student_name, course_name) FROM student_score;它同样忽略全为 NULL 或部分 NULL 的组合吗确切地说COUNT(DISTINCT 多列) 会忽略“所有列为 NULL”的行但不会忽略只有部分列为 NULL 的组合。比如 (张三, NULL) 会被计入。这一点和单列 COUNT(DISTINCT) 不同。要小心。3.3 DISTINCT 与 ORDER BY 的冲突与解决MySQL 有一条规则如果 SELECT DISTINCT 与 ORDER BY 同时使用ORDER BY 中的字段必须出现在 SELECT 列表中。例如-- 报错ORDER BY 的 score 不在 SELECT 列表中 SELECT DISTINCT student_name FROM student_score ORDER BY score; -- 正确SELECT 中包含 score SELECT DISTINCT student_name, score FROM student_score ORDER BY score;但第二条 SQL 的语义变成了按 (student_name, score) 去重很可能不是你想要的。如果你希望按 score 排序后取出唯一的 student_name那就不能直接用 DISTINCT 加 ORDER BY score而应该用 GROUP BYSELECT student_name, MAX(score) AS max_score FROM student_score GROUP BY student_name ORDER BY max_score;或者用窗口函数比如取每个人的最高分再按分数排序。这是 DISTINCT 一个非常经典的坑想要“按某列排序后对另一列去重”却被语法限制挡在门外。理解这条规则背后的原因也很简单MySQL 需要保证排序字段的唯一值组合和 SELECT 结果集一致否则 DISTINCT 后排序字段可能对应多行排序就失去意义。在实际项目中遇到这种需求时我通常第一反应是先问业务排序字段到底要取哪一条记录是最大值、最小值、最新值明确之后用窗口函数写会比硬凑 DISTINCT 干净得多。4. 从 EXPLAIN 看 DISTINCT 的底层代价与索引优化4.1 一次真实慢查询的 EXPLAIN 分析之前我处理过一个慢查询表结构大概是这样CREATE TABLE user_login_log ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, login_date DATE NOT NULL, login_ip VARCHAR(30), KEY idx_user_date (user_id, login_date) );业务要统计某段时间内登录过的用户数SQL 是SELECT COUNT(DISTINCT user_id) FROM user_login_log WHERE login_date BETWEEN 2024-06-01 AND 2024-06-30;数据量只有几百万行但跑了将近 5 秒。EXPLAIN 结果里 type 是 rangekey 显示用了 idx_user_date但 Extra 里有 Using temporary。为什么因为 WHERE 条件用 login_date 做范围过滤MySQL 沿着索引找到符合条件的多个 entry但是要统计 user_id 的去重数索引顺序是先 user_id 后 login_date并不能直接告诉我们有哪些 user_id 是唯一的。于是优化器先按 user_id 进行一次分组去重临时表就产生了。4.2 用索引消除临时表DISTINCT 如何命中索引如果能设计一个覆盖 DISTINCT 所需字段的索引优化器可以直接扫描索引去重不需要额外排序。比如如果查询只需要 SELECT DISTINCT user_id那一个单独的 user_id 索引就够了MySQL 可以 order index scan 直接输出唯一值。但如果查询里有范围条件又要统计去重情况就复杂了。一个常用的优化思路是“把去重字段放在 WHERE 范围的左侧”。比如上面的需求如果再把 user_id 作为索引第一列login_date 作为第二列那么 WHERE login_date BETWEEN ... 其实是在索引的第二列上做范围扫描user_id 顺序有可能打乱因为 user_id 相同的数据不是连续的导致临时表。更好的方式是让 user_id 单独建索引或者调整索引顺序实际上没有万能方案。对于“按日期范围统计 user_id 去重数”更好的优化可能是把登录日志表按天分区然后扫描分区或者允许近似值用 HyperLogLog 之类的算法。但从 DISTINCT 本身来看有一条经验当 SELECT 列表和 WHERE 条件都能在一个二级索引内覆盖时DISTINCT 完全有可能通过 loose index scan 完成。MySQL 的 Loose Index Scan 可以跳过重复键用在 DISTINCT/GROUP BY 上。EXPLAIN 里的 Extra 会显示 Using index for group-by这代表它没有创建一个完整临时表而是利用索引顺序跳过相同值性能极好。但 loose index scan 有严格限制比如只能用于单表查询GROUP BY 字段必须符合索引最左前缀等。在我的实测里用覆盖索引把一条 COUNT(DISTINCT user_id) 的 SQL 从不加索引的 4.8 秒降到 0.2 秒关键就是让 user_id 成为查询可覆盖的索引列并让条件字段也走索引。具体做法要根据实际数据分布反复试。4.3 实在无法命中索引时的替代方案比如 EXISTS、窗口函数如果 DISTINCT 无法避免临时表通常可以考虑几个替代方案。一是用 EXISTS 改写。有些“对 A 表去重且关联 B 表”的查询可以改成 EXISTS 判断避免先把巨大结果集 DISTINCT 出来。比如-- 原始写法找出所有下过单的用户并对用户去重 SELECT DISTINCT u.id, u.name FROM users u JOIN orders o ON o.user_id u.id WHERE o.status SUCCESS; -- 等价改写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 SUCCESS );这个改写通常更快因为 EXISTS 是半连接只要找到一条满足条件的记录就停止不需要把订单表的所有匹配行都展开后再去重。二是用窗口函数 ROW_NUMBER() 生成序号再取每组第一条。这在“去重后保留某一字段最大值”的场景里很有用SELECT student_name, course_name, score FROM ( SELECT student_name, course_name, score, ROW_NUMBER() OVER ( PARTITION BY student_name, course_name ORDER BY score DESC ) AS rn FROM student_score ) t WHERE rn 1;这种写法比“DISTINCT 后关联取出其他字段”的方式更可控而且逻辑清晰在 MySQL 8.0 里性能也还不错。三是如果去重字段涉及多个表 JOIN 后的组合可以先在子查询里对需要的字段去重再 JOIN 其他表。不要一上来就 JOIN 再 DISTINCT这样会把 JOIN 的膨胀结果放大到内存里临时表很容易爆。5. DISTINCT 面试题与项目落地建议5.1 高频面试题及参考答案既然热搜里有“mysql 面试题”DISTINCT 几乎是 MySQL 面试题的常客。我收集了几个最常出现的题给出参考答案问题1SELECT DISTINCT 和 GROUP BY 有什么区别答两者在“按字段去重”的结果集上常常等价但语义不同。GROUP BY 是分组聚合可以配合 COUNT/SUM/AVG/HAVING 等使用而 DISTINCT 只是去除结果集中的重复行。底层实现上两者在 MySQL 里都可能走排序或临时表性能差异不大但 DISTINCT 可读性更直接。问题2COUNT(DISTINCT col) 会不会把 NULL 也算进去答不会。COUNT 本身忽略 NULLCOUNT(DISTINCT col) 同样忽略 NULL。如果需要把 NULL 当作一个值统计需要用 COALESCE 转成非 NULL 值再 DISTINCT。问题3SELECT DISTINCT * 和 SELECT * GROUP BY 所有列有什么区别答本质相同都是对全字段组合去重。但 GROUP BY 所有列可读性差一般不会这样写。需要注意包含 TEXT/BLOB 等大字段时GROUP BY/DISTINCT 都可能涉及大对象比较性能很差。问题4如何统计两个字段组合的去重数量答使用 COUNT(DISTINCT col1, col2)。MySQL 支持多列 COUNT(DISTINCT)注意它会把 NULL 当作值的情况部分列为 NULL 时仍会计数全列为 NULL 则忽略。问题5DISTINCT 导致临时表过大怎么办答优先看能否通过索引覆盖减少临时表如果无法避免检查 tmp_table_size 和 max_heap_table_size 是否足够避免临时表落到磁盘更治本的方式是改写 SQL用 EXISTS、窗口函数等替代。5.2 项目中滥用 DISTINCT 的反模式在真实的项目代码里我见过不少“为了去重而 DISTINCT”的写法其实隐藏着性能隐患在 JOIN 结果集上直接 DISTINCT导致中间结果巨大临时表占用大量内存。比如先 JOIN 两张表再 SELECT DISTINCT 几列。JOIN 本身已经把数据膨胀了DISTINCT 又要在膨胀后的结果上做去重双重代价。用 DISTINCT 代替 EXISTS 判断子查询里是否有匹配记录。可能有效但更容易写出低效执行计划。对包含 JSON、BLOB 的大字段做 DISTINCTMySQL 要对整个字段比较连临时表都放不下容易命中磁盘临时表限制甚至报错。在大表上做 SELECT DISTINCT * 用于“清洗数据”这是操作数据库的大忌很容易把实例拖垮。我推荐的经验是先明确“去重后有哪些列”再看这些列是否在一个索引上如果不在尝试通过改写 SQL 避免全量去重如果必须在结果集上去重那就把子查询结果先缩到最小再 DISTINCT。5.3 与去重需求相关的几条优化经验最后分享几条我在实践中沉淀下来的优化经验供各位参考。第一能用 PRIMARY KEY 或 UNIQUE 约束解决的数据重复不要在查询阶段去重。很多“重复数据”是因为表设计时没有唯一约束导致源头产生了重复。在查询里 DISTINCT 只是亡羊补牢应该先治理数据源加上唯一索引或者使用 INSERT ... ON DUPLICATE KEY UPDATE 来防止重复写入。第二如果一定要 DISTINCT优先创建一个小而精的覆盖索引。例如只需要 DISTINCT (a, b)建一个 (a, b) 联合索引如果需要 COUNT(DISTINCT a)单独建 (a) 索引就有机会走 loose index scan。这个经验看起来简单但我在实际优化中命中率很高。第三数据量上来后DISTINCT 去重统计可以考虑采用近似算法。例如要统计 UV用 Redis 的 HyperLogLog 替代 COUNT(DISTINCT user_id) 可以极大降低资源消耗接受一定误差。业务上大部分 UV 统计是允许 1% 以内误差的没必要让数据库硬扛。第四关于 MySQL 8.0 的窗口函数如果你被 DISTINCT ORDER BY 的限制卡住试试用 ROW_NUMBER 或 DENSE_RANK经常可以写出更灵活的 SQL而且执行计划不一定比 DISTINCT 差。我带过的团队里很多新人看到 DISTINCT 就上但遇到复杂去重需求会写不出来所以我建议把窗口函数当作 DISTINCT 的补充技能一起掌握。第五和 DISTINCT 相关的一个隐藏点是字符集和排序规则。如果字段是 utf8mb4_general_ci那么 DISTINCT 去重时会把大小写视为相同如果是 utf8mb4_bin大小写视为不同。很多去重结果“不对”的排查方向就在这里。同样的数据换个表或换个 collationDISTINCT 的结果可能就变了。这是很容易被忽略的坑。我已经把 MySQL 里 DISTINCT 的地基打得差不多了。说实话这个关键字看起来简单真正用对的人并不多。如果你在项目里遇到 DISTINCT 导致的慢查询或者去重结果和预期不一致不妨从今天这些角度再排查一遍。我踩过最大的一次坑就是把一个本可以用 GROUP BY 解决的问题硬写成 DISTINCT结果大量 NULL 行被漏统计复盘起来还是因为对语义理解不够。希望这篇文章能帮各位少走一些弯路。
网站建设高端定制企业官网