在 MySQL 日常数据治理中,我们常遇到某张表里的某些字段组合出现了三次甚至更多次完全相同的记录。这类重复数据如果只靠肉眼或者简单的去重工具很难发现,必须借助分组统计的方式把重复次数超过两条的组揪出来。本文围绕如何查询重复数据超过两条的记录这一需求,从原理到实践给出完整方案。

一、问题本质与基础语法
所谓重复数据超过两条,是指按照一个或多个列的值进行分组后,该分组内的行数大于等于三。MySQL 中处理这种统计最自然的方式就是 GROUP BY 配合 HAVING 子句。GROUP BY 负责把相同键值归为一组,COUNT() 聚合函数计算每个组的行数,而 HAVING 则在分组之后对组级结果进行过滤,这一点不同于 WHERE 在分组前过滤单行。
理解执行顺序非常关键:MySQL 先执行 FROM 确定表,接着 WHERE 筛选原始行,然后 GROUP BY 分组,再执行聚合函数,最后 HAVING 过滤分组结果。因此,统计重复超过两条的记录,条件必须写在 HAVING COUNT(*) > 2 中,而不能写在 WHERE 里。
二、单字段重复超过两条的查询
假设有一张用户行为表 user_log,其中 email 字段因为历史 bug 出现了同一个邮箱注册多条日志的情况。我们要查出哪些邮箱出现了超过两条记录。最基础的写法如下:
SELECT email, COUNT(*) AS repeat_count FROM user_log GROUP BY email HAVING COUNT(*) > 2;
这条语句会返回所有重复次数大于二的邮箱以及对应的重复次数。它的优点是直观、执行计划简单,MySQL 只需对 email 建索引即可快速分组。不过它只给出了分组字段和数量,没有返回原表中那些具体的重复行,如果后续需要清理数据,还要再做关联。
如果我们想直接看到这些重复邮箱对应的所有原始记录,可以用子查询把上一步找出的邮箱作为条件,回查原表:
SELECT *
FROM user_log
WHERE email IN (
SELECT email
FROM user_log
GROUP BY email
HAVING COUNT(*) > 2
);
这种写法在中小数据量下很方便,但在超大数据表上,子查询可能会产生临时表导致性能下降。此时更推荐用派生表关联或者窗口函数(MySQL 8.0 以上)来优化,后文会提到。
三、多字段联合判重的写法
实际业务中,重复往往不是单一字段决定的。例如订单表里 user_id 和 product_id 都相同,且创建时间在同一天,才算重复下单。这时就要按多个字段分组:
SELECT user_id, product_id, COUNT(*) AS repeat_count FROM orders GROUP BY user_id, product_id HAVING COUNT(*) > 2;
多字段分组时,MySQL 会先按第一个字段排序分组,再在组内按第二个字段分,因此建议在 (user_id, product_id) 上建立联合索引,能显著减少 Using temporary 和 Using filesort 的出现。注意 GROUP BY 后面的字段顺序不影响结果正确性,但会影响索引利用率。
有时候我们不仅要查出来,还要顺带知道这些重复组里的最大 ID 或最小时间,方便定位第一条合法数据。可以在 SELECT 中加上聚合列:
SELECT user_id, product_id,
COUNT(*) AS cnt,
MIN(id) AS first_id,
MAX(created_at) AS last_time
FROM orders
GROUP BY user_id, product_id
HAVING COUNT(*) > 2;
这样一行结果就包含了重复组的核心信息,运维人员可以直接拿 first_id 作为保留主键,其余大于该 ID 的行作为待删除重复项。
四、使用窗口函数精准定位重复行
MySQL 8.0 引入了窗口函数,我们可以用 COUNT() OVER (PARTITION BY ...) 在不压缩行的前提下给每一行标注它所在组的重复总数,然后外层过滤:
SELECT *
FROM (
SELECT *,
COUNT(*) OVER (
PARTITION BY user_id, product_id
) AS grp_cnt
FROM orders
) t
WHERE grp_cnt > 2;
这种写法的优势在于原表行数不变,每一行都带着分组计数,方便我们在应用层按业务规则挑选保留哪一条。而且窗口函数通常比子查询回表效率更高,因为只扫描一次数据。缺点是如果重复组极多,中间结果集较大,需要足够的内存临时空间。
如果只想给重复组打序号以便删除非首条,可以改用 ROW_NUMBER():
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user_id, product_id
ORDER BY id ASC
) AS rn
FROM orders
) t
WHERE rn > 1;
上面语句找出每个重复组里除第一条(按 id 最小)之外的所有后续行,也就是应该清理掉的冗余数据。结合 DELETE 联表即可完成去重,且不会误删首条记录。
五、性能与注意事项对比
不同方案在真实环境中的表现差异明显,下面用一张表总结常用方法的适用场景:
| 方案 | 返回形式 | 索引依赖 | 大数据量表现 |
|---|---|---|---|
| GROUP BY + HAVING | 分组聚合行 | 分组列索引 | 良好,但无明细 |
| 子查询 IN 回查 | 原表明细 | 列索引 | 一般,易用临时表 |
| 窗口函数 COUNT OVER | 带计数的明细 | 分区列索引 | 优,单次扫描 |
| ROW_NUMBER 删冗余 | 冗余行标记 | 分区及排序列 | 优,利于清理 |
在生产执行前,务必先在从库或者用 EXPLAIN 查看执行计划,确认没有全表扫描。对超大型表,可以考虑分批按主键区间统计,避免长事务锁表。另外,查重只是第一步,根本解决还需在应用层加唯一索引或业务逻辑校验,防止新的重复不断产生。
通过上述几种写法,无论是单字段还是多字段,统计重复超过两条的记录都能在 MySQL 中高效完成。开发者应根据自身版本与数据规模,选择基础分组或窗口函数方案,并把查重动作纳入定期数据巡检流程。