MySQL 8.0+ 用 ROW_NUMBER() 窗口函数按指定字段分组排序编号,删除编号>1的行以保留最新一条;5.7-用自连接+LEFT JOIN模拟,需联合索引和主键,或用CREATE TABLE SELECT DISTINCT重建表。

MySQL 删除重复行只保留一条:用 ROW_NUMBER() 窗口函数最稳
MySQL 8.0+ 支持窗口函数,ROW_NUMBER() 是去重最可控的方式。它能按指定字段分组编号,再筛掉编号 >1 的行,避免误删或漏删。
常见错误是直接用 GROUP BY 配合 MIN(id) 再 DELETE,但这种写法在子查询里引用同一张表会报错:ERROR 1093 (HY000): You can't specify target table 't' for update in FROM clause。
- 必须用派生表(子查询套一层)或 CTE 绕过限制
- 排序逻辑很重要:比如按
created_at DESC保留最新的一条,而不是默认顺序 - 务必先
SELECT验证要删哪些行,别跳过这步
WITH dup AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY col_a, col_b
ORDER BY created_at DESC
) AS rn
FROM my_table
)
DELETE FROM my_table
WHERE id IN (SELECT id FROM dup WHERE rn > 1);
MySQL 5.7 或更低版本:用自连接 + LEFT JOIN 模拟去重
老版本没窗口函数,只能靠自连接比对。核心思路是:对每组重复数据,让“较小的 id”左连接“更大的 id”,如果连不上,说明它是该组里最小的,就保留;否则删除。
性能上要注意:JOIN 字段必须有联合索引,否则全表扫描极慢。比如按 (col_a, col_b) 重复,就得建 INDEX idx_dup (col_a, col_b)。
- 不能用
IN子查询替代JOIN,5.7 对子查询优化差,容易锁表卡死 -
id必须是主键或唯一索引列,否则JOIN条件可能匹配多行,逻辑错乱 - 执行前加
SELECT COUNT(*)看重复量级,几万行以上建议分批删
DELETE t1 FROM my_table t1 INNER JOIN my_table t2 ON t1.col_a = t2.col_a AND t1.col_b = t2.col_b AND t1.id > t2.id;
用 CREATE TABLE ... SELECT DISTINCT 替换整表:适合中小表且可停写
当表不大(比如
容易踩的坑是忽略表结构细节:原表的 AUTO_INCREMENT、COMMENT、字符集、索引、外键都不会自动复制过去。
- 先用
SHOW CREATE TABLE my_table查原建表语句,手动补全索引和选项 -
DISTINCT只对 SELECT 字段生效,如果想按某字段去重但保留其他字段值(如最新时间),得配合GROUP BY+ 聚合函数,不能只靠DISTINCT - 替换前记得备份原表:
RENAME TABLE my_table TO my_table_bak, my_table_new TO my_table
CREATE TABLE my_table_new AS SELECT MIN(id) AS id, col_a, col_b, MAX(created_at) AS created_at FROM my_table GROUP BY col_a, col_b;
为什么不要用 ALTER IGNORE TABLE ... ADD UNIQUE INDEX
MySQL 5.7 以前支持 IGNORE 关键字强制去重建唯一索引,但它已被弃用,5.7 默认禁用,8.0 完全移除。即使开启兼容模式,行为也不可靠:
- 重复行被删哪条完全由存储引擎内部顺序决定,无法控制保留逻辑
- 会静默丢数据,不报错也不提示删了几行
- 可能破坏主从一致性,从库同步失败风险高
- 唯一索引建完,原表
id就断了,后续插入可能冲突
真遇到线上紧急去重,宁可停写几分钟走 CREATE TABLE 方案,也别碰 IGNORE。
真正难的不是写 SQL,而是判断「哪些字段才算重复」——业务语义一旦模糊,比如 phone 字段带空格或区号变体,SQL 层就无能为力,得前置清洗。


















