当一张业务表因为程序漏洞、重复导入或者并发写入产生了完全相同的记录时,查询统计就会出现偏差,索引空间也被白白占用。所谓完全重复,指的是所有字段的值都一模一样,连主键都无法区分它们(这种情况通常发生在表没有设计主键的时候)。本文介绍两种经过实践验证的清理方案:ROWID定位删除法和临时表重建法,并针对不同版本的MySQL给出对应的SQL写法。

一、准备工作:确认重复情况并做好备份
动手删数据之前,必须先搞清楚重复的严重程度。对于没有主键的表,可以直接用SELECT *, COUNT(*) FROM t GROUP BY 所有字段 HAVING COUNT(*) > 1来查看哪些数据出现了重复。注意GROUP BY的列表必须覆盖全部字段,否则会把只是部分相同的记录也当成重复。如果表字段很多,写起来很繁琐,可以用CONCAT_WS把所有字段拼接成一个字符串再分组,效果等价但SQL更短。
备份这一步千万不要省。最直接的方式是整表备份一份:
-- 快速备份整张表 CREATE TABLE t_backup AS SELECT * FROM your_table; -- 确认备份行数一致后再操作 SELECT COUNT(*) FROM your_table; SELECT COUNT(*) FROM t_backup;
备份完成后,建议在一个事务中执行删除(InnoDB引擎),这样即使误删也可以回滚。另外如果这张表被其他表通过外键引用,删除时还要注意外键约束的行为,必要时先临时关闭外键检查:SET FOREIGN_KEY_CHECKS = 0;,操作完再打开。
二、方案一:利用隐藏ROWID定位删除
InnoDB表每行都有一个隐藏的ROWID(如果表没有显式主键,系统会自动生成一个聚簇索引键)。思路是:先按所有字段分组,每组只保留一条,把要删除的行定位出来,再用DELETE配合子查询删除。MySQL 8.0提供了窗口函数,写法非常简洁:
-- MySQL 8.0 及以上版本
DELETE FROM your_table
WHERE row_id IN (
SELECT row_id FROM (
SELECT ctid AS row_id,
ROW_NUMBER() OVER (
PARTITION BY col1, col2, col3
ORDER BY ctid
) AS rn
FROM your_table
) tmp
WHERE rn > 1
);
不过需要说明的是,MySQL的InnoDB并不像Oracle那样提供可以直接查询的物理ROWID伪列,上面的ctid思路在PostgreSQL里才可用。在MySQL中,更通用的做法是先给没有主键的表补一个自增主键,让每一行变得可区分,然后再执行删除:
-- 第一步:为无主键的表添加自增列,使每行唯一可识别
ALTER TABLE your_table ADD COLUMN rid BIGINT NOT NULL AUTO_INCREMENT,
ADD PRIMARY KEY (rid);
-- 第二步:保留每组最小rid的记录,删除其余重复行
DELETE FROM your_table
WHERE rid NOT IN (
SELECT min_rid FROM (
SELECT MIN(rid) AS min_rid
FROM your_table
GROUP BY col1, col2, col3
) tmp
);
这里有一个细节值得注意:MySQL不允许DELETE语句直接引用被删除的同一张表作为子查询,所以中间必须包一层派生表(示例中的tmp),否则会报错"You can't specify target table for update in FROM clause"。很多人第一次写这个SQL都会踩到这个坑。另外,如果表里字段非常多,GROUP BY全部字段可以用GROUP BY CONCAT_WS('|', col1, col2, ...)简化,但要注意字段值中如果本身就含有分隔符,理论上存在极小概率的误判,字段可控时再使用。
三、方案二:临时表重建法
当重复数据量非常大时,逐行DELETE会产生大量undo日志和binlog,执行缓慢且主从延迟明显。此时更推荐临时表方案:先把去重后的干净数据写入新表,再用RENAME原子替换。这种方式本质上是重建数据,删除操作被转化为一次建表加一次改名,速度通常快一个数量级。
-- 第一步:创建结构相同的新表
CREATE TABLE your_table_new LIKE your_table;
-- 第二步:插入去重后的数据(DISTINCT会按所有字段去重)
INSERT INTO your_table_new
SELECT DISTINCT * FROM your_table;
-- 第三步:原子交换表名,瞬间完成
RENAME TABLE your_table TO your_table_old,
your_table_new TO your_table;
如果只需要按部分字段去重(即保留每个分组的一条记录),DISTINCT就不够用了,可以结合GROUP BY取每组任意一条:
INSERT INTO your_table_new
SELECT * FROM your_table
WHERE rid IN (
SELECT MIN(rid) FROM your_table
GROUP BY col1, col2, col3
);
这种方案的主要风险在于切换瞬间:RENAME之前新旧表是两张不同的表,期间写入旧表的新数据在切换后会丢失。因此执行前最好停掉相关写入,或者选择业务低峰期操作。确认线上稳定后,再把your_table_old保留几天作为额外备份,之后DROP掉即可。此外,新表记得检查索引是否完整,CREATE TABLE ... LIKE会复制索引结构,但如果原表缺索引,正好趁此机会补上。
四、两种方案的对比与选择建议
两种方案各有适用场景,简单对比如下:
| 对比维度 | ROWID定位删除法 | 临时表重建法 |
|---|---|---|
| 执行速度 | 重复行少时快,重复行多时慢 | 与数据总量相关,大表整体更快 |
| 锁影响 | 按行加锁,间隙锁可能阻塞业务 | 写入新表不锁旧表,RENAME瞬时完成 |
| binlog量 | 每删一行记一条,量大 | 一次INSERT SELECT加RENAME,量小 |
| 操作复杂度 | 一条SQL搞定,逻辑直观 | 多步骤,需停写或选低峰期 |
| 适用场景 | 重复行占比低于20% | 重复严重的大表、核心业务表 |
实际选择时可以先估算重复比例:SELECT COUNT(*) - COUNT(DISTINCT 所有字段拼接) FROM your_table得到重复行数。重复行数在一万以内的,直接用方案二的第一种SQL删除即可,几分钟内能跑完;重复行数达到百万级别,果断采用临时表重建。无论选哪种,操作前的时间点备份和操作后的行数校验都是不可省略的流程,数据安全永远排在效率前面。
MySQL删除重复记录临时表去重ROWID去重修改时间:2026-09-05 04:10:31