最常用且稳妥的做法是用delete + 子查询保留最小id行:先按业务字段(如name、email)分组取min(id),再删除不在该集合中的记录;或用delete join自连接删掉t1.id > t2.id的重复行,确保保留最小id。

用DELETE + 子查询保留最小ID的行
直接删掉重复行中ID较大的那些,只留ID最小的——这是最常用也最稳妥的做法。核心思路是:对每个重复组(比如按 name 和 email 判重),找出 MIN(id),然后把其他行删掉。
典型写法是用子查询配合 NOT IN 或 NOT EXISTS,但要注意 NULL 值和性能问题:
-
NOT IN遇到子查询结果含NULL会整个返回空集,导致一条不删,务必加WHERE id IS NOT NULL过滤 - 子查询里不能直接引用外层表别名(MySQL 5.7+ 支持,但低版本会报错),建议统一用
JOIN写法替代 - 大表慎用,没索引时可能全表扫描两次;确保
(name, email)有联合索引
推荐写法(兼容性好):
DELETE t1 FROM users t1 INNER JOIN users t2 ON t1.name = t2.name AND t1.email = t2.email AND t1.id > t2.id;
MySQL 8.0+ 可用 ROW_NUMBER() 精准控制
如果用的是 MySQL 8.0、PostgreSQL 或 SQL Server,窗口函数更直观,也更容易扩展(比如保留最新一条而非最早一条)。
关键点在于:用 ROW_NUMBER() 按分组排序后标记序号,再删掉序号 > 1 的行:
- 排序字段决定“保留哪条”——
ORDER BY id ASC保留最小ID,ORDER BY id DESC就保留最大ID - 必须用 CTE 或派生表包裹,因为多数数据库不允许在
DELETE中直接引用窗口函数 - PostgreSQL 可用
ctid或tableoid辅助去重,但不如ROW_NUMBER()明确
MySQL 示例:
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY name, email ORDER BY id ASC
) rn
FROM users
)
DELETE FROM users WHERE id IN (
SELECT id FROM ranked WHERE rn > 1
);
DELETE 自连接写法的陷阱
上面用 INNER JOIN 的写法本质是自连接,但不同数据库语法细节差异大,容易出错。
常见翻车点:
- SQLite 不支持
DELETE ... FROM ... JOIN,得改用WHERE id NOT IN (SELECT MIN(id) ...),且要处理NULL - PostgreSQL 要求
USING关键字,写成DELETE FROM users u USING users u2 WHERE u.name = u2.name AND u.id > u2.id - SQL Server 允许
FROM子句带别名,但不能省略FROM,否则语法报错 - 所有场景下,执行前务必先
SELECT验证要删的行,例如:SELECT t1.* FROM users t1 INNER JOIN users t2 ON t1.name = t2.name AND t1.id > t2.id
去重前必须做的三件事
删数据不是试错操作,尤其线上库。动手前这三步跳不过:
- 备份目标表:
CREATE TABLE users_backup AS SELECT * FROM users;(或导出 SQL) - 确认重复逻辑是否真符合业务——比如
email相同但is_deleted = 1的是否算重复?需加WHERE is_deleted = 0过滤 - 检查外键约束:如果其他表引用了
users.id,删完可能引发级联删除或报错,先查INFORMATION_SCHEMA.KEY_COLUMN_USAGE
真正麻烦的从来不是SQL怎么写,而是删完发现某条“重复”的记录其实关联着未同步的订单或日志——业务语义永远比语法优先。










