无主键表去重必须先添加临时主键,否则DELETE...JOIN无法安全执行;MySQL 8.0+推荐用ROW_NUMBER()窗口函数配合CTE删除重复行,老版本需用双层子查询加派生表规避ERROR 1093。

直接删重复行必须加临时主键
无主键表无法用 DELETE ... JOIN 安全去重,因为没有稳定锚点来区分“保留哪条”和“删哪条”。MySQL 会把语义上相同的行视为完全不可区分的副本,执行 DELETE 时可能误删全部或漏删——这不是 bug,是设计使然。所以第一步不是写 DELETE,而是给表一个可依赖的唯一标识。
常见错误是跳过这步,直接用子查询套 GROUP BY + HAVING COUNT() > 1 去驱动删除,结果在某些 MySQL 版本(尤其是 5.7 及更早)中触发非确定性行为,甚至报错 ERROR 1093: You can't specify target table for update in FROM clause。
- 先执行
ALTER TABLE mat ADD COLUMN id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY FIRST;(注意加FIRST避免影响后续列序) - 确认新增列已填充连续值:
SELECT id, cat FROM mat LIMIT 5; - 别急着删——先备份:
CREATE TABLE mat_backup AS SELECT * FROM mat;
用 ROW_NUMBER() 精准保留首条(MySQL 8.0+)
如果你用的是 MySQL 8.0 或更新版本,ROW_NUMBER() 是最干净的方式:它不改表结构、不锁全表、逻辑一目了然。关键在于 PARTITION BY 要覆盖你定义“重复”的字段(比如 cat),而 ORDER BY id 保证取每组第一条。
但注意:窗口函数不能直接用于 DELETE,必须走中间表或 CTE。下面这个写法能绕过限制:
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY cat ORDER BY id) AS rn FROM mat ) DELETE m FROM mat m INNER JOIN ranked r ON m.id = r.id WHERE r.rn > 1;
这个语句实际执行的是“对原表做自连接”,比嵌套子查询更易读,也避免了 MySQL 对子查询中引用目标表的限制。
兼容老版本的 DELETE + 子查询组合(5.7 及以下)
老版本没窗口函数,只能靠两层子查询:外层删,内层定“要留哪些 id”。但 MySQL 不允许在 DELETE 的 WHERE 子句里直接 SELECT 同一张表,所以必须用派生表(alias)包一层。
典型写法长这样:
DELETE FROM mat WHERE id NOT IN (
SELECT id FROM (
SELECT MIN(id) AS id FROM mat GROUP BY cat
) AS keep_ids
);这里容易踩三个坑:
-
MIN(id)前必须加别名(如AS id),否则派生表无列名,外层NOT IN会失败 - 如果
cat字段含NULL,GROUP BY cat会把所有NULL归为一组,导致只留一条NULL行——若业务允许多条NULL,得额外处理 - 大表慎用:
NOT IN在遇到NULL时整个条件变UNKNOWN,可能意外跳过删除;建议改用LEFT JOIN ... IS NULL替代
删完别忘了收尾和防复发
物理去重只是第一步。删完后立刻执行 OPTIMIZE TABLE mat; 回收碎片空间(尤其 MyISAM 引擎),InnoDB 也能释放部分页空间并重建索引树。
更重要的是预防再出现:
- 加唯一约束:
ALTER TABLE mat ADD UNIQUE KEY uk_cat (cat);(如果业务允许) - 如果
cat本身不能唯一,就建联合唯一索引,比如(cat, status) - 应用层插入前先
SELECT ... FOR UPDATE检查,或统一走INSERT IGNORE/ON DUPLICATE KEY UPDATE
真正容易被忽略的点是:无主键表在去重过程中,任何并发写入都可能导致新重复行混入——所以操作前务必停写或加表级写锁(LOCK TABLES mat WRITE;),删完再 UNLOCK TABLES;。这不是过度谨慎,是物理去重无法回避的代价。


















