在处理业务数据时,我们经常遇到这样的情况:一张表中因为程序bug、重复导入或者并发写入,产生了大量按某几个字段判断属于“重复”的记录。比如订单表里同一个用户对同一件商品下了多笔相同订单,日志表里同一条事件被记录了多次。这类问题用MySQL解决时,绕不开两个关键字:GROUP BY和DISTINCT。这篇文章就来详细讲讲这两个关键字的组合用法,以及多字段去重的各种实战写法。

一、先弄清楚DISTINCT和GROUP BY的本质区别
很多初学者觉得DISTINCT和GROUP BY效果差不多,能去重就行,随便用哪个都可以。实际上两者虽然经常能返回相同的结果,但底层定位完全不同。DISTINCT的作用是把结果集中完全相同的行合并为一行,它关注的是“输出的行不重复”;而GROUP BY是把数据按指定字段分组,目的是对每一组做聚合运算,比如COUNT、SUM、MAX等。
看一个具体例子,假设有一张订单明细表order_detail,包含user_id、product_id、create_time三个字段。如果想查出所有不重复的用户和商品组合,下面两种写法结果完全一样:
-- 写法一:DISTINCT SELECT DISTINCT user_id, product_id FROM order_detail; -- 写法二:GROUP BY SELECT user_id, product_id FROM order_detail GROUP BY user_id, product_id;
需要注意一点,DISTINCT是对SELECT后面列出的所有字段组合起来判断重复,而不是对某一个字段去重。这一点经常被误解,有人写了SELECT DISTINCT user_id, product_id FROM ...却以为只对user_id去重,结果发现数据行数没变化还以为语句失效了。实际上只要user_id和product_id的组合不同,这一行就会被保留。
两者的差异主要体现在:DISTINCT只能返回去重后的原始值,没法附带聚合统计;GROUP BY则可以在分组的基础上取聚合结果,比如统计每个组合出现的次数:
SELECT user_id, product_id, COUNT(*) AS cnt FROM order_detail GROUP BY user_id, product_id HAVING cnt > 1;
上面这条语句顺便还能筛选出重复的组合,是排查重复数据时最常用的手段之一。另外从执行计划角度看,在MySQL较老版本(5.7之前)中,DISTINCT在部分场景下会借助临时表,而GROUP BY在某些查询里可以走松散索引扫描,性能会更好一些。不过MySQL 8.0之后优化器对两者的处理已经越来越接近,大多数情况下执行计划是一致的。
二、多字段组合去重的实战写法
多字段去重的核心思路是把所有参与判断的字段一起放进DISTINCT或者GROUP BY里。但在真实业务中,需求往往不止“查出去重结果”这么简单,更常见的是:查出每个分组中最新的一条记录。这种需求单靠DISTINCT就做不到了,需要借助子查询配合GROUP BY。
1. 基础多字段去重查询
SELECT DISTINCT user_id, product_id, order_status FROM order_detail WHERE create_time >= '2024-01-01';
这条语句会对user_id、product_id、order_status三个字段的组合进行去重,注意WHERE条件要写在DISTINCT前面,因为SQL的执行顺序是先过滤再去重,先缩小数据范围能明显减少去重的工作量。
2. 每组保留最新一条记录
这是多字段去重里最经典的需求。思路是先用GROUP BY找出每个组合的最大时间(或其他排序依据),再回表关联取出完整记录:
SELECT t.*
FROM order_detail t
INNER JOIN (
SELECT user_id, product_id, MAX(create_time) AS max_time
FROM order_detail
GROUP BY user_id, product_id
) tmp ON t.user_id = tmp.user_id
AND t.product_id = tmp.product_id
AND t.create_time = tmp.max_time;
这种写法在MySQL 5.x版本中非常通用。但它有一个隐患:如果同一个组合里恰好有两条记录的create_time完全相同,那么这两条都会被查出来,去重就不彻底。遇到这种情况可以再取一个MAX(id)作为兜底判断条件,确保排序依据的绝对唯一性。
3. 使用窗口函数ROW_NUMBER
MySQL 8.0引入了窗口函数,处理这类需求优雅得多。ROW_NUMBER可以给每组内的记录编号,然后只取编号为1的那条:
SELECT *
FROM (
SELECT t.*,
ROW_NUMBER() OVER (
PARTITION BY user_id, product_id
ORDER BY create_time DESC, id DESC
) AS rn
FROM order_detail t
) x
WHERE x.rn = 1;
PARTITION BY指定了分组的字段组合,ORDER BY决定组内保留哪一条。这里在排序末尾加上id DESC正是为了解决时间相同导致的并列问题。窗口函数的写法逻辑清晰,而且不用像子查询那样回表关联两次,在数据量大时通常表现更好。
三、物理删除表中的重复数据
查询去重只是第一步,很多场景下需要真正把重复数据从表里删掉。删除操作风险较高,动手前一定要备份,并且在事务中执行或者先用SELECT验证要删的数据范围。
1. 保留id最小的一条,删除其余重复记录
DELETE t FROM order_detail t
INNER JOIN order_detail t2
ON t.user_id = t2.user_id
AND t.product_id = t2.product_id
AND t.id > t2.id;
这个自连接删除的思路是:让每一行去和同组中id更小的行比较,只要存在比自己id小的同组记录,说明自己不是最早的那条,就该被删除。写法简洁,但在没有合适索引的情况下,自连接的复杂度会很高,大表上执行可能非常慢。
2. 通过临时表方式处理大表去重
如果表数据量达到千万级,直接DELETE会产生大事务和大量行锁,更稳妥的做法是先把去重后的数据插入一张新表,再通过RENAME原子性地替换旧表:
-- 创建新表并插入去重后的数据
CREATE TABLE order_detail_new LIKE order_detail;
INSERT INTO order_detail_new
SELECT *
FROM (
SELECT t.*,
ROW_NUMBER() OVER (
PARTITION BY user_id, product_id
ORDER BY create_time DESC, id DESC
) AS rn
FROM order_detail t
) x
WHERE x.rn = 1;
-- 验证数据无误后原子替换
RENAME TABLE order_detail TO order_detail_old,
order_detail_new TO order_detail;
这种方式的好处是删除动作几乎瞬间完成,业务停顿时间极短,缺点是需要临时占用双倍存储空间,而且替换期间新写入的数据需要另行处理,适合可以在低峰期执行的批处理任务。
四、性能优化:索引对去重效率的影响
无论用GROUP BY还是DISTINCT,去重本质上都需要对数据排序或哈希分组。如果参与分组的字段上有合适的联合索引,MySQL可以直接利用索引的有序性完成分组,避免额外的排序和临时表,性能差距可能是几十倍。
针对前面例子中的表,建立联合索引时有讲究:
-- 联合索引的字段顺序要和GROUP BY的字段顺序一致 ALTER TABLE order_detail ADD INDEX idx_user_product (user_id, product_id, create_time);
把create_time也放进索引有两个好处:一是覆盖索引让分组统计不用回表,二是“每组取最新一条”的子查询里MAX(create_time)可以直接从索引中读取。可以用EXPLAIN验证,当Extra列出现Using index for group-by时,说明MySQL正在利用松散索引扫描优化分组操作,这是GROUP BY最理想的执行方式。
还有几个实践建议值得参考:去重前先用WHERE尽量过滤无关数据,不要把几十万行数据全捞出来再去重;对经常出现重复的表,考虑在业务层加唯一索引从源头杜绝重复写入,比如对user_id和product_id建UNIQUE KEY,插入时用INSERT IGNORE或者ON DUPLICATE KEY UPDATE兜底,这比事后清理要省心得多;删除重复数据时务必分批执行,每批控制在一两千行左右,避免长事务阻塞其他查询。
总结一下,简单的结果去重用DISTINCT足够,涉及分组统计用GROUP BY,要保留组内特定记录就上窗口函数ROW_NUMBER,物理清理大表则推荐临时表替换方案。根据数据量和业务容忍度选对工具,多字段去重并不难处理。