☰
SQL DISTINCT去重详解:从原理到性能优化与实战踩坑
2026/10/14 2:59:32 网站建设 项目流程

做SQL的人十有八九都栽过同一个跟头:满心欢喜跑了一条查询,结果结果集里刷出一排一模一样的订单号,排查半天才发现是JOIN的时候把一对多关系带进来了。这种"重复"可以说是SQL世界里出现频率最高、又最容易被低估的问题。DISTINCT就是专门用来干掉结果集重复行的关键字,字面上理解起来很简单——去重,但真要用得明白,得搞清楚它和GROUP BY、UNION、窗口函数之间那些微妙的边界,还得知道它在性能、NULL处理、排序约束上藏着的各种细节。

这篇内容适合刚开始学SQL的入门者,也适合那些已经会用DISTINCT但踩过坑、想弄明白"为什么"的开发者。我会从重复数据怎么来、DISTINCT基础语法怎么写、和GROUP BY怎么选、NULL怎么处理、性能怎么优化、以及去重后如何取最新数据这几个角度,把我这些年实际用下来的心得一次性讲清楚。

1. 重复数据是怎么混进结果集的:先搞懂DISTINCT到底在解决什么问题

我见过太多人一看到结果集里有重复就条件反射写DISTINCT,但如果你不清楚重复是从哪个环节进来的,DISTINCT就可能只是把问题"盖住",而不是真正解决。

1.1 一对多JOIN导致的"行膨胀"

最常见的一种重复,是查询多张表的时候JOIN出来的。举个例子,假设有订单表和订单明细表:

-- 订单表:一个订单对应一行 SELECT * FROM orders; -- 订单明细表:一个订单可以有多条明细 SELECT * FROM order_items;

如果你要查订单的金额、状态,同时还想带出明细的商品名称,很自然地会这样写:

SELECT o.order_id, o.customer_name, i.item_name FROM orders o JOIN order_items i ON o.order_id = i.order_id;

只要一个订单下挂了两条明细,这个订单在结果集里就会出现两行。此时你看到的"重复"其实并不是数据错乱,而是表之间关系的天然结果。很多新手到这里就开始懵:为什么我的订单表里明明只有一行,查出来却有两行?

这种场景下直接用DISTINCT确实能把重复的行合并掉,但我更建议你先想清楚业务上到底要什么。如果只是要订单维度的信息,应该用聚合或子查询把明细先收敛,而不是查完明细再DISTINCT。因为明细一旦多了,DISTINCT的代价会成倍上升,而且你丢失了明细本身的价值。

1.2 表本身的数据质量问题

另一种情况是物理表里真的存在重复行。常见于没有主键约束、或者是从多个数据源导入时没有做幂等控制的历史表。比如一张用户触达记录表,同一用户同一时间点被插入了两次,运行下面这条语句时就会出现完全相同的两行:

SELECT user_id, touch_time, touch_channel FROM user_touch_log;

这种情况下DISTINCT是合理的止血手段。但我要提醒一句:用DISTINCT掩盖数据质量问题之前,先确认一下你的下游表、报表、对账逻辑是不是也会被同一个数据源污染。如果源头不清理,每个查询都得挂DISTINCT,系统会变得越来越慢,而且一旦某天漏写了一个,数据又脏了。

1.3 UNION ALL带来的"伪重复"

还有一种制造重复的现场,是UNION ALL。UNION ALL的本意就是把两个查询的结果纵向拼接,它不会做任何去重。而UNION则会在拼接之后自动去重。很多人记不住这两个的区别,导致写出来的报表里出现重复汇总。

-- 两个查询各自返回一部分用户,拼接时可能出现同一用户出现在两边 SELECT user_id FROM vip_users UNION ALL SELECT user_id FROM active_users;

如果同一用户既在VIP名单里又在活跃名单里,UNION ALL会把他输出两次。这时候你要是反应快,改成UNION就能去重,但也要掂量一下,UNION的去重是要付出排序或哈希代价的。能用UNION ALL保证两侧数据本身互斥,就不必让数据库白做一次去重。

2. DISTINCT基础语法:单字段、多字段与表达式的去重差异

聊完重复的来源,回到DISTINCT本身。它最基础的形态其实一句话就能说清:写在SELECT关键字后面,让结果集中每一行都唯一。

2.1 单字段去重:最朴素的用法

SELECT DISTINCT department FROM employees;

这条语句的作用是把员工表里所有不重复的部门列出来。结果里每个部门只会出现一次,哪怕这个部门有一百个员工。这个场景最简单,也最不容易出错。

2.2 多字段去重:整个组合要被当成"一行"

真正容易出问题的写法是多个字段一起用。很多人会误以为SELECT DISTINCT a, b是"只对a去重,b随便取一条",这是SQL入门阶段最大的误解之一。

SELECT DISTINCT city, region FROM store_locations;

这条语句的含义是:把(city, region)当成一个整体,只有两个字段的组合完全一样才被合并。比如(北京, 朝阳)和(北京, 海淀)是两个不同的组,结果集中都会保留。你在结果集里会看到"北京"重复出现多次,但每一行和另一行的(city, region)组合都不相同。

理解了这个逻辑,你就能明白为什么DISTINCT在"保留一条记录"这件事上非常无能为力。它只能告诉你某个组合是否存在,不能告诉你组合里其他字段取什么值。比如你想查每个城市的第一家门店,DISTINCT做不到,它只能把城市列表列出来。这个需求得交给窗口函数或分组聚合,后面第6部分会专门讲。

2.3 对表达式去重:换一种"分组口径"

DISTINCT后面跟的不一定是列名,也可以是表达式。这个技巧在处理时间、文本规范化时非常实用。

-- 按年份去重,查出有哪些年份产生过订单 SELECT DISTINCT EXTRACT(YEAR FROM order_date) FROM orders; -- 把姓名转成大写后去重,避免大小写差异导致的"假重复" SELECT DISTINCT UPPER(customer_name) FROM customers;

第一条用于快速盘点数据覆盖的时间范围,比先查全部订单再在应用层做去重要轻量得多。第二条在清洗数据时特别好用,很多系统录入时大小写不统一,ZhangSan和zhangsan在数据库里会被当成两行,用UPPER统一口径后再DISTINCT,才能看清真实的人数。这种写法本质上是把表达式的结果当成一个新的"值"来参与去重判断。

2.4 SELECT ALL:被遗忘的对立面

和DISTINCT相对的是SELECT ALL,它表示不去重,也是SQL的默认行为。你写SELECT name FROM users和SELECT ALL name FROM users效果完全一样。大多数数据库的优化器会自动把ALL当作默认,所以平时没人写它,但你知道它的存在,再看别人代码里偶尔冒出来的SELECT ALL就不会觉得奇怪了。

3. DISTINCT与GROUP BY、UNION的三角关系:怎么选才不会后悔

很多人学到后面会问:DISTINCT能干的事,GROUP BY不是也能干吗?确实,这两个语法在很多场景下结果一样,但它们的定位完全不同。我倾向于把DISTINCT理解为"存在性检查",而GROUP BY是"分组后再计算"。

3.1 等价但不等价:一个简单的对比

-- 写法一:直接去重 SELECT DISTINCT department FROM employees; -- 写法二:按部门分组 SELECT department FROM employees GROUP BY department;

在不带任何聚合函数时,这两个语句的结果集基本一样。但GROUP BY真正的价值在于它可以一边分组一边算统计量,比如:

SELECT department, COUNT(*), AVG(salary) FROM employees GROUP BY department;

这种需求用DISTINCT就完全做不到了。反过来,如果你只是想把某个字段的枚举值拉出来,用GROUP BY反而显得重。实际工作中我更推荐按语义选择:如果只是"我要看有哪些不同的值",用DISTINCT;如果还要"按这些值分别做计算",用GROUP BY。

3.2 COUNT(DISTINCT)的正确姿势

DISTINCT和聚合函数结合时,威力就出来了。最常见的组合是COUNT(DISTINCT 列名),用来统计"一共有多少个不同的值":

-- 统计有订单产生的日期有多少天 SELECT COUNT(DISTINCT order_date) FROM orders; -- 统计每个城市的客户数 SELECT city, COUNT(DISTINCT customer_id) FROM customer_address GROUP BY city;

这里有个细节:COUNT(DISTINCT customer_id)统计的是客户ID去重后的数量,而不是明细行数。如果同一客户在同一个城市有多条地址记录,不加DISTINCT就会把他算成多个人。这种错误在出报表时特别致命,轻则让老板看到虚高的数字,重则影响到运营决策。我在带新人时经常强调:涉及"人数""客户数""订单数"这类统计时,先问一句主键是不是唯一的,不唯一就立刻上COUNT(DISTINCT)。

另外一个值得注意的点是,COUNT(DISTINCT ...)在计算时会额外做一次去重排序,数据量大时并不便宜。如果这张表有上千万行,每一行都要参与去重判断,查询可能会跑好几秒甚至几十秒。后面第5部分我会讲怎么通过改写来优化。

3.3 UNION自带"隐形DISTINCT"

前面提到UNION会自动去重,这其实就是在内部做了一次DISTINCT操作。你可以把UNION理解为UNION ALL再加上一次去重。所以如果两个查询结果本身就不会重复,优先用UNION ALL,省掉这次多余的去重。如果确实需要合并两个来源并去除重复,才用UNION。

-- 把两个渠道的注册用户合并,同时自动去重 SELECT user_id FROM channel_a_users UNION SELECT user_id FROM channel_b_users;

4. NULL值在DISTINCT眼里的地位:一个经常被误解的细节

NULL大概是SQL里最容易引发争议的值。它既不是0也不是空字符串,而是一个"未知"的标记。DISTINCT碰见NULL时有一套自己的规则,不懂的人很容易踩坑。

4.1 NULL被当成"同一个值"来合并

先看一个例子:

-- 客户联系方式表,部分客户手机号是NULL SELECT DISTINCT phone_number FROM customers;

如果这张表里有3行phone_number都是NULL,DISTINCT结果里只会出现一个NULL行,而不是三行。听起来很合理,但这和SQL的等值比较逻辑其实是矛盾的——在WHERE phone_number = NULL里,NULL和NULL不相等,任何等值比较都会返回"未知";但DISTINCT去重时,却把所有NULL当成同一组。这个反差让不少初学者困惑。

实际含义是什么?就是"无法区分具体值的数据会被归为一类"。你在做数据清洗时,如果想看看有多少种不同的手机号,用上面这条就够了。但如果想统计"有多少客户没留手机号",得用WHERE phone_number IS NULL,而不是COUNT(DISTINCT)。

4.2 COUNT(DISTINCT)自动忽略NULL

这个特性在某些场景下很省心,在某些场景下却会误导人。默认情况下,COUNT(DISTINCT col)不会把NULL计进去。比如一张订单表里有100行,其中30行这个字段是NULL,另外70行里有50个不同的非NULL值,那么COUNT(DISTINCT col)的结果是50,而不是51,更不是80。

-- 结果只统计非NULL的不同值 SELECT COUNT(DISTINCT coupon_code) FROM orders;

如果你想知道"使用了多少种优惠券",这个结果是正确的。但如果你想统计"有优惠券记录的订单有多少种状态",可能要把NULL单独拎出来看。我在做数据质量报告时,经常用下面这条语句同时看非空去重数和NULL数量:

SELECT COUNT(DISTINCT coupon_code) AS distinct_not_null, COUNT(*) - COUNT(coupon_code) AS null_count FROM orders;

COUNT(coupon_code)只统计非NULL的行数,用总行数减去它就能得到NULL的数量。两个指标放一起,数据的基本盘就清楚了。

5. 实战踩坑:DISTINCT性能与排序、子查询的经典矛盾

把DISTINCT当成一把万能钥匙到处用,迟早会遇到性能问题。我见过一个数据量撑到两千万行的业务表,原本一条查询只要两秒,加了个DISTINCT之后直接变成四十秒,还把数据库CPU打到接近100%。这一部分把我实际踩过的坑和排查链路完整过一遍。

5.1 DISTINCT与ORDER BY的隐形约束

有些数据库对DISTINCT和ORDER BY的组合有严格限制。以SQL Server为例,使用SELECT DISTINCT时,ORDER BY后面的列必须出现在SELECT列表中,否则直接报错:

-- SQL Server中会报错:ORDER BY的列不在SELECT列表中 SELECT DISTINCT department FROM employees ORDER BY hire_date;

原因很好理解:DISTINCT会在排序之前先去重,去重后hire_date可能对应多个值,数据库根本不知道该用哪一条来排。MySQL在这点上相对宽松,但语义同样容易让人懵——MySQL会先取DISTINCT结果,再按ORDER BY排序,如果ORDER BY列不在选择列表中,排序结果可能与预期不一致。

我的建议是:只要在去重查询里用了ORDER BY,就把排序列也放到SELECT列表中,或者改成用聚合函数取一个确定值。比如想按部门分组后按最早入职日期排序:

SELECT department, MIN(hire_date) AS earliest_hire FROM employees GROUP BY department ORDER BY earliest_hire;

这样既去重了,又排序了,语义还清清楚楚。

5.2 大数据量下DISTINCT为什么慢

DISTINCT的实现原理通常依赖排序或哈希。数据库需要把涉及去重的所有行都读出来,然后对它们做排序或构建哈希表,最后只保留每组的第一行。这个过程的代价取决于去重字段的基数:

  • 基数低(比如性别字段,只有几个值):排序很快,哈希表很小
  • 基数高(比如订单号或手机号,值几乎都不同):几乎所有行都要参与,排序或哈希的代价接近O(n log n)甚至更高

更麻烦的是,如果去重中间结果超过了内存限制,数据库会把临时数据写到磁盘,这时候查询速度会断崖式下降。我排查过一条慢SQL,执行计划里显示Sort Method: external merge Disk,日志里明确写着内存不够用了。这种情况下光改SQL写法还不够,得从数据量和索引上去找方案。

5.3 用EXISTS替代DISTINCT的场景

有一类经典场景,是查"哪些客户下过单"。很多人的第一反应是JOIN订单表再DISTINCT:

SELECT DISTINCT c.customer_id, c.customer_name FROM customers c JOIN orders o ON c.customer_id = o.customer_id;

这个写法在订单表数据量大的时候非常吃亏,因为JOIN会先把两张表做匹配,可能产生大量中间行,然后再去重。换个思路,用EXISTS改成"存在性判断",数据库一旦找到一条满足条件的订单就能立刻停下:

SELECT c.customer_id, c.customer_name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id );

两条语句的结果一样,但后者避免了一次"先膨胀再压缩"的过程。在订单表有几百万行时,这个改写经常能让查询从几十秒降到一两秒。这是个很值得养成习惯的优化点:DISTINCT不是不能用,而是要想清楚它处理的数据量到底有多大。

5.4 养成看执行计划的习惯

我排查DISTINCT相关慢SQL,基本流程固定:先EXPLAIN看执行计划,确认是不是出现了Sort、HashAggregate、Unique这类关键节点,再看有没有走索引。大多数情况会看到类似下面这样的输出:

-> Sort (cost=... sort method=...) -> Result (cost=... rows=...)

一旦发现排序的rows特别大,就可以确定瓶颈在去重本身。这时候要么减少参与去重的列,要么在应用层分页处理,要么用聚合改写。坚持这个排查习惯,远比死记硬背几条优化口诀靠谱。

6. 去重之后还想取最新数据?ROW_NUMBER()和DISTINCT ON的进阶思路

DISTINCT最大的遗憾,是它只负责告诉你"有哪些不同的值",却无法帮你决定"保留哪一行"。但在真实业务里,我们经常遇到的是更复杂的去重需求:一组相同业务键的数据里,只保留最新的一条。

6.1 用ROW_NUMBER()按组取最新

假设有一张商品价格变更记录表,同一个商品有多次调价,你想查每个商品当前最新的价格。DISTINCT做不到,GROUP BY也不好写,此时窗口函数是最顺手的工具:

SELECT product_id, price, effective_date FROM ( SELECT product_id, price, effective_date, ROW_NUMBER() OVER ( PARTITION BY product_id ORDER BY effective_date DESC ) AS rn FROM product_price_history ) t WHERE rn = 1;

这里的逻辑是:先把每个商品的所有价格记录按生效时间倒序编号,同一个商品的第一条就是最新的一条,然后外层过滤rn = 1。整个过程把"去重"和"取最新"两个需求在一条SQL里一次做完。窗口函数不会像DISTINCT那样只输出一行去重结果,而是可以在每个分组内保留完整字段,这是它最大的优势。

6.2 PostgreSQL的DISTINCT ON

如果你用的是PostgreSQL,还有更简洁的专用语法DISTINCT ON。它能让你明确指定"按哪些字段去重"以及"去重后按什么顺序取第一条":

SELECT DISTINCT ON (product_id) product_id, price, effective_date FROM product_price_history ORDER BY product_id, effective_date DESC;

注意这里的写法顺序是有讲究的:ORDER BY里必须以DISTINCT ON的字段打头,然后才是决定"取哪条"的排序字段。上面这条语句会按product_id分组,在每个组内按effective_date倒序,取最早生效日期的记录——不对,倒序取的是最新的一条。这种写法比嵌套子查询简洁不少,但它是PostgreSQL的方言,MySQL和SQL Server不认,跨数据库开发时要小心。

6.3 物理表存在完全重复行时怎么清理

还有一种更头疼的场景:表本身没有主键,同一行数据被插入了多次,导致完全重复。处理这种数据时,先查出来看看规模:

SELECT key_col, count(*) FROM dirty_table GROUP BY key_col HAVING count(*) > 1;

确定有重复后,常规做法是把不重复的数据捞进临时表,删掉原表再插回去。PostgreSQL里还有一个基于ctid的物理删除技巧:

DELETE FROM dirty_table a USING dirty_table b WHERE a.ctid < b.ctid AND a.key_col = b.key_col;

ctid是PostgreSQL里每行数据的物理位置标识,a.ctid < b.ctid表示"保留物理位置更靠前的那一行,删除后面的重复行"。这个方法不用建临时表,执行效率很高,但第一次用的时候一定要先在测试环境验证,并且提前备份数据——删数据这种事,谨慎永远不过分。

6.4 我的实际取舍经验

把几种去重方式放在一起对比,我的选择逻辑通常是这样:

需求类型推荐写法理由
只列出不同的枚举值DISTINCT代码简洁,语义清晰
分组后做统计计算GROUP BY + 聚合函数一边分组一边计算,一次搞定
确认某条件下是否存在记录EXISTS数据量大时性能优势明显
每组保留最新一条或完整字段ROW_NUMBER() + PARTITION BY通用性强,各数据库都支持
PostgreSQL专用快速去重DISTINCT ON写法简洁,适合PG环境
清理物理重复行临时表或ctid删除从源头解决脏数据

这些年用下来的体会是,DISTINCT本身没有原罪,问题往往出在"没搞清楚重复数据的生成路径"和"没想清楚去重后还要不要其他字段"。前者决定你该不该用DISTINCT,后者决定你该用DISTINCT还是窗口函数。顺手再分享一个习惯:每次写完去重查询,先用EXPLAIN看一遍执行计划,再数据量级上做个粗略估算,这样大部分坑都能在SQL上线之前被挡掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询