查重复数据时,很多人第一反应是加上 DISTINCT,但这只能去重,不能告诉你“哪些值重复了、哪几行需要处理”。真正要把结果查准,必须先分清目标:你是只想知道重复值,还是要把每一条重复记录连同原始 ID 一起找出来。
下面按这两个问题展开,再补充 MySQL 版本差异和删除前的确认要点。看完后,你可以根据是否支持窗口函数、是否需要保留首条记录,快速判断该用哪种写法。
先找出哪些值发生了重复
如果你的目标只是确认“哪些邮箱重复了”,最直接的写法就是 GROUP BY 配合 HAVING。这类查询适合先看数据分布,确认重复范围,再决定后续是否清理。

假设表 users 中要检查 email 是否重复:
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;这条语句的关键点有三个:
GROUP BY email会把相同邮箱归到同一组。HAVING COUNT(*) > 1用来过滤分组后的结果,只保留出现次数大于 1 的邮箱。COUNT(*)属于聚合结果,不能写进WHERE,否则会报错Invalid use of aggregate function。
这里也能看出 DISTINCT 和“查重复”不是一回事。DISTINCT 只负责把结果集中的重复值压成一条,不会把重复次数告诉你,也不能直接定位哪些记录重复。
如果你要检查的是“整行是否重复”,而不是单个字段重复,那么单用这个思路会更麻烦。因为 GROUP BY 后通常需要把参与判断的字段全部列出来;字段一多,查询可读性和维护性都会变差。这种场景通常更适合放到下一节,用窗口函数或其他方式处理。
需要把重复记录整行查出来时怎么写
实际业务里,更常见的问题不是“哪个邮箱重复”,而是“重复的是哪几条数据”。这时只返回 email 和次数还不够,往往还要把 id、name 等原始字段一起取出来,方便排查或后续删除。
如果数据库支持窗口函数,推荐直接使用 ROW_NUMBER()。适用范围包括 MySQL 8.0+、PostgreSQL、SQL Server:
SELECT id, email, name
FROM (
SELECT id, email, name,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
) t
WHERE rn > 1;这条写法的思路是先给每个重复组内部编号,再过滤掉每组中的第一条:
PARTITION BY email表示按邮箱分组,每个邮箱各自编号。ORDER BY id决定了组内的先后顺序。ROW_NUMBER()会让每组第一条记录编号为 1,后续记录依次变成 2、3……- 外层用
WHERE rn > 1,就能只取出“非首次出现”的重复行。
这个方案的优势在于,它不仅能查出重复行,还顺带表达了“保留谁、处理谁”的规则。比如上面的 ORDER BY id,默认就是保留较早的那条记录。如果你后面要做清理,这个排序条件就是判断依据的一部分。
有些场景也会改用 COUNT(*) OVER (PARTITION BY email)。这样每一行都能看到自己所在分组的总条数,也能判断是否重复。不过如果你最终仍然要排除“首条保留、其余待处理”的记录,ROW_NUMBER() 一般更直接,筛选逻辑也更清楚。
MySQL 5.7 及更老版本怎么办
如果你用的是 MySQL 5.7 或更早版本,就不能使用窗口函数,也没有 CTE(WITH 子句)可用。此时常见做法是自连接,让同一张表中的重复值互相匹配。

一个比较常见的写法如下:
SELECT DISTINCT u1.*
FROM users u1
INNER JOIN users u2
ON u1.email = u2.email AND u1.id != u2.id;它的核心逻辑是:只要某条记录能在表里找到另一条 email 相同、但 id 不同的记录,就说明它属于重复数据。
u1.email = u2.email用来匹配重复值。u1.id != u2.id必须保留,否则每条记录都会和自己匹配,结果就失真了。DISTINCT也不能省,因为一组内如果有多条重复记录,连接后会产生多组配对结果。
这一点很容易被低估。比如同一个邮箱出现 3 次,连接结果会产生多对匹配关系,而不是简单返回 3 行。所以最终需要 DISTINCT 去掉这些重复返回的组合。
这个方案能用,但在大表上要格外注意性能。没有合适索引时,自连接很容易把查询拖慢,甚至接近 O(n²) 扫描。至少要给 email 建好索引,这样数据库在匹配同值记录时才有机会走得更稳。
准备删除前,先把保留规则说清楚
查到重复数据只是第一步,真正容易出错的地方往往发生在删除之前。很多问题不是 SQL 写不出来,而是“到底保留哪一条”没有提前定义清楚。

如果你使用的是窗口函数方案,保留策略就体现在排序条件里。例如:
- 写成
ORDER BY id,通常表示保留 ID 更小、出现更早的记录。 - 如果业务要求保留最新数据,就应该改成
ORDER BY created_at DESC,让最新记录成为第 1 条。
这一步不要凭习惯写。因为排序条件一变,rn = 1 对应的“保留记录”也会跟着变,后面的删除范围自然全变了。
在老 MySQL 中,删除通常比查询更绕。常见做法是先把需要保留的 ID 存到临时表,再执行类似 DELETE WHERE id NOT IN (...) 的语句。但这里还有一个典型坑:NOT IN 一旦遇到 NULL,判断结果可能失效,所以必须额外确认数据里是否存在 NULL,或者改用更稳妥的写法。
无论使用哪种数据库、哪套删除方案,正式执行 DELETE 前都应该先把同样的条件跑成 SELECT 再复核一遍。尤其是在字段组合较多、历史脏数据复杂、空值混杂时,这一步能拦下很多本来可以避免的误删。
重复数据看起来是简单问题,但真正影响结果的因素并不少:字段怎么组合、按什么顺序保留、数据库版本支不支持窗口函数、查询里是否存在 NULL 干扰,这些都会直接改变最终结果。最稳妥的做法,是先用 SELECT 观察实际数据分布,再选匹配当前版本和业务规则的方案,而不是直接套一段固定模板。













