开头先讲一件我上周实际处理的事:一个运营统计需求,要算某活动页的近七天访问用户数。表里user_id字段明明加了索引,第一版 SQL 写得也很顺手:
SELECT DISTINCT user_id FROM page_visit WHERE visit_date >= CURRENT_DATE - 7;跑出来 108 万行,运营拿去一对比,发现比埋点后台的数值多出将近一倍。我第一反应是埋点有脏数据,查了半小时才发现,问题根本不是 DISTINCT 不会去重,而是这个字段里同一个人的 ID 既有数字10086,又有字符串'10086',还有带不可见空格的' 10086'。DISTINCT 老老实实把三种"看起来是一个人"的数据当成了三种不同的值。
这其实就是大多数人对DISTINCT的误解集中点:它确实在做去重,但它的去重规则比很多人想象的严格得多。这篇内容我就把 SQL 里DISTINCT的原理、用法、注意事项和底层执行逻辑一次性讲清楚,适合刚学 SQL 的新人,也适合写过一两年 SQL 但被奇怪"重复数据"坑过的同学。
1. DISTINCT的去重真相:它比较的是整条投影行,不是某列
1.1 一张表看明白:两行数据什么时候算重复
DISTINCT的作用范围不是"你关心的那一列",而是SELECT关键字后面出现的所有列组合。光说概念不好理解,看一个具体例子。
假设有一张订单表:
CREATE TABLE orders ( order_id INT, customer_id INT, product_name VARCHAR(50) ); INSERT INTO orders VALUES (1, 101, '键盘'), (2, 101, '键盘'), (3, 102, '鼠标'), (4, 103, '键盘');执行:
SELECT DISTINCT customer_id, product_name FROM orders;结果是什么样的?
customer_id | product_name ------------+------------- 101 | 键盘 102 | 鼠标 103 | 键盘customer_id = 101虽然出现了两次,但因为两次的product_name都是"键盘",组合后的投影行完全一致,只保留一行。这才是 DISTINCT 做判断的真实粒度:它先把 SELECT 列表里的每一行组合成一个"比较串",再比较这些串是否完全相同。
1.2 "只针对某一列去重"是理解DISTINCT最大的误区
很多人写SELECT DISTINCT a, b FROM t时,心里想的是"对 a 去重,b 随便拿一条"。这跟 DISTINCT 的实际语义完全是两回事。
我们用同一张订单表演示一下这个误区:
SELECT DISTINCT customer_id, product_name FROM orders WHERE customer_id = 101;结果:
customer_id | product_name ------------+------------- 101 | 键盘因为 101 的两条记录 product_name 都是"键盘",所以这里看不出差异。但如果数据是这样的:
(1, 101, '键盘'), (2, 101, '鼠标')那SELECT DISTINCT customer_id, product_name FROM orders WHERE customer_id = 101的结果会同时返回两行:
customer_id | product_name ------------+------------- 101 | 键盘 101 | 鼠标这时候很多人的第一反应是"DISTINCT 失效了",其实不是失效,是 DISTINCT 根本没有"只对 customer_id 去重"这种语义。它永远是对整条投影行做完全匹配。想按 a 分组再取任意一条,应该用GROUP BY a配合MIN/MAX之类的聚合函数,或者用窗口函数ROW_NUMBER(),这些在后面的章节会说。
1.3 别把DISTINCT当排序用
DISTINCT不是ORDER BY。它不会改变行之间的先后顺序,也不保证去重后结果按某个字段有序。在很多数据库里,DISTINCT 的底层实现会走排序或哈希,所以你在结果里看到的顺序可能恰好是"排过序"的,但这只是实现细节,不是 SQL 语义承诺。
如果查完去重后必须按某个字段排序,请显式加ORDER BY:
SELECT DISTINCT customer_id FROM orders ORDER BY customer_id;这里有一个非常容易踩的坑:SELECT DISTINCT customer_id FROM t ORDER BY customer_name,customer_name 不在 SELECT 列表里,在 MySQL 的ONLY_FULL_GROUP_BY模式下会直接保错,在 SQL Server 里也会报"ORDER BY 项必须出现在选择列表中"。原因是 DISTINCT 会把结果压缩成 customer_id 的唯一值集合,而排序时已经没有 customer_name 可以用了。这不是数据库抽风,是语义上根本排不了。
2. 从SELECT DISTINCT到COUNT(DISTINCT):细数它的常规打开方式
2.1 单一列去重:最简单的场景
这是最基础、也是用得最多的形式:
SELECT DISTINCT department FROM employees;返回所有不重复的部门名。这种查询在数据体量不大、目标列有索引时效率很高,但如果表很大、列上又没有合适的索引,执行计划里往往会出现Using temporary或filesort。这个后面讲性能时专门展开。
2.2 多列组合去重:唯一组合里的应用
多列去重常用于判断"哪些组合是真实存在的"。举一个真实业务例子:一个用户可能对应多个角色,一个角色可能对应多个用户,想知道"用户-角色"一共有多少种有效组合:
SELECT DISTINCT user_id, role_id FROM user_role_map;这种查询在权限系统、标签系统里非常常见,本质是查出这张关联表的"唯一组合全集"。
2.3 COUNT(DISTINCT)的计数逻辑
统计去重后的数量,是DISTINCT在报表场景里最常用的一种打开方式:
SELECT COUNT(DISTINCT user_id) AS active_users FROM user_login_log;这里有两个细节必须说明:
第一,COUNT(DISTINCT col)会忽略NULL。如果user_login_log.user_id允许为空,那么这条 SQL 统计出来的活跃用户数不会包含任何NULL行。在很多数据清洗不严格的表里,缺失 ID 的日志行可能被"静默忽略",这在对比不同来源数据时容易产生差异。
第二,COUNT(DISTINCT col1, col2)这种写法在标准 SQL 里是支持的,它统计的是(col1, col2)组合去重后的行数。但 MySQL 从 8.0.29 之前的版本对多列COUNT(DISTINCT)的支持存在一些优化不足的情况;SQL Server 对COUNT(DISTINCT col1, col2)的支持则要看版本,老版本直接报语法错误。想在 SQL Server 里实现同样效果,更稳妥的写法是先子查询去重再计数:
SELECT COUNT(*) FROM ( SELECT DISTINCT user_id, login_date FROM user_login_log ) t;第三,COUNT(DISTINCT ...)在数据量达到千万级、亿级时,代价是很大的。它必须对所有唯一值做完整去重后才能计数,没法凭空预估。所以日活这类指标在超大表上,要么依赖预聚合表(如按天汇总后的结果表),要么接受一定的统计延迟。
2.4 不同数据库里的DISTINCT "变体"
标准 SQL 的 DISTINCT 实现流程大同小异,但不同数据库会加一些自己的变体。
PostgreSQL 提供了DISTINCT ON (expr),它允许你指定"按哪些字段去重",同时从每组里返回自己想要的那一行,这在"取每组最新一条"场景里非常好用:
SELECT DISTINCT ON (customer_id) customer_id, product_name, order_date FROM orders ORDER BY customer_id, order_date DESC;这条 SQL 的意思是:按 customer_id 分组,每组取ORDER BY排序后的第一行,也就是每个客户最近一次买的商品。注意这里有个硬性要求:ORDER BY的开头部分必须包含DISTINCT ON里的表达式,否则数据库无法决定每组的第一行是哪个。
MySQL 没有DISTINCT ON,但你可以通过窗口函数模拟,这个放到第四节讲。
还有一个容易混淆的兄弟:UNION本身自带去重语义。SELECT a FROM t1 UNION SELECT a FROM t2会把两张表的 a 合并后去重;如果不想去重,要用UNION ALL。很多人习惯写UNION而不知道它默默执行了一次 DISTINCT,在数据量大时白白浪费一轮排序去重,建议明确用UNION ALL。
2.5 与WHERE、ORDER BY、LIMIT的搭配顺序
DISTINCT与WHERE、ORDER BY、LIMIT一起使用时,执行顺序大概是:先WHERE过滤,再对过滤后的结果做 DISTINCT 去重,然后ORDER BY排序,最后LIMIT取前几条。
SELECT DISTINCT user_id FROM user_login_log WHERE login_date >= '2024-01-01' ORDER BY user_id LIMIT 10;这条 SQL 的逻辑是:先筛出 2024 年之后的登录记录,再取唯一 user_id,排序后输出前 10 个。理解这个顺序对你排查"为什么我 LIMIT 之后数量还是不对"这类问题很有帮助——LIMIT是在去重结束后才执行的,所以结果里的"10 条"是 10 个唯一用户,而不是 10 行原始日志。
3. 查询结果里多出来的"重复":大小写、空白、NULL与隐式类型转换
这一节是全文最有实战价值的部分。很多人问"为什么我用了 DISTINCT 还是有重复",十有八九原因落在下面几个分类里。
3.1 NULL在去重里算一个值吗
算,而且所有 NULL 在去重时被视为同一个值。执行:
SELECT DISTINCT user_id FROM user_login_log;如果表里有多行user_id = NULL,最终结果里只会出现一个NULL。这在逻辑上是合理的:数据库把 NULL 当作一个特殊的"未知值",未知值和未知值在去重时被视为相等,统一保留一行。
但这里有一个隐蔽的坑:如果你在 DISTINCT 后的结果集里做进一步处理,比如把结果导出、逐行判断"这行是 NULL 就跳过",是没问题的;但如果用WHERE user_id = NULL去匹配这个结果,那永远匹配不上。NULL 的等值判断必须用IS NULL,这是 SQL 基础,但也是最容易在大规模数据处理脚本里出问题的点。
3.2 大小写敏感度由排序规则决定
DISTINCT判断两个字符串是否相同时,是否区分大小写,由数据库的排序规则(collation)决定。不同数据库、不同版本、不同默认配置,行为差异很大。
拿最常见的场景举例。MySQL 里,如果你建表时用的是utf8mb4_general_ci或utf8mb4_0900_ai_ci这类_ci结尾的排序规则,那么:
SELECT DISTINCT name FROM users;如果表里有'Alice'和'alice',结果只会出现一个。因为_ci表示 case-insensitive,大小写不敏感,"Alice" 和 "alice" 被视为同一个字符串。
但在 PostgreSQL 里,默认排序规则通常是区分大小写的,同样的查询会把'Alice'和'alice'当成两个不同的值,全都会返回。SQL Server 则取决于库或列的 collation 设置,SQL_Latin1_General_CP1_CI_AS不敏感,SQL_Latin1_General_CP1_CS_AS敏感。
所以你在排查"DISTINCT 去重后怎么还有重复"时,先确认数据本身的字符是否只有大小写差异。如果业务上确实要把'Alice'和'alice'视为同一人,有两个方向:一是把列改成大小写不敏感的排序规则,二是查询时主动归一化:
SELECT DISTINCT LOWER(name) FROM users;注意,用LOWER后去重,结果里输出的是统一小写的值,原数据的大小写样式就丢了。
3.3 空格和不可见字符:数据清洗的头号敌人
我刚开头说的那个案例,就是典型的不可见字符问题。'10086'和' 10086'在数据库看来是两个完全不同的字符串——一个长度为 5,一个长度为 6。当你从不同系统导入数据、或者人工录入手误时,很容易混进空格、全角空格、\t、\n等不可见字符。
排查方法很直接:看看去重前后记录数差距,再把疑似重复的字段用HEX()或者LENGTH()比对。比如:
SELECT LENGTH(user_id), HEX(user_id), COUNT(*) FROM user_login_log WHERE user_id IN ('10086', ' 10086') GROUP BY LENGTH(user_id), HEX(user_id);一旦确认是空白类字符差异,数据清洗时统一使用TRIM()、REPLACE()归一化后再做 DISTINCT:
SELECT DISTINCT TRIM(user_id) FROM user_login_log;但TRIM()只能去掉行首和行尾的空格,数据中间的多个连续空格还是洗不掉,必要时配合REPLACE(user_id, ' ', '')或者正则函数处理。
3.4 隐式类型转换造成的"看起来重复"
数字和字符串是另一个重灾区。假设user_id是 VARCHAR 类型,里面同时存了数字10086和字符串'10086'。在 MySQL 里,因为它会把字符串和数字比较时做隐式转换,所以你直接SELECT DISTINCT user_id时,这两个值通常会被视为相同,因为比较时'10086'被转换成了数字10086。
但如果字段里同时存在'10086'、' 10086'、'10086 '、'010086',情况就复杂了。字符串比较和数字转换的规则在不同数据库里不完全一致,很可能出现"用等值查询能查到同一行,但 DISTINCT 后却输出了多个"的诡异现象。
我给你的建议是:做去重之前,先强制类型统一。用CAST(user_id AS CHAR)把所有值统一成字符串后再去重,或者反过来统一成数字CAST(user_id AS UNSIGNED)再去重。这样至少保证比较基准一致,不会因为隐式转换玩双标。
3.5 排查口诀:先看数据源,再看排序规则,最后看函数
如果你遇到"DISTINCT 没去干净",按照下面顺序排查,基本能覆盖 95% 的情况:
- 先确认 SELECT 列表里到底有几个字段。多列 DISTINCT 的结果是组合去重,不是你心里想的"只对某一列去重"。
- 再看数据本身。用
LENGTH、HEX检查是否有不可见字符,用LOWER/UPPER检查是否只有大小写差异。 - 最后看排序规则和隐式转换。确认数据库当前 collation 对大小写是否敏感,确认字段类型是否混入了数字和字符串。
这几步走完,绝大多数"假重复"都能定位到根因。
4. DISTINCT的性能账单:什么时候它不再是首选
4.1 EXPLAIN视角:DISTINCT在底层怎么干活
DISTINCT 不是千篇一律的实现。MySQL 里,一条SELECT DISTINCT col FROM t的典型执行计划会出现Using filesort或Using temporary。原因是优化器通常选择"排序去重":把目标列的数据全部取出,排序后相邻值比较,发现相同的就跳过。数据量大时,排序这一步要写临时文件,代价很高。
还有一种实现方式是哈希去重:把每一行投影结果计算成哈希值,用哈希表记录是否出现过。这种方式内存消耗大,但在某些场景下比排序快。三种主流数据库(MySQL、PostgreSQL、SQL Server)的优化器会基于数据量、可用内存、索引情况自动选择。你不需要记住所有优化细节,但要知道一个关键结论:DISTINCT 的成本差不多等于一次"对所有目标列的全量扫描 + 排序/哈希",它不是免费的。
4.2 DISTINCT vs GROUP BY:没聚合时谁更快
经典问题:SELECT DISTINCT a, b FROM t和SELECT a, b FROM t GROUP BY a, b在没有聚合函数时语义相同,那性能有差别吗?
从执行计划角度,两者通常没有本质区别,甚至优化器可能生成完全相同的执行计划。因为 GROUP BY 在无聚合函数时本质上也是"按 a、b 分组后每组取一行",去重逻辑一样。但有一个微妙的差异:GROUP BY 如果你配合HAVING COUNT(*) > 1之类的条件,可以做更多统计分析;纯粹去重场景下,两者选哪个都行,我习惯用 GROUP BY,因为它的语义更明确,到了后面要加聚合条件(比如"只保留出现次数大于 1 的")时不用改写 SQL。
注意别相信网上"GROUP BY 一定比 DISTINCT 快"这种话。在一百多万行的表上我两种都测过,执行时间几乎一致,真正的差距来自索引、数据分布和排序字段设计,而不是"那个关键字本身更高级"。
4.3 用EXISTS做半连接去重
有一些去重场景,表面上可以用 DISTINCT,但用EXISTS改写后性能好得多。典型场景是:查"在 A 表出现过、且在 B 表有匹配记录"的 A 表字段列表。
最开始的写法往往是这样:
SELECT DISTINCT a.customer_id FROM orders a JOIN customers b ON a.customer_id = b.customer_id;当 orders 表很大、customers 表不大的时候,这条 JOIN 会先把两张表连接出大量中间行,再做 DISTINCT 去重,中间结果可能是最终结果的几十倍。用 EXISTS 改写:
SELECT a.customer_id FROM orders a WHERE EXISTS ( SELECT 1 FROM customers b WHERE b.customer_id = a.customer_id );EXISTS 在右边匹配到第一条记录时就会短路返回,不再继续扫描,整体扫描行数通常远小于 JOIN + DISTINCT。这就是我用 ORDER 表关联客户表时常用的优化手段。
4.4 窗口函数ROW_NUMBER:去掉"每组取最新一条"的痛点
前面提到 PostgreSQL 的DISTINCT ON很好用。如果你在 MySQL 或 SQL Server 里,想"按客户分组,取每个客户最新一笔订单",用 DISTINCT 完全做不了,因为 DISTINCT 只能去重,不能"保留某一条"。这时候用ROW_NUMBER()窗口函数:
WITH ranked AS ( SELECT customer_id, product_name, order_date, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM orders ) SELECT customer_id, product_name, order_date FROM ranked WHERE rn = 1;PARTITION BY customer_id表示按客户分组,ORDER BY order_date DESC表示每组按日期倒序排,rn = 1取出每组第一条。这种写法在任何支持窗口函数的数据库里都能用,也比 DISTINCT + 子查询关联的方案清晰得多。
4.5 索引优化与COUNT(DISTINCT)的代价
想提升 DISTINCT 查询性能,最直接的办法是让目标列有合适的索引。比如SELECT DISTINCT department FROM employees,如果 department 上有索引,数据库可以走索引扫描直接获得有序的唯一值列表,跳过排序和临时表,速度会快很多。从执行计划看,这时Using index for group-by或者类似的分支会出现,说明优化器利用了索引本身的有序性。
COUNT(DISTINCT) 的代价更值得单独说。它面对大表时,必须把所有非 NULL 且唯一的取值全部找出来再计数,索引的作用往往只是把"扫描全表"变成"扫描索引",但去重这一步依然要完整做。我测过一个 2000 万行的日志表,COUNT(DISTINCT user_id)即便走了索引也要 20 多秒,后来改成凌晨跑批预聚合、前台只查汇总表,响应时间从 20 多秒降到几十毫秒,这才符合业务预期。
结论:DISTINCT 适合"数据量可控、偶尔跑一次"的场景;频繁查询、超大表、实时报表,优先走预聚合或数仓分层,别把 DISTINCT 当成长效方案。
5. 我实际踩过的坑:从重复订单到日活统计的排查复盘
5.1 重复订单定位:GROUP BY + HAVING才是主角
遇到"怀疑有重复订单"的排查需求,我的第一选择往往不是 DISTINCT,而是GROUP BY ... HAVING COUNT(*) > 1:
SELECT order_no, COUNT(*) AS cnt FROM orders GROUP BY order_no HAVING COUNT(*) > 1;这条 SQL 直接告诉你哪些单号重复了、各重复了几次。如果表很大,可以加LIMIT 100先看一眼。这是数据质量检查和面试里最常用的去重定位手段,和 DISTINCT 的区别在于:DISTINCT 只告诉你"有哪些唯一值",不告诉你"哪些值重复了、重复了几次"。想找出“重复的那些”,必须用 GROUP BY + HAVING + COUNT。
5.2 删除重复记录保留一条:MIN(id)的经典写法
确认重复后,真要动手清理,往往需要"每组保一条、删其余"。以订单表为例,同样的 order_no 重复了三行,想保留 id 最小的一行:
DELETE t1 FROM orders t1 INNER JOIN ( SELECT order_no, MIN(id) AS keep_id FROM orders GROUP BY order_no, customer_id HAVING COUNT(*) > 1 ) t2 ON t1.order_no = t2.order_no AND t1.id <> t2.keep_id;这属于"先用 GROUP BY 定位,再用 JOIN 删除"的组合拳。在执行删除前,强烈建议先把DELETE改成SELECT *确认影响行数,再包一层事务,删完检查无误后提交。这是数据库操作的通用安全习惯,跟 DISTINCT 本身关系不大,但清理重复数据这个场景里真的见过太多人一把梭哈删到只剩零行的悲剧。
5.3 日活与新增用户统计中的DISTINCT
日活统计最朴素的做法就是COUNT(DISTINCT user_id)。但很多报表口径的差异,根因就在 DISTINCT 的组合键上。
比如统计"按天维度下,每天有多少用户登录",正确组合键是(login_date, user_id):
SELECT login_date, COUNT(DISTINCT user_id) AS daily_active_users FROM user_login_log GROUP BY login_date;如果把login_date换成了时间戳带时分秒,或者直接在user_id上 DISTINCT,口径就全乱了。更隐蔽的是统计"连续两天活跃用户"时,要用两个日期的INTERSECT或自连接,而不是把两天的 user_id 拼一起 DISTINCT——拼一起的话,用户只要两天里活跃过一天就会命中,根本不符合"两天都活跃"这个约束。
5.4 面试追问:为什么你的DISTINCT"不生效"
面试现场常会给你一张表,里面有重复行,让你"用 DISTINCT 去重",然后追问:"我的查询是这样的,为什么还有重复?"这时候一定要先说清楚 DISTINCT 是整行比较。更进一步的加分回答是:把SELECT DISTINCT name FROM t和SELECT DISTINCT name, age FROM t区分开,指出前者只看 name,后者看 name 和 age 的组合,组合中其他列的差异会保留多余行。
还可以补一句:SELECT DISTINCT *只有在整行所有列完全相同时才去重,这往往不是业务想要的"重复";业务里的"重复"通常是某一列或某几列相同,其余列不同,这用 DISTINCT 根本处理不了,正确姿势是 GROUP BY 目标列 + 聚合函数取代表值。能讲清这个层次,比死记"DISTINCT 去重"有价值得多。
另一个高频追问是 DISTINCT 和 GROUP BY 的区别。核心回答就三句话:一,GROUP BY 可以配合聚合函数,DISTINCT 不行;二,在无聚合函数且投影列完全一致时,两者语义几乎等价,但 GROUP BY 语义更适合扩展统计需求;三,DISTINCT 的语义集中在"消除重复行",GROUP BY 的语义是"分组后再处理",表达意图不同。
5.5 我在生产环境观察到的两个DISTINCT优化案例
第一个案例是用户标签去重。业务方每天拉取全量标签关系中每个用户的标签列表,原始 SQL 是SELECT DISTINCT user_id, tag_id FROM tag_map。tag_map 表接近 800 万行,每天全量跑一次要 40 多秒。后来发现这张表的变更很小,改成了"每日只处理增量 + 汇总结果 persist 到结果表",全量 DISTINCT 不再每天发生,查询时间直接降到 1 秒内。这不算 SQL 写法优化,是架构层面避免无意义全量计算,但思路值得参考。
第二个案例是导出去重。运营要从订单表导出每个客户最近一次购买的商品,一开始写的是SELECT customer_id, product_name FROM orders GROUP BY customer_id, product_name,结果一个客户多次购买不同商品时,会导出多行。这就是把 GROUP BY 当 DISTINCT 用的典型误伤。后来改成子查询 + 关联:
SELECT o.customer_id, o.product_name FROM orders o INNER JOIN ( SELECT customer_id, MAX(order_date) AS max_date FROM orders GROUP BY customer_id ) m ON o.customer_id = m.customer_id AND o.order_date = m.max_date;数据立刻变成"每客户一行最近购买记录"。虽然这个写法在某些边界条件下(一个客户在同一个最大日期买了两次)会多出几行,但业务上已经可接受。如果要绝对精确,就得引入窗口函数 ROW_NUMBER 方案,把多行都标号后只取 rn=1。
DISTINCT 本身并不复杂,复杂的是你把它用在什么场景、对它的预期是否合理。一个小技巧收尾:写任何去重 SQL 前,先在心里回答三个问题——"我要对什么字段去重""这些字段里有没有 NULL 和不可见字符""这个查询多久跑一次、数据多少行"。这三个问题答清楚了,DISTINCT 基本不会给你挖坑;答不清楚,它就会用各种"意外重复"来教你怎么长记性。