推荐用 ROW_NUMBER() + CTE 删除重复行:先通过 CTE 为每组 email 按 created_at 降序编号,再删除 rn > 1 的记录;MySQL 8.0+ 支持,旧版需改用自连接或临时表。

用 ROW_NUMBER() + CTE 删除重复行(推荐方案)
这是目前最通用、可读性好且可控性强的方法,适用于 PostgreSQL、SQL Server、Oracle、Snowflake 和较新版本的 MySQL(8.0+)。核心思路是给每组重复记录编号,只保留序号为 1 的那条。
假设表 users 中 email 字段重复,需按 created_at 保留最新的一条:
WITH dupes AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC
) AS rn
FROM users
)
DELETE FROM users
WHERE id IN (SELECT id FROM dupes WHERE rn > 1);注意点:
-
ROW_NUMBER()必须配合PARTITION BY和ORDER BY,否则无法定义“哪条算第一条” - MySQL 8.0 以前不支持在
DELETE中直接引用 CTE,需改用临时表或子查询嵌套 - 执行前务必先
SELECT * FROM dupes WHERE rn > 1预览将删哪些行
MySQL 5.7 及更早版本的替代写法
老版 MySQL 不支持 CTE 和窗口函数,只能靠自连接或子查询。效率略低,但兼容性好:
DELETE u1 FROM users u1 INNER JOIN users u2 ON u1.email = u2.email AND u1.created_at < u2.created_at;
这条语句保留每组中 created_at 最大的那条(即最新),删掉所有“有更新同邮箱记录”的旧行。
关键细节:
- 必须用
INNER JOIN,不能用LEFT JOIN,否则可能误删全部 - 条件
u1.created_at 决定了保留逻辑:只要存在一条更新的同邮箱记录,<code>u1就该被删 - 如果想保留最早的一条,把
改成 <code>>,并确保索引覆盖(email, created_at)
避免全表扫描:索引不是可选项,是必需项
无论用哪种删除方式,没有合适索引时,重复判定和 JOIN 都会触发全表扫描,百万级数据可能卡住十几分钟甚至失败。
针对 email 去重,至少建这个联合索引:
CREATE INDEX idx_email_created ON users(email, created_at);
理由:
-
PARTITION BY email和JOIN ... ON email都依赖email快速定位分组 -
ORDER BY created_at DESC或u1.created_at 需要高效范围比较,单列 <code>email索引无法支撑 - 若去重依据是多个字段(如
first_name, last_name, birth_date),索引字段顺序必须与PARTITION BY或JOIN条件完全一致
DELETE 没反应?检查事务隔离与锁表现
在高并发场景下,DELETE 可能看似“没生效”,实际是被锁阻塞或回滚了。
常见表现与对策:
- 执行很久没返回 → 查
SHOW PROCESSLIST(MySQL)或pg_stat_activity(PostgreSQL),看是否在等行锁 - 删完查不到变化 → 检查是否在事务里没
COMMIT,或被其他事务的未提交修改遮蔽 - 部分重复没删掉 → 确认字段值是否真“相等”:空字符串
''和NULL不等;大小写敏感(如 MySQL 默认utf8mb4_0900_as_cs);前后空格未TRIM()
真正麻烦的是大表在线去重:既要避免长事务拖垮系统,又得保证业务连续。这时候得拆成小批次删,比如每次删 1000 行加 SLEEP(0.1),而不是一气呵成。

















