核心思路是先用ROW_NUMBER()在CTE或子查询中为重复组编号,保留rn=1的行,再通过唯一标识(如ctid、id)关联删除;必须指定PARTITION BY和ORDER BY,且不可在WHERE中直接使用窗口函数。

用 ROW_NUMBER() 标识重复行再过滤
没有主键的表删重,核心思路不是“直接删”,而是“先标出哪些该留、哪些该删”。ROW_NUMBER() 是唯一能稳定生成序号的窗口函数,配合 PARTITION BY 可对逻辑重复组编号。关键在于:同一组重复数据中,只保留 ROW_NUMBER() = 1 的那行,其余全删。
常见错误是写成 DELETE FROM table WHERE ROW_NUMBER() OVER (...) > 1 —— 这在标准 SQL 中语法非法,因为 WHERE 子句不能直接引用窗口函数。
- 必须用 CTE 或子查询把
ROW_NUMBER()结果物化出来 -
PARTITION BY列要覆盖所有判定“重复”的字段(比如name, email, created_at),漏一个就可能把本该去重的行放过了 - 注意
ORDER BY在OVER中的作用:它决定哪一行被标为1。如果想保留最新插入的,就按时间倒序;想留最早一条,就正序
CTE + DELETE 的标准写法(PostgreSQL / SQL Server / Oracle)
绝大多数支持 CTE 的数据库(包括 PostgreSQL、SQL Server、Oracle)都允许在 CTE 中定义带 ROW_NUMBER() 的结果集,再对 CTE 执行 DELETE(前提是 CTE 可更新,且目标表是单表引用)。
WITH duplicates AS (
SELECT id, name, email,
ROW_NUMBER() OVER (
PARTITION BY name, email
ORDER BY created_at DESC
) AS rn
FROM users_no_pk
)
DELETE FROM duplicates WHERE rn > 1;这段代码能跑通的前提是:duplicates CTE 引用的是单个基表(users_no_pk),且数据库支持可更新 CTE。MySQL 8.0+ 也支持,但 MySQL 5.7 不支持这种写法。
- 别名
duplicates必须是 CTE 名,不能写成基表名,否则某些数据库(如 SQL Server)会报错 -
id字段只是选出来便于调试,实际删重不依赖它;但如果表里真有id,建议在ORDER BY里加上(比如ORDER BY id)来保证顺序确定性 - 执行前务必先用
SELECT * FROM duplicates WHERE rn > 1预览将删哪些行
MySQL 5.7 或不支持可更新 CTE 的场景
MySQL 5.7 不支持 DELETE FROM (CTE),得绕道:用 JOIN 自关联或临时表。最稳妥的是用自连接 + 子查询排除最小 ID(假设你有一列隐含唯一性的字段,比如 id 或时间戳):
DELETE t1 FROM users_no_pk t1 INNER JOIN users_no_pk t2 ON t1.name = t2.name AND t1.email = t2.email AND t1.created_at < t2.created_at;
这语句含义是:“删掉所有存在另一行和它字段相同、且时间更晚的记录”——等价于只留每组最新的一条。但它依赖 created_at 能严格区分先后;如果时间完全一样,就得靠 id:
- 若表有自增
id,改用t1.id ,并确保 <code>id是插入顺序代理 - 如果没有可靠排序字段,就必须先加一列(如
ALTER TABLE ... ADD COLUMN _rowid INT AUTO_INCREMENT FIRST),再基于它去重——但这已超出纯 SQL 范畴,涉及 DDL 变更 - 千万避免写
DELETE FROM ... WHERE (a,b) IN (SELECT a,b FROM ... GROUP BY a,b HAVING COUNT(*) > 1),这种写法无法指定留哪一行,且 MySQL 可能报错 “You can't specify target table for update in FROM clause”
删重后要不要加主键或唯一约束?
删完只是清除了当前重复,不加约束,下次 INSERT 还会重复。强烈建议紧接着加唯一索引:
CREATE UNIQUE INDEX idx_unique_name_email ON users_no_pk (name, email);
这条命令在 PostgreSQL/MySQL/SQL Server 都适用。注意两点:
- 建索引前确认数据已真正去重,否则会失败并提示重复键冲突
- 如果业务上允许
name为空,而数据库又把多个NULL视为相等(如 PostgreSQL),那唯一索引会拒绝插入第二条NULL值——这时得用部分索引或业务层控制
没主键的表就像没锁的门,删一次重复只是修了次门板,不装锁,风一吹又开。

















