做数据处理的人,十个里有八个每天都要跟重复数据打交道。不管是清洗接口入仓的脏数据,还是前端报表统计用户数,SQL 里的distinct都是出场率最高的关键字之一。但越是常用的东西,越容易被人用错:有人把它当成函数套在列名两边,有人以为加了 distinct 就万事大吉结果性能暴跌,还有人根本分不清它和 group by 在去重场景下的真正区别。这篇文章就把 distinct 从语法到原理、从单表去重到多表关联、从慢查询优化到删除冗余数据,完整串一遍,把我这些年跑 SQL 时的实操经验和踩过的坑都放在里面。
这篇文章适合这几类人:刚开始写 SQL 的学生或转行新人,想把自己写的去重语句从"能跑"改成"跑得稳跑得快"的开发,以及做数据清洗、报表统计时经常被重复行折腾的运营和分析师。我会尽量用大白话讲清楚它背后的执行逻辑,也会给出完整可复现的示例。
1. DISTINCT 到底是什么:语法与最小可用示例
1.1 先明确一个关键认知:DISTINCT 不是函数,是关键字
我见过太多人写SELECT DISTINCT(column_name) FROM table,这个写法在绝大多数数据库里不会报错,但它容易让人产生一个错误认知:以为 distinct 只作用于它括号里的那一列。实际上,distinct和括号没有任何语法绑定关系,DISTINCT (column_name)里的括号只是一个普通表达式分组符,和(column_name)一样等价于column_name。
这句话翻译成人话就是:SELECT DISTINCT col1, col2 FROM table去重的单位是(col1, col2)这一整行组合,而不是单独把 col1 去重、再看 col2。这是理解 distinct 一切行为的地基。举个具体例子,有一张订单表:
SELECT customer_id, city FROM orders;返回 5 行:
| customer_id | city |
|---|---|
| 1001 | 上海 |
| 1001 | 北京 |
| 1002 | 上海 |
| 1002 | 上海 |
| 1001 | 上海 |
如果执行SELECT DISTINCT customer_id FROM orders,结果是两行:1001、1002。如果执行SELECT DISTINCT customer_id, city FROM orders,结果是四行,因为1001-上海和1002-上海这两组是不同的行组合。记住这个逻辑,后面所有复杂场景都不会跑偏。
1.2 单列去重、多列去重与 COUNT(DISTINCT)
单列去重是最基础也最常见的,典型场景是统计"一共有多少名用户下单":
SELECT COUNT(DISTINCT customer_id) AS user_cnt FROM orders;这里必须注意,COUNT(DISTINCT column)是聚合函数里的特殊用法,它和普通COUNT(column)一样会忽略 NULL 值。也就是说如果 customer_id 在表里有 NULL,那么 COUNT(DISTINCT customer_id) 不会把这部分 NULL 统计进去。而普通SELECT DISTINCT customer_id会把 NULL 作为一个独立的去重结果返回。
多列去重的写法很简单,就是几个列名用逗号隔开:
SELECT DISTINCT customer_id, channel, device_type FROM orders WHERE order_date >= '2024-01-01';这条语句的业务含义是:统计 2024 年以后,每个用户通过哪些渠道、哪些设备类型产生过订单。返回行数就是这些维度组合的独立数量。后续如果要接报表,这个结果可以直接作为维度表使用。
另外一个容易踩坑的点是:DISTINCT只能出现在 SELECT 列表的最前面,不能写在中间。比如SELECT user_name, DISTINCT city FROM users是语法错误,必须是SELECT DISTINCT user_name, city。这是很多初学者第一次报错的原因。
2. DISTINCT 的底层逻辑:它为什么慢,以及什么时候会快
2.1 官方文档没直说的事实:去重本质是"排序"或"哈希"
聊性能之前,必须先说清楚 distinct 在数据库里是怎么执行的。主流的实现方案有两种:排序去重和哈希去重。
排序去重是传统思路:先把需要去重的列从表中捞出来,排个序,然后遍历一次,相邻重复的只保留一个。这个方案对内存要求高,数据量一大就要把中间结果写到临时磁盘文件里,这就是你在执行计划里经常看到的Using temporary、Using filesort这类标记的来源。
哈希去重则是数据库先把每一行的去重列组合算一个哈希值,放到内存哈希表里,碰到相同哈希值就丢弃。它比排序快,但比较吃内存,而且哈希冲突极端情况下会退化成性能灾难。
在 MySQL 8.0、PostgreSQL、SQL Server、Oracle 里,优化器会自己判断用哪种方式。但不管用哪种,有一个结论是不变的:distinct 的代价取决于参与去重的行数和去重列的数据宽度。行多、列宽(比如对一整个 TEXT 类型字段去重)、内存配置差,这三个条件只要占两个,慢查询就来了。
2.2 DISTINCT 和 GROUP BY 到底是不是一回事
这是群里被问烂了的问题。结论是:在绝大多数数据库里,SELECT DISTINCT a, b FROM t和SELECT a, b FROM t GROUP BY a, b执行计划基本一样,优化器都会按"分组去重"来处理。你完全可以把 group by 的写法当成 distinct 的另一张脸。
那为什么要区分它们?有三个理由:
第一,语义完整性不同。group by 后面可以接聚合函数,distinct 不行。SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id是合法的,但SELECT customer_id, COUNT(*) FROM orders DISTINCT是错的。如果你要去重的同时还要统计每一组的数量、金额、均值,就必须用 group by。
第二,WHERE 与 HAVING 的配合习惯不同。SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(*) > 3可以轻松筛出下单次数超过 3 的用户,但如果用 distinct 写,你得嵌套一层子查询:
SELECT customer_id FROM ( SELECT DISTINCT customer_id, order_count FROM (...) ) WHERE order_count > 3;可读性差一大截。
第三,在部分 SQL Server 版本里,distinct 与 group by 的执行细节存在差异,比如涉及并行度、排序算子的选择等。虽然绝大多数场景你感知不到,但在复杂查询里混用两种写法做性能对比时,确实出现过 distinct 比 group by 慢的情况。我的经验是:如果只是单纯去重,怎么写都行;如果去重之外还要聚合,直接用 group by;如果两种写法的 SQL 都要跑数十秒,就都跑一遍看执行计划再决定。
2.3 索引对 DISTINCT 的加速效果
去重能不能走索引,直接决定查询是毫秒级还是分钟级。核心原则是:让参与去重的列尽可能让优化器在索引树上完成扫描与去重,避免回表。
假设 orders 表有索引idx_customer_status(customer_id, order_status),那么这条查询:
SELECT DISTINCT customer_id, order_status FROM orders;可以直接扫描联合索引,索引里本身就是按 customer_id、order_status 排序存储的,数据库一层层往下走就能边扫边去重,完全不需要二次排序。但如果你把查询改成:
SELECT DISTINCT UPPER(customer_id) FROM orders;索引就废了。因为你对列做了函数处理,优化器无法直接把索引排序结果映射到函数结果上,只能先把全表 customer_id 抠出来、算出大写值、再排序去重。所以如果你经常需要对某个字段做函数式去重,可以考虑建一个表达式索引(MySQL 5.7+/8.0 支持、PostgreSQL 天然支持),把UPPER(customer_id)这个表达式作为索引列。
3. 实战拆解:多表查询、窗口函数与数据清洗去重
3.1 多表 JOIN 之后出现重复行:先诊断再上去重
很多人遇到这种情况:两个表 join 之后,明明只想取明细,结果行数比左表多。第一反应就是往 SELECT 前面加 distinct。这属于典型的"头痛医头"。
比如订单表 orders 和订单明细表 order_items 关联:
SELECT DISTINCT o.order_id, o.order_date, o.customer_id FROM orders o JOIN order_items i ON o.order_id = i.order_id;如果 order_items 里一个 order_id 对应多条明细,这个 join 本来就会把订单信息重复多行。加了 distinct 后确实把行数压回了订单数,但它同时也掩盖了 join 条件上的问题。更合理的做法有两种。
第一种,如果你根本不关心明细内容,只是为了判断"这个订单有没有明细",就不要 join 订单明细表,改用 EXISTS 或者子查询:
SELECT o.order_id, o.order_date, o.customer_id FROM orders o WHERE EXISTS ( SELECT 1 FROM order_items i WHERE i.order_id = o.order_id );这个写法的执行效率通常比 distinct join 高,因为它在找到第一条匹配明细后就会停止,而不是把全部明细都关联出来再压掉。
第二种,如果你确实要带出明细聚合信息,比如订单下有几条明细、明细总金额多少,那应该 group by:
SELECT o.order_id, o.order_date, o.customer_id, COUNT(i.id) AS item_cnt, COALESCE(SUM(i.amount), 0) AS total_amount FROM orders o LEFT JOIN order_items i ON o.order_id = i.order_id GROUP BY o.order_id, o.order_date, o.customer_id;记住一句话:distinct 是数据正确性问题的创可贴,不是治疗方案。日志表、埋点表这些天然带重复数据源的表可以放心用 distinct 清洗,但在业务表 join 场景里,先想想这条 SQL 的关联粒度对不对。
3.2 子查询里的 IN (SELECT DISTINCT ...) 怎么优化
判断"哪些用户属于 VIP"这类场景,有人喜欢写:
SELECT user_name, order_id FROM orders WHERE customer_id IN (SELECT DISTINCT customer_id FROM vip_users);其实这里的 distinct 完全是多余的。IN 子查询的语义是"只要匹配到就算命中",中间结果重不重复根本不影响最终结果。你写SELECT customer_id FROM vip_users就好,因为 IN 本质上做的是存在性判断,优化器会帮你物化去重或者转换成 semi join。多余写一个 distinct 只会让子查询多一步去重成本,SQL 读起来也更啰嗦。
真正需要子查询里 distinct 的场景,是子查询的结果要参与 join 且会造成行数膨胀的时候:
SELECT v.level, COUNT(*) FROM vip_users v JOIN ( SELECT DISTINCT customer_id FROM orders WHERE order_date >= '2025-01-01' ) d ON v.customer_id = d.customer_id GROUP BY v.level;这里先取"2025 年有下单的用户去重集合",再和 vip 表关联统计,就是合理的用法。子查询先缩小数据范围,再交给外层 join,整体效率远好于把整个订单表 join 进来再 distinct。
3.3 用 ROW_NUMBER() 替代 DISTINCT:去重不再是唯一诉求
distinct 只能做到"相同行保留一份",但它保留哪一份、按什么规则保留,完全不可控。比如表里有某个用户的重复注册记录,我想保留创建时间最早的那条,distinct 就做不到了。
这是窗口函数ROW_NUMBER()的主场。写法如下:
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC) AS rn FROM user_register_log ) SELECT * FROM ranked WHERE rn = 1;PARTITION BY 负责定义"哪些行算一组",ORDER BY 负责定义组内排序规则,rn = 1 就是这组里排第一的那条。和 distinct 相比,它多了"可控性"和"保底规则",代价是要多掌握一种语法。
这套写法最常见的落地场景是删除冗余数据。热搜词组里反复出现"清洗---sql语句去重",生产环境里真正的清洗逻辑很少是"只保留一条任意记录",而是带业务约束的:保留更新时间最新的、保留金额最大的、保留状态最完整的。这些全部要用 row_number 而不是 distinct。
另外有一个很隐蔽的问题:在 MySQL 8.0 之前,窗口函数不可用,做"删除重复行保留一条"要用临时表加自连接。核心思路是把需要保留的那条的主键找出来,然后按主键排除。下面是通用方案:
DELETE t1 FROM user_table t1 JOIN user_table t2 WHERE t1.user_name = t2.user_name AND t1.id > t2.id;这条语句的效果是:同名用户里,只保留 id 最小的那一条,其余全部删除。原理就是自连接找到所有可配对的两行记录,把较大 id 的那方删掉。
3.4 数组、对象数组和拆分字符串的去重
搜索热词里出现了"数组去重""对象数组去重",在 SQL 世界里,这通常对应两种数据形态:PostgreSQL/MySQL 的 JSON 数组,以及逗号分隔的字符串字段。
PostgreSQL 里数组直接有ARRAY(SELECT DISTINCT unnest(arr))的玩法,JSON 数组可以先把元素拆开再聚合:
SELECT id, ARRAY(SELECT DISTINCT jsonb_array_elements_text(tags)) AS distinct_tags FROM articles WHERE jsonb_typeof(tags) = 'array';MySQL 这边没有内置数组类型,最常见的"数组"其实是逗号分隔字符串。去重就老实用FIND_IN_SET配合递归或 JOIN 数字表实现拆分,或者直接用存储过程循环处理。说实话这种设计本身就反模式,遇到这种字段,能改表结构就改掉,改成子表或者 JSON 数组,别在 SQL 里硬刚。
3.5 不同数据库的 DISTINCT 差异备忘
各大数据库在 distinct 上总体兼容,但细节有差异,踩过坑的人都知道这些差异有多磨人:
- PostgreSQL 支持
SELECT DISTINCT ON (col1) col1, col2,可以直接实现"按 col1 分组后取每组第一条",这个语法其他数据库没有,oracle 和 sql server 用户迁移过来会一脸懵。 - Oracle 的
SELECT DISTINCT col1 FROM t ORDER BY col2会直接报 ORA-01791,除非 order by 的 col2 也在 select 列表里。这是 Oracle 的严格限制。 - SQL Server 里
SELECT DISTINCT col1, col2 ORDER BY col2相对宽松,但依然要确保排序键在 select 列表中才不会出问题。 - MySQL 对
SELECT DISTINCT col1 ORDER BY col2的行为就比较宽容,因为它的 only_full_group_by 模式默认没有完全约束 distinct 排序组合,但这不代表业务语义正确,仅仅是"不报错"而已。
4. 高频踩坑现场与排查速查表
4.1 大小写、排序规则与"看似去重失败"
同一批数据里出现"Apple"和"apple"两条记录,distinct 会怎么处理?在 MySQL 里,这完全取决于表的 collation。如果你用utf8mb4_general_ci(默认的 case-insensitive 排序规则),去重时会把 Apple 和 apple 视为相同;如果用utf8mb4_bin或utf8mb4_0900_as_cs,它们就是两条不同的记录。
SQL Server 同理,数据库的 collation 决定比较规则。经常有程序员在 dev 库跑得好好的,数据一上生产发现去重结果不同,查半天都排查不到,其实就是因为两边的排序规则不一样。排查方法是在数据库里执行:
-- MySQL SHOW VARIABLES LIKE 'collation%'; -- SQL Server SELECT name, collation_name FROM sys.databases;真觉得默认规则不满足业务需求,可以在查询时显式指定排序规则:
SELECT DISTINCT city COLLATE utf8mb4_bin FROM user_city;但注意,COLLATE后这个列就无法使用原索引了,数据量小没关系,大表会慢,要谨慎。
4.2 NULL 值去重的特殊行为
SQL 的 NULL 是一个非常拧巴的存在。常规比较里NULL = NULL不是 true 也不是 false,而是 UNKNOWN,所以按理说两个 NULL 不该被当成相同值。但在 distinct 的语义里,数据库做了一个特殊约定:把所有 NULL 归为一组,只输出一个 NULL。
所以执行SELECT DISTINCT city FROM users,如果 city 列有 5 行 NULL,结果里只会出现一行 NULL。这影响的是统计口径:你做完 distinct 后拿结果数去估"城市数量",NULL 会被计成 1,而实际上这批用户根本不知道城市信息。如果业务上不想让 NULL 参与去重,可以先用 COALESCE 处理或者把 NULL 过滤掉,再在结果里单独统计缺失量。
4.3 隐式类型转换导致 DISTINCT 结果与预期不同
热搜词里有一条 "sql server conversion failed when converting date and/or time fr",这是典型的隐式类型转换引起的报错。在 distinct 场景里它也经常出来捣乱。
举个 SQL Server 的例子:
SELECT DISTINCT order_date_str FROM sales;order_date_str 在源表里是 varchar,但里面存的可能是 "2024-01-01"、"01/01/2024" 等不同格式。distinct 直接按字符串比较,两种格式当然是两条不同记录。如果你业务上要的是"相同日期只算一个",就必须显式转换:
SELECT DISTINCT CONVERT(date, order_date_str, 120) AS order_date FROM sales;这里 CONVERT 的第三个参数 120 是格式代码,对应yyyy-MM-dd。这类问题在 MySQL 里则是CAST(order_date_str AS DATE),Oracle 里则是TO_DATE(order_date_str, 'YYYY-MM-DD')。显式转换的好处是可以尽早暴露脏数据,比如某个字段里混了一个 "2024-02-30",转换时直接报错,你就能顺着报错去定位脏数据源头。
4.4 DISTINCT 与 ORDER BY 的兼容性陷阱
前面铺垫过 Oracle 的 ORA-01791。完整解释一下:Oracle 要求 ORDER BY 里的表达式必须出现在 SELECT 列表中,否则无法确定排序依据。比如:
-- Oracle 报错: ORA-01791: not a SELECTed expression SELECT DISTINCT department_id FROM employees ORDER BY salary;为什么不行?因为 distinct 要先去除重复行,得知道最终输出的行集合是什么,你按 salary 排序,但 salary 根本不在输出集合里,优化器不知道"该拿哪一条 salary 来代表这一个 department"。这是逻辑层面说不通,不是数据库故意刁难。解决办法是把 salary 也加进 select 列表,或者改用 group by / 子查询:
SELECT department_id FROM employees WHERE employee_id IN ( SELECT MIN(employee_id) FROM employees GROUP BY department_id ) ORDER BY salary;MySQL 在这个问题上相对宽松,但宽松不等于语义正确。同理,我建议不管在哪个数据库,都遵守"排序字段必须出现在 select 输出中"的自律规则,能省一大半跨库迁移时的报错。
4.5 慢 SQL 排查看这几个信号
distinct 慢是整个生产环境里备案最多的慢查询类型之一。你打开执行计划,主要看这几个信号:
第一,Using temporary。这意味着查询要建临时表,如果数据量大,临时表会落到磁盘,写着写着 IO 就把性能拖垮了。第二,Using filesort。这不是说真的创建了一个文件,而是指无法利用索引顺序完成排序,需要额外排序步骤。第三,Buffer Sort、TempSpace相关字样(不同数据库叫法不同),都是在提醒你:内存不够用了,中间结果在往磁盘上倒。
实际优化步骤我给一个标准顺序:
- 先看参与去重的列有没有可用索引,没有索引优先加索引,尤其是覆盖索引。
- 再看能不能缩小参与去重的数据范围,比如在 WHERE 里加时间分区、加状态过滤。distinct 前先 filter,永远比全表捞数据再压重强。
- 然后看 distinct 是不是必须的。如果只是为了判断存在性,改成 EXISTS 或半连接;如果是为了拿聚合指标,改成 GROUP BY 加条件聚合。
- 如果可以接受近似值,大表统计 UV 可以尝试
APPROX_COUNT_DISTINCT(SQL Server、BigQuery 等支持)或者HLL_PRECISION这类近似函数,误差在业务容忍范围内的话,性能能提升几个数量级。 - 最后考虑改写为窗口函数方案。row_number + partition by 在部分场景下能更好地利用索引排序,还能顺带输出明细列。
4.6 兼容性备忘:SQL Server 2008/2012 与低版本 MySQL
SQL Server 2008、2012 至今还有很多老系统在用。低版本里窗口函数 ROW_NUMBER 是支持的,从 SQL Server 2005 就有了,这点比 MySQL 幸运。但要注意,SQL Server 2008 对FOR JSON、STRING_AGG这类语法是没有的,如果你在做字符串拼接去重,低版本只能用FOR XML PATH。这种写法又绕又慢,但老库没得选。
MySQL 5.7 及以下没有窗口函数,去重删除逻辑只能通过临时表、自连接实现。我在生产里见过的最经典的一段老版本去重删除 SQL 是这样:
CREATE TEMPORARY TABLE tmp_user SELECT MIN(id) AS keep_id FROM user_table GROUP BY user_name; DELETE FROM user_table WHERE id NOT IN (SELECT keep_id FROM tmp_user);逻辑是先按 user_name 分组取最小 id,也就是每条记录唯一的保留资格,然后反查删除。这个思路在任何数据库版本里都通用,和 3.3 节里自连接删除的例子互为补充。注意一点,MySQL 里 DELETE 的子查询如果引用同一张被删表会报错,必须套一层临时表,或者在 DELETE 语句里做双重嵌套隔离,这也是我上面先 CREATE TEMPORARY TABLE 的原因。
5. 最后分享几点个人体会
我在多个数据库之间来回折腾过不少项目,从 MySQL 到 PostgreSQL,从 SQL Server 到 Oracle,都碰到过 distinct 引发的问题。说实话,distinct 本身不难,难的是知道它底下在做什么。归纳几个我个人的使用原则,给大家参考:
小表随手用 distinct 完全没问题,几千几万行怎么跑都不差那零点几秒;大表只要上了几十万行,先看一眼执行计划再放行。能用索引覆盖就用索引覆盖,能先过滤就先过滤,能半连接就别全表去重。
真正做数据清洗的时候,distinct 大多数时候只是第一步,不是全部。清洗的目标一般还包括"保留哪一条权重的数据"“把字段格式统一后再去重”"去重后还要补全缺失字段",这些都需要配合 row_number、聚合函数、显式类型转换一起用。所以别只学 distinct 一个语句,把它和 group by、窗口函数、exists 串起来,才是处理重复数据比较完整的姿势。
最后再提醒一点:但凡遇到 distinct 后结果总觉得不对,先从排序规则、隐式类型转换、NULL 处理这三个方向排查,它们出问题的概率远大于 distinct 语法本身的错误。先有这些意识,再开跑 SQL,能省下不少排查时间。