最稳妥方式是用row_number()配合cte为重复组编号,保留rn=1行并关联主键删除;必须指定partition by(覆盖所有重复判定列)和order by(决定保留最新或最早),且不可在where中直接使用窗口函数。

用 ROW_NUMBER() 给重复行打序号再删
直接删重复行最稳妥的方式,是先用 ROW_NUMBER() 为每组“逻辑上相同”的记录分配序号,再保留序号为 1 的那行,其余全删。关键在于 PARTITION BY 列必须准确反映“重复”的定义——比如你认为 email 和 name 都一样才算重复,那就写 PARTITION BY email, name。
注意:不能在 DELETE 语句里直接嵌套窗口函数(MySQL 8.0+、PostgreSQL、SQL Server 支持 CTE + 窗口函数组合删除;SQLite 不支持;旧版 MySQL 完全不支持)。实操要分两步:
- 把带
ROW_NUMBER()的结果放进 CTE 或子查询 - 用主键或唯一标识去关联删除,例如:
WITH dupes AS ( SELECT id, ROW_NUMBER() OVER (PARTITION BY email, name ORDER BY created_at DESC) AS rn FROM users ) DELETE FROM users WHERE id IN (SELECT id FROM dupes WHERE rn > 1); -
ORDER BY很重要——它决定哪条被保留。按created_at DESC就留最新的,ASC就留最早的
为什么不用 COUNT(*) OVER 直接删?
COUNT(*) OVER (PARTITION BY ...) 只能告诉你某行属于一个几条的重复组,但无法区分“该留哪条”。比如三行完全相同,它们的 COUNT 都是 3,删掉 COUNT > 1 的话会全删光。而 ROW_NUMBER() 能给出唯一顺序,这是删除动作可执行的前提。
常见误操作:
-- ❌ 错!这会把所有重复组里的行全删掉(包括想保留的那条)
DELETE FROM users
WHERE id IN (
SELECT id FROM (
SELECT id, COUNT(*) OVER (PARTITION BY email) cnt
FROM users
) t
WHERE cnt > 1
);
所以别贪快跳过序号生成这步——没序号,就等于没锚点。
不同数据库对 DELETE ... USING 和 CTE 的支持差异
语法看似差不多,实际执行权限和写法限制很不一样:
- PostgreSQL:支持
DELETE ... USING直接连 CTE,也支持标准 CTE 删除,推荐用USING写法更直观DELETE FROM users u USING ( SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) rn FROM users ) dupes WHERE u.id = dupes.id AND dupes.rn > 1; - SQL Server:CTE 后跟
DELETE是合法的,但 CTE 必须只引用一张表(不能含 JOIN) - MySQL 8.0+:必须用 CTE + 子查询方式,且
DELETE不能直接删 CTE,得通过主键关联 - SQLite:不支持窗口函数用于 DML,只能先查出要删的 ID,再用循环或临时表处理
删之前务必加 WHERE 条件限制范围
线上表一旦执行删除,不可逆。哪怕测试过 SQL,也要加防护:
- 第一次跑,先把
DELETE换成SELECT *,确认选出的确实是该删的行 - 加上时间范围或状态过滤,例如
AND created_at ,避免误伤近期数据 - 如果表没主键,靠
ROW_NUMBER()生成的序号无法反向定位具体哪行,这种场景必须先补主键或用ctid(PostgreSQL)等物理标识辅助 - 大表删除建议分批,比如每次删 1000 行,用
LIMIT(PostgreSQL/MySQL)或TOP(SQL Server)控制
真正麻烦的不是语法,而是“重复”的定义是否覆盖了业务边界——比如手机号空值算不算重复?大小写不同的邮箱要不要归一?这些得在 PARTITION BY 里提前处理好,否则窗口函数只会忠实地按字面匹配。










