应使用自关联DELETE或窗口函数ROW_NUMBER()保留每组重复记录中ID最小(或按业务规则最优)的一条,避免误删;大表推荐临时表重建法,禁用INSERT IGNORE/REPLACE INTO清理存量重复数据。

用自关联 DELETE 语句保留最小 ID 的那条重复记录
直接删掉所有重复行会丢数据,通常目标是“每组重复中只留一条”,最稳妥的是留 id 最小的那条。MySQL 支持表自关联删除,但语法容易写错——不能在子查询里直接引用要删的表。
正确写法是用别名绕过限制:
DELETE u1 FROM users u1 INNER JOIN users u2 WHERE u1.email = u2.email AND u1.id > u2.id;
这条语句的意思是:对每一对 email 相同的记录,如果 u1.id 比 u2.id 大,就删 u1。最终每组只剩 id 最小的那条。
- 执行前必须加事务:
START TRANSACTION,确认结果后再COMMIT - 务必先在测试库跑
SELECT COUNT(*)验证影响行数,避免误删整张表 - 如果表没主键或没
id字段,得换其他唯一标识字段(比如created_at+email组合)
用窗口函数 ROW_NUMBER() 精确控制保留逻辑(MySQL 8.0+ / PostgreSQL)
当保留规则变复杂——比如“留最新创建的”“留状态为 active 的”——自关联就力不从心了。ROW_NUMBER() 能按业务优先级排序后标记序号,再筛掉非首行。
例如保留每个 email 下 created_at 最大的那条:
DELETE FROM users
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, id DESC
) AS rn
FROM users
) t WHERE rn > 1
);
-
PARTITION BY email把数据按邮箱分组 -
ORDER BY created_at DESC, id DESC确保最新时间排第一;加id DESC是防时间相同时不确定选哪条 - 嵌套一层子查询是因为 MySQL 不允许 DELETE 中直接引用同一张表的窗口函数结果
用临时表重建实现彻底清洗(适合大表或高一致性要求场景)
上面两种方法都是原地修改,锁表时间长、无法回滚到中间状态。如果表有百万级数据,或者你不敢在生产库上直接 DELETE,临时表是最可控的方式。
核心步骤是三步:建新表 → 插入去重后数据 → 原子化切换:
-- 1. 创建结构一致的新表 CREATE TABLE users_clean LIKE users; <p>-- 2. 插入每组 email 中 id 最大的记录(保留最新) INSERT INTO users_clean SELECT * FROM users WHERE id IN ( SELECT MAX(id) FROM users GROUP BY email );</p><p>-- 3. 原子重命名(瞬间完成,无数据丢失风险) RENAME TABLE users TO users_backup, users_clean TO users;</p>
- 执行前务必备份原表,
RENAME不可逆 -
INSERT ... SELECT期间原表仍可读,但写操作会被阻塞,建议选低峰期 - 如果原表有外键,需先
DROP再重建,否则RENAME会失败
为什么不能用 INSERT IGNORE 或 REPLACE INTO 替代去重删除
这两个语句常被误用于“去重插入”,但它们解决的是写入时的冲突,不是已有数据的清理问题。
-
INSERT IGNORE只对新插入生效,对已存在的重复记录完全无感 -
REPLACE INTO是“删+插”,会触发ON DELETE外键级联、自增 ID 跳变、触发器重复执行等问题,且无法指定保留哪条 - 更隐蔽的风险:如果表只有
UNIQUE(email)索引,但没主键,REPLACE INTO会把整行删掉再插,连created_at这类时间戳都会被重置
真正要清理存量重复数据,必须用明确的 DELETE 或重建策略。想靠写入语句反向“修复”历史脏数据,只会让问题更难追溯。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











