MySQL 删除重复数据并保留最小或最大 ID 的优雅方案 (实操 SQL)
详细讲解在 MySQL 中如何精准查找并删除重复数据行,同时保留自增主键最小(最早)或最大(最新)的那一条,包含单条 SQL 关联删除与大表安全去重方案。
在日常数据维护或由于业务代码未加唯一性约束时,数据库表中经常会出现内容完全重复或部分关键字段(如邮箱、手机号、身份证号)重复的脏数据。
我们通常的诉求是:找出这些重复的记录,将多余的副本全部删除,但只保留其中一条(通常是保留 id 最小的那条,或者 id 最大的最新记录)。
在 MySQL 中,如果直接写 DELETE FROM t WHERE id NOT IN (SELECT MIN(id) FROM t GROUP BY ...),会直接遭遇经典的 MySQL 报错:
ERROR 1093 (HY000): You can't specify target table 'users' for update in FROM clause本文将带您避开这个陷阱,给出 3 种在 MySQL 中优雅删除重复数据的终极解法。
一、 准备测试数据与场景复现
假设我们有一张用户联系表 contacts,其中 first_name 和 email 出现了多条重复录入:
DROP TABLE IF EXISTS contacts;
CREATE TABLE contacts (
id INT PRIMARY KEY AUTO_INCREMENT,
first_name VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL
);
INSERT INTO contacts (first_name, email) VALUES
('Alice', '[email protected]'), -- id: 1 (应保留)
('Bob', '[email protected]'), -- id: 2 (唯一)
('Alice', '[email protected]'), -- id: 3 (重复,需删除)
('Charlie', '[email protected]'), -- id: 4 (唯一)
('Alice', '[email protected]'), -- id: 5 (重复,需删除)
('David', '[email protected]'), -- id: 6 (唯一)
('David', '[email protected]'); -- id: 7 (重复,需删除)
查询当前数据表:
+----+------------+---------------------+
| id | first_name | email |
+----+------------+---------------------+
| 1 | Alice | [email protected] |
| 2 | Bob | [email protected] |
| 3 | Alice | [email protected] |
| 4 | Charlie | [email protected] |
| 5 | Alice | [email protected] |
| 6 | David | [email protected] |
| 7 | David | [email protected] |
+----+------------+---------------------+二、 方案一:使用自连接 (Self Join) 一键删除(最推荐)
利用 MySQL 支持的多表 DELETE 语法,将同一张表进行自连接对比。
1. 保留最小 ID(保留最早插入的数据):
DELETE c1
FROM contacts c1
INNER JOIN contacts c2
ON c1.email = c2.email -- 相同业务字段重复
AND c1.id > c2.id; -- 只删除 id 较大的行
原理拆解:
- 当
c1.email = c2.email时:c1的 id 为 3,c2的 id 为 1,满足c1.id > c2.id,因此c1 (id=3)将被标记删除;c1的 id 为 5,c2的 id 为 1,满足c1.id > c2.id,因此c1 (id=5)将被标记删除;- 而
c1的 id 为 1 时,找不到任何一条比它 id 更小的同 email 记录,因此id=1绝不会被删除!
2. 若要保留最大 ID(保留最新插入的数据):
只需将比较符号反转即可:
DELETE c1
FROM contacts c1
INNER JOIN contacts c2
ON c1.email = c2.email
AND c1.id < c2.id; -- 只删除 id 较小的行,保留最大 id
三、 方案二:使用子查询并套一层临时表(突破 1093 报错)
如果你习惯使用 NOT IN (SELECT MIN(id) ...) 的思路,为了避开 MySQL 的 ERROR 1093,必须在子查询外面再套一层中间临时别名表:
DELETE FROM contacts
WHERE id NOT IN (
-- 在外层包裹一层 SELECT * FROM (...) AS tmp
SELECT min_id FROM (
SELECT MIN(id) AS min_id
FROM contacts
GROUP BY email
) AS tmp
);
[!NOTE] MySQL 遇到外层嵌套的派生表(Derived Table)时,会先将其物化(Materialize)到内存临时表中,从而解除对目标表的加锁锁定限制。
四、 方案三:针对超千万级生产大表的“换表大法”
对于上千万行数据的大型生产表,直接执行大面积的 DELETE 语句会导致:
- 全局行锁或间隙锁等待,引发业务接口阻塞超时;
- 产生海量
Undo/Redo Log; - 删除后表空间碎片过多,物理磁盘文件不会自动缩小。
生产级安全换表流程:
-- 1. 创建一张与原表结构完全相同的新表
CREATE TABLE contacts_new LIKE contacts;
-- 2. 将去重后的干净数据直接按批过滤导入新表
INSERT INTO contacts_new
SELECT * FROM contacts
WHERE id IN (
SELECT MIN(id) FROM contacts GROUP BY email
);
-- 3. 原子重命名交换数据表 (业务无感切换,耗时仅数毫秒)
RENAME TABLE contacts TO contacts_old, contacts_new TO contacts;
-- 4. 业务平稳后,在低峰期删除历史旧表释放磁盘空间
DROP TABLE contacts_old;
五、 防患于未然:给关键字段添加唯一索引
去重完成后,为了防止日后再次产生脏数据,应及时为重复字段建立唯一约束(Unique Index):
ALTER TABLE contacts ADD UNIQUE INDEX uk_email (email);
有了唯一索引的保护,未来任何重复数据的插入都会被数据库直接拦截拒收,从源头上保障数据完整性。