可用ROW_NUMBER()配合CTE实现去重删除:先用PARTITION BY分组、ORDER BY倒序编号,再删rn>1的行;MySQL 5.7需用自连接找非最新记录删除。

用 ROW_NUMBER() 突破 DELETE 不支持窗口函数的限制
SQL 的 DELETE 语句本身不能直接使用窗口函数,所以不能写 DELETE FROM t WHERE rn > 1 这种逻辑。必须把去重逻辑“转嫁”到子查询或 CTE 中,再通过主键/唯一标识关联删除。
核心思路是:给每组重复数据按时间倒序编号,只保留 rn = 1 的记录,其余删掉。
- MySQL 8.0+、PostgreSQL、SQL Server、Oracle 都支持该方案;MySQL 5.7 及更早版本不支持窗口函数,需改用自连接或变量模拟
- 务必确保有可排序的时间字段(如
created_at或id)——没有它,“最新”就无从定义 - 执行前先用
SELECT预览将被删的行:WITH dup AS ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at DESC ) AS rn FROM users ) SELECT * FROM dup WHERE rn > 1;
MySQL 5.7 或更低版本的替代写法
老版本 MySQL 没有 ROW_NUMBER(),但可以用自连接 + 条件排除实现等效逻辑:对每条记录,找出同邮箱下更新的记录,若不存在,则它是“最新”的;反之则应删除。
- 假设表
users有id(自增主键)、email、created_at字段 - 删除非最新记录的语句:
DELETE u1 FROM users u1 INNER JOIN users u2 ON u1.email = u2.email AND u1.created_at < u2.created_at;
- 注意:这个写法依赖
created_at严格可比较;如果存在毫秒级相同值,可能漏删或误删——此时应叠加id辅助判断:u1.created_at - 该语句在大表上性能较差,建议在
(email, created_at)上建联合索引
PostgreSQL 中用 ctid 避免依赖业务字段
PostgreSQL 每行自带隐藏系统列 ctid,表示物理位置,插入越晚 ctid 值越大(虽不绝对保证,但在无 VACUUM 干扰的常规插入场景下可靠)。当缺乏明确时间字段时,可用它临时锚定“最新”。
- 慎用:仅限开发/修复场景,不可作为长期逻辑依赖
- 示例:
DELETE FROM users WHERE ctid NOT IN ( SELECT max(ctid) FROM users GROUP BY email );
-
max(ctid)在多数情况下对应最后插入的行,但并发插入或表膨胀后可能失效;生产环境仍推荐补上created_at字段并加NOT NULL约束
删除前必须确认的三件事
重复数据清理是不可逆操作,跳过验证极易引发线上事故。
- 确认去重依据字段组合是否合理:比如仅按
email去重,但业务允许同一邮箱多人注册(如家庭账号),那就错了 - 检查待删记录是否关联其他表:外键未设
ON DELETE CASCADE时,直接删主表会报错ERROR: update or delete on table "users" violates foreign key constraint - 备份!哪怕只是
CREATE TABLE users_bak AS SELECT * FROM users;—— 很多“删完才发现逻辑理解反了”的问题,靠它能快速回滚
真正麻烦的不是语法怎么写,而是搞清“哪些算重复”“哪条才算最新”——这两个问题没共识,代码写得再漂亮也没用。

















