先交代一个背景:前阵子同事跑月度对账,明明在 SQL 里加了 DISTINCT 去重,结果导出的订单用户数还是比业务系统里的数字多。排查了半天,最后发现他把 DISTINCT 当成了“把某一列重复值删掉”的万能工具,实际用在了多列拼接的场景上,出来的结果跟预期完全不挨着。这事之后我就想,SQL 里 DISTINCT 这个关键字,看着简单到不能再简单,但真正把它用对的人其实不多。
所以这篇不打算只给你列语法,而是把 DISTINCT 的底层逻辑、典型用法、爱踩的坑、以及和 GROUP BY、ROW_NUMBER 这类方案的选型差别一次讲透。适合刚接触 SQL 的初学者,也适合写过一两年 SQL 但偶尔被去重结果搞懵的开发者。
1. DISTINCT 到底在做什么:它的一次完整执行过程
1.1 先搞懂一个核心概念:DISTINCT 是对“行”去重,不是对“列”去重
很多初学者对 DISTINCT 的理解停留在“去掉重复值”这个层面,但这个表述极其容易误导人。DISTINCT 真正干的事,是对查询结果集中的整行数据进行去重——只有当两行数据的每一列都完全相同时,这两行才会被合并成一行。
举个例子,假设有张用户表:
SELECT city, age FROM users;返回结果:
| city | age |
|---|---|
| 北京 | 25 |
| 上海 | 30 |
| 北京 | 25 |
| 北京 | 26 |
如果你写:
SELECT DISTINCT city FROM users;会得到北京、上海两行,因为此时只查了一列,DISTINCT 判断的是 city 这一列是否重复。
但如果写:
SELECT DISTINCT city, age FROM users;返回的是三行:北京25、上海30、北京26。北京25出现了两次,合并成一次;北京26因为和北京25不是完全相同的行,所以保留。这时候如果有人还抱着“DISTINCT 就是给 city 去重”的想法,就会觉得“怎么北京还出现两次?是不是没去重成功?”
这就是最核心的认知偏差。DISTINCT 后面跟几个列,它就把几个列拼在一起当成一个整体来判断重复。不是“每个列各自去重”的组合结果。
1.2 从执行计划看 DISTINCT 的底层操作
只看语法容易理解偏,建议直接看执行计划。在 MySQL 里执行:
EXPLAIN SELECT DISTINCT user_id FROM orders;你会发现执行计划里通常会出现Using temporary。这说明数据库为了去重,需要把中间结果放进临时表,通过排序或哈希的方式找出重复行。简单来说,DISTINCT 的底层逻辑是:
- 先从表中取出符合 WHERE 条件的全部列数据;
- 将结果集中的每一行视为一个整体,放入临时结构;
- 通过排序(Sort)或哈希(Hash)方式比较各行是否完全相同;
- 重复的行只保留一个,返回最终结果。
这也就解释了为什么 DISTINCT 的性能损耗往往不是出在“去重”本身,而是出在“需要把所有目标列的数据都搬到临时表里做比较”这个过程。如果查询涉及的列特别多、数据量特别大,临时表的 IO 和排序开销会非常明显。
1.3 DISTINCT 对 NULL 的处理逻辑:NULL 与 NULL 被认为是“相同”的
还有一个高频误区是 NULL。很多新手以为 NULL 代表“未知”,那多个 NULL 之间应该算不同吧?但 DISTINCT 的处理逻辑是:NULL 与 NULL 被认为是重复的,只保留一行。
SELECT DISTINCT phone FROM customers;如果表里有三行 phone 都是 NULL,最终结果里只有一个 NULL。这个行为在不同数据库里是统一的(MySQL、PostgreSQL、SQL Server、Oracle 都是如此),但如果你用 COUNT(DISTINCT phone) 去统计,NULL 又会被直接忽略——这点后面讲 COUNT 的时候再展开。
2. DISTINCT 的几种常规用法与适用场景拆解
2.1 单列去重:最简单的形态,但注意别滥用
最基础的用法:
SELECT DISTINCT category_id FROM products;这个场景适合从明细表里提取“有哪些枚举值”。比如订单表里有 100 万行,想知道一共涉及多少个城市,用 SELECT DISTINCT city 就完事。
但这里有个容易被忽略的点:单列去重时,如果这一列没有索引,MySQL 大概率会走全表扫描加临时表排序,数据量大的时候你会明显感觉到慢。后面讲优化的时候会再提。
2.2 多列联合去重:最容易被误解的用法
SELECT DISTINCT user_id, product_id FROM order_detail;这个语句的语义是“返回所有不同的用户-商品组合”,不是“把 user_id 去重,再把 product_id 去重”。实际工作中,这个用法非常适合处理关联表。比如我们有个标签表和文章表的关联表,一个文章有多个标签、一个标签对应多篇文章,用 SELECT DISTINCT tag_id, article_id 就能拿到所有去重后的关联对。
2.3 COUNT(DISTINCT column):统计场景里的标准答案
这是 DISTINCT 在聚合场景下最常见的形态:
SELECT COUNT(DISTINCT user_id) AS uv FROM visit_log WHERE visit_date = '2024-01-15';统计某天访问人数,这是标准写法。这里必须注意几点:
- COUNT(DISTINCT col) 会忽略 col 为 NULL 的行,如果你希望把 NULL 也作为一种值统计进去,需要先用 COALESCE 做转换。
- COUNT(DISTINCT col1, col2) 在 MySQL 里是合法的,表示统计 col1 和 col2 组合起来不重复的行数。但 SQL Server 里这个写法会直接报错,你需要先做子查询再包一层 COUNT。
- COUNT(DISTINCT *) 在任何数据库里都不是合法写法,这一点经常有人写错。
2.4 搭配 ORDER BY 和 LIMIT 的组合用法
有些场景下你需要把去重后的结果排序并取前 N 条:
SELECT DISTINCT user_id FROM orders ORDER BY user_id DESC LIMIT 10;这里有个容易犯的错:如果你 SELECT 的列和 ORDER BY 的列不一致,很可能报错。比如:
-- MySQL 5.7 及之前会直接报错 SELECT DISTINCT user_id FROM orders ORDER BY created_at DESC;因为 DISTINCT 和 ORDER BY 同时使用时,ORDER BY 的列必须出现在 SELECT 列表中。MySQL 5.7 以前会直接报 "Expression #1 of ORDER BY clause is not in SELECT list" 的错误;MySQL 8.0 放宽了部分限制,但行为依然可能和预期不一致。最稳妥的做法是:先子查询去重,外层排序。
SELECT user_id FROM ( SELECT DISTINCT user_id FROM orders ) t ORDER BY created_at DESC;2.5 与窗口函数结合:更精细的分组去重
常规 DISTINCT 只能给出全局去重的结果,如果你希望“每个用户保留最近一条记录”这种精细控制,DISTINCT 就不够用了,需要 ROW_NUMBER() 窗口函数。
SELECT order_id, user_id, product_id, created_at FROM ( SELECT order_id, user_id, product_id, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders ) t WHERE rn = 1;这种写法本质上也是去重,但维度更精细:按 user_id 分组,组内按时间排序取第一条。它和 DISTINCT 的区别在于,DISTINCT 只能告诉你“有哪些不同的用户”,而窗口函数能告诉你“每个用户最新的那一条记录长什么样”。
提示:如果你只需要“去重后的某个列”,DISTINCT 就够了。如果你需要“去重后还要携带该行其他字段”,用窗口函数,不要试图用 DISTINCT 加 GROUP BY 去凑。
3. 五个我踩过的 DISTINCT 经典坑
3.1 坑一:多列 DISTINCT 被当成“各列分别去重”
这是我开头提到的同事踩的坑。他想统计“订单表里涉及了哪些用户和城市”,写了:
SELECT DISTINCT user_id, city FROM orders;他以为结果是“去重后的 user_id 列表”和“去重后的 city 列表”拼在一起,但实际返回的是 user_id 和 city 的组合去重。如果用户 A 既在北京下过单、又在上海下过单,他会看到两行:
| user_id | city |
|---|---|
| A | 北京 |
| A | 上海 |
他期待的却是:
| user_id | city |
|---|---|
| A | 北京 |
要拿到“每个用户对应的一个城市”,这不是 DISTINCT 能解决的,而需要明确业务规则——是取首次下单城市、最近下单城市,还是随便一个城市。业务规则一旦确定,才能决定是用 MIN、MAX、还是窗口函数。
3.2 坑二:COUNT(DISTINCT) 与 NULL 的统计偏差
统计某天有下单的用户数,大家最常用的就是:
SELECT COUNT(DISTINCT user_id) FROM orders WHERE order_date = '2024-01-15';这个没问题,因为 user_id 本身不应该为 NULL。但如果你统计的是“有多少用户填了备注”,而备注字段大量为 NULL:
SELECT COUNT(DISTINCT remark) FROM orders;返回结果会远小于实际订单数,因为 NULL 全部被忽略。如果需要把“没填备注”也算一种状态,应该先转换:
SELECT COUNT(DISTINCT COALESCE(remark, '未填写')) FROM orders;3.3 坑三:SELECT DISTINCT * 的性能黑洞
有一次我接手一个慢查询排查,发现研发在排查重复数据时图省事直接写了:
SELECT DISTINCT * FROM logs;日志表有四十多个字段,其中几个还是 TEXT 类型,查询跑了十分钟没出结果。DISTINCT 会对所有列做比较,TEXT 字段的比对开销极大,再加临时表和排序,基本属于自杀式查询。
如果确实需要看重复行,正确的姿势是先定位可能重复的关键列:
SELECT col1, col2, col3, COUNT(*) AS cnt FROM logs GROUP BY col1, col2, col3 HAVING COUNT(*) > 1;确认了重复范围,再做针对性的去重操作,而不是无脑 SELECT DISTINCT *。
3.4 坑四:DISTINCT + JOIN 产生非预期结果
订单表和订单明细表做 JOIN,统计有订单的用户数:
SELECT COUNT(DISTINCT o.user_id) FROM orders o JOIN order_detail d ON o.order_id = d.order_id;单看 SQL 没毛病,但如果 order_detail 表存在一个订单对应多条明细,orders 表本身的一行数据因为 JOIN 被放大成了多行,DISTINCT 依然能保证 user_id 去重,结果没问题。
但有一种场景会出问题:如果你去重的列恰好是 JOIN 之后被放大的列之一。比如:
SELECT DISTINCT o.order_id, o.user_id, d.product_id FROM orders o JOIN order_detail d ON o.order_id = d.order_id;这条语句的结果里,order_id 和 user_id 的组合会出现多次,原因是 d.product_id 不同。如果你脑子里的业务逻辑是“统计订单涉及的用户数”,这个 SQL 的结果完全不对。
这里要强调的是:JOIN 之后先理解结果集的粒度,再决定要不要加 DISTINCT。加了 DISTINCT 只是消除 JOIN 放大效应带来的重复,不代表业务上一定正确。
3.5 坑五:DISTINCT 和 GROUP BY 随便互用
DISTINCT 和 GROUP BY 在简单场景下结果很像:
SELECT DISTINCT city FROM users; SELECT city FROM users GROUP BY city;两条返回结果一致。于是很多初学者(甚至是有些经验的开发)直接得出“DISTINCT 和 GROUP BY 差不多”的结论。但两条语句的执行语义完全不同:
- GROUP BY 是分组聚合,分组之后你可以用 COUNT、SUM、MAX 等聚合函数;
- DISTINCT 是行去重,不能带聚合函数。
当你发现自己在 DISTINCT 后面还想算“每个组的数量”时,说明应该用 GROUP BY 而不是 DISTINCT。反过来,当你对一整行去重且不需要任何聚合时,用 GROUP BY 也能做到,但语义上不够清晰。
4. DISTINCT、GROUP BY、ROW_NUMBER 三兄弟怎么选
4.1 三种去重方案的对比
| 方案 | 核心语义 | 适用场景 | 性能特征 |
|---|---|---|---|
| DISTINCT | 对结果集整行去重 | 提取枚举值、统计唯一数 | 需要临时表+排序/哈希,列越多越慢 |
| GROUP BY | 按列分组后聚合 | 分组统计、带聚合函数 | 同样需要分组排序,但还能输出聚合结果 |
| ROW_NUMBER() | 组内编号,取特定行 | 保留每组最新/最旧一条记录 | 需要窗口排序,分区字段有索引会好些 |
4.2 实际操作里我的选择经验
第一优先级:如果只是“看一眼有哪些值”,用 DISTINCT,写起来快、语义直接。
第二优先级:如果要去重后还要统计数量、求和、平均,用 GROUP BY。比如统计各城市用户数:
SELECT city, COUNT(*) FROM users GROUP BY city;这个用 DISTINCT 完全做不到。
第三优先级:如果每个分组要取一条特定记录(比如每个商品的最新价格、每个用户最近的订单),用 ROW_NUMBER()。
不过还有一个冷门选项——DISTINCT ON,这是 PostgreSQL 的语法:
SELECT DISTINCT ON (user_id) user_id, product_id, created_at FROM orders ORDER BY user_id, created_at DESC;它的语义是按 user_id 分组后,取每组里 ORDER BY 排在最前面的那一行。很多从 PostgreSQL 转 MySQL 的同事会下意识写出这个语法,然后被 MySQL 直接报错。MySQL 里没有 DISTINCT ON,只能老老实实用窗口函数。
4.3 大数据量下的去重优化思路
在千万级数据表上做 DISTINCT,最容易碰到的瓶颈就是临时表过大。几条亲测有用的优化方向:
- 尽量缩小 SELECT 的列范围。只对必要的列去重,别把大字段带进去。
- 给去重字段加索引。如果经常对 user_id 做 DISTINCT,组合索引(user_id, 其他常查字段)往往能让去重走覆盖索引,省掉回表和临时表。
- 考虑两阶段去重。先在小范围内去重,再对外层结果去重。比如先按天统计去重用户,再把多天结果合并去重,减少单次排序数据量。
- 用 EXISTS 替代 COUNT(DISTINCT) 的场景。如果只是判断“用户是否有过订单”,不要写:
SELECT COUNT(DISTINCT user_id) FROM orders WHERE user_id = 123;应该写:
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = 123);前者要把所有满足条件的 user_id 拉出来去重统计,后者只要找到一条就返回,性能差距在小数据量上不明显,但大表上可能是几十倍的差距。
5. 真实场景里 DISTINCT 的几种正确打开方式
5.1 场景一:统计 PV / UV 时 COUNT(DISTINCT) 的天然优势
统计每日访问用户数,最直接的写法:
SELECT visit_date, COUNT(DISTINCT user_id) AS uv FROM visit_log GROUP BY visit_date;这里 DISTINCT 是必须的,因为一个用户一天可能访问几十次。但如果你还要算 PV:
SELECT visit_date, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv FROM visit_log GROUP BY visit_date;此处 COUNT(*) 统计所有访问记录数,COUNT(DISTINCT user_id) 统计去重后的用户数。注意这两者在一条语句里可以共存,因为它们服务的维度不同。
5.2 场景二:跨表关联时先缩小结果集再做 DISTINCT
实际工作中,我最常犯的错就是把 DISTINCT 加在一个已经 JOIN 得乱七八糟的结果集上。后来我给自己定了一个规矩:能提前缩小数据量,绝不最后 DISTINCT。
比如要从订单表和用户表统计“下单用户所在的城市分布”,如果用户表有几十个字段,直接 JOIN 后再 DISTINCT 会很浪费:
-- 不推荐:先 JOIN 全表再 DISTINCT SELECT DISTINCT u.city, o.user_id FROM orders o JOIN users u ON o.user_id = u.user_id;更好的方式是先在各表内部缩小范围,再 JOIN:
SELECT u.city, o.user_id FROM ( SELECT DISTINCT user_id FROM orders ) o JOIN users u ON o.user_id = u.user_id;这样先对订单表做一次轻量去重,再关联用户表,JOIN 时数据量小很多。
5.3 场景三:用 DISTINCT 快速核对两张表的数据差异
在实际工作中,我经常需要快速判断两张表的数据是否一致。一种取巧的方式是利用 DISTINCT 配合集合操作。
SELECT 'source' AS src, COUNT(*) AS cnt FROM ( SELECT DISTINCT id, name, price FROM product_source ) t UNION ALL SELECT 'target' AS src, COUNT(*) AS cnt FROM ( SELECT DISTINCT id, name, price FROM product_target ) t;两张表去重后的行数不一致,基本就能确定有差异,再去定位差异行。这一招在处理“上游系统导出数据”和“本地数据库存储数据”对不上的问题时非常实用。
5.4 场景四:谨慎用在 UPDATE / DELETE 之前的数据预览
很多时候,我们需要先预览一下“去重后影响哪些行”,再决定怎么 UPDATE 或 DELETE。比较安全的操作顺序是:
-- 1. 先预览 SELECT DISTINCT user_id FROM orders WHERE status = 'pending'; -- 2. 确认无误后再 UPDATE UPDATE orders SET status = 'processed' WHERE user_id IN ( SELECT user_id FROM ( SELECT DISTINCT user_id FROM orders WHERE status = 'pending' ) t );这里特别提醒:MySQL 里 UPDATE 的子查询中直接引用目标表会报“You can't specify target table for update in FROM clause”错误,需要像上面那样包一层临时表子查询。这个坑不是 DISTINCT 本身的坑,但和 DISTINCT 组合使用时非常容易碰到。
6. 最后的经验之谈
把 DISTINCT 的用法、原理、坑和优化方案讲完之后,说点我的主观看法。
DISTINCT 是 SQL 里最容易被低估的关键字之一,因为它看起来太简单了,简单到大部分人不会为它专门去查文档。但恰恰是这种“看起来简单”的语法,在实际业务里引发的数据质量问题最多。每一次用 DISTINCT 之前,都应该先问自己三个问题:我要去重的列是什么?重复行的定义是什么?去重之后我需要的字段是什么?这三个问题想清楚了,DISTINCT 的用法基本不会错。
另外,DISTINCT 不是性能问题的万恶之源,滥用 DISTINCT 才是。一条 SQL 如果跑得慢,先看执行计划里是不是有 Using temporary、Using filesort,再确认 SELECT 的列是不是被 DISTINCT 拖着全部进了临时表。很多时候,只需要把 WHERE 条件加严一点、把 SELECT 的列收窄一点,DISTINCT 的开销就能大幅下降。
在实际项目里,我最常用到的组合是“子查询 DISTINCT + 外层 JOIN”和“ROW_NUMBER 窗口函数”,前者用于快速拿枚举值,后者用于精确取分组内记录。DISTINCT 单打独斗的场景反而少,它更像是 SQL 工具箱里的一个基础零件,要和其他语法配合才能发挥最大价值。
最后分享一个我自己养成的习惯:写完带 DISTINCT 的 SQL,一定顺手跑一遍 COUNT(*) 和 COUNT(DISTINCT ...) 对比一下,确认去重比例是符合预期的。如果去重前后行数几乎没变化,很可能 WHERE 条件已经帮你过滤掉了大部分重复;如果去重比例异常高,就要怀疑是不是 JOIN 把一行数据放大了。这个习惯帮我避免过很多次上线后才发现数据对不上的尴尬局面。