最稳妥的去重方法是用ROW_NUMBER()配合CTE或子查询,保留每组中排序后rn=1的行,务必指定ORDER BY(如created_at、id),避免行为不可控。

用 ROW_NUMBER() 标记重复行再删
窗口函数本身不能直接删数据,必须配合子查询或 CTE 把“要留哪一条”明确下来。最稳的路是:给每组重复数据编号,只保留 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) 结果为 1 的那条。
常见错误是漏写 ORDER BY——不指定顺序时,ROW_NUMBER() 行为不确定,可能每次跑结果不同,删掉的到底是哪条根本不可控。
-
ORDER BY建议用主键或时间字段(如created_at),确保逻辑可预期 - 如果按业务字段排序(比如
status),注意NULL值默认排最前,可能意外留下脏数据 - 别在
WHERE里直接用窗口函数,会报错WINDOW function is not allowed in WHERE clause
示例(删掉 email 相同但非最新的一条):
DELETE FROM users
WHERE id NOT IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, id DESC
) AS rn
FROM users
) t
WHERE rn = 1
);用 ctid 或 %%physloc%% 快速去重(PostgreSQL / SQL Server)
当表没主键、没唯一标识,又急需清理时,得靠物理位置信息定位行。PostgreSQL 用 ctid,SQL Server 可用 %%physloc%%(需启用 DBCC TRACEON(3604),但生产环境慎用)。
这类方案快,但危险:它不看业务语义,只按存储顺序删,容易误伤。尤其在并发写入频繁的表上,ctid 可能刚查完就被 vacuum 重用。
- 务必先
BEGIN; SELECT ctid, * FROM table WHERE ... LIMIT 10;看一眼实际分布 - 删除时加
WHERE ctid NOT IN (...)比用子查询更安全,避免计划器优化出错 - MySQL 没等价物,别硬套;想类似效果得先加临时自增列再操作
避免 DISTINCT ON 被当万能解(仅 PostgreSQL)
DISTINCT ON 看起来简洁,但它只保证“每组第一个”,不提供删除能力,也不能和 DELETE 直接连用。有人写 DELETE ... USING (SELECT DISTINCT ON (...) FROM ...),结果发现删得比预期多——因为 DISTINCT ON 的“第一行”和你想象的排序不一致。
根本问题是:它不显式暴露排序依据,容易掩盖逻辑漏洞。比如 DISTINCT ON (email) ORDER BY email, updated_at DESC,若 updated_at 有重复,数据库会任意选一条,你无法控制。
- 用
DISTINCT ON前,先确认ORDER BY后缀足够唯一(比如追加id) - 它适合导出清洗后数据,不适合原地删;真要删,还是回到
ROW_NUMBER()+ CTE 更可靠 - 其他数据库(MySQL/SQL Server)没有这个语法,别写完才发现没法迁移
大表删重复数据卡死?先建索引再动手
没索引时,PARTITION BY 字段上的 GROUP BY 或 JOIN 会全表扫描,100 万行以上基本卡住。不是语法问题,是执行计划崩了。
重点不是“要不要索引”,而是“索引字段必须覆盖 PARTITION BY + ORDER BY 的前导列”。比如按 (email, created_at) 分组排序,索引就得是 ON users(email, created_at),反过来就不生效。
- 加索引前先
EXPLAIN ANALYZE对比执行计划,看是否从 Seq Scan 变成 Index Scan - 线上加索引记得用
CONCURRENTLY(PostgreSQL),否则锁表 - 删完记得检查索引膨胀,必要时
VACUUM FULL或重建
真正麻烦的从来不是怎么写 SQL,而是删完之后,外键、触发器、物化视图有没有跟着失效——这些不会报错,但数据一致性已经悄悄坏了。

















