最稳方案是用 ROW_NUMBER() + CTE 标序号再删:先用 CTE 计算每组重复数据的行号,再在 DELETE 中引用,确保安全、可控、可读;PARTITION BY 和 ORDER BY 必须严格匹配业务逻辑,避免漏删或误删。

用 ROW_NUMBER() + CTE 是唯一安全可行的写法
PostgreSQL 不允许在 DELETE 语句中直接嵌套窗口函数,所以你不能写 DELETE FROM t WHERE rn > 1 这种带 ROW_NUMBER() 的子查询。必须用 WITH CTE 先算出行号,再在 DELETE 中引用结果——这是硬性语法限制,不是风格建议。
常见错误是试图把 ROW_NUMBER() 塞进 IN 或 NOT IN 子查询里,PostgreSQL 会直接报错:ERROR: window functions are not allowed in this context。
- CTE 必须包含完整字段(至少含主键或唯一标识列),否则后续无法精准定位要删的行
-
PARTITION BY列必须和业务定义的“重复”逻辑完全一致,比如按email判重就不能漏掉email IS NULL的情况 - 如果表没有主键,可用
ctid临时替代,但仅限一次性清理,不可用于生产逻辑
ORDER BY 决定留哪条,NULL 处理必须显式声明
窗口函数里的 ORDER BY 不只是排序,它决定了 ROW_NUMBER() = 1 是哪一条——也就是你要保留的那条。想留最新记录就写 create_time DESC,想留最早就写 ASC;但只要涉及时间、ID 等可能为 NULL 的字段,就必须加 NULLS LAST 或 NULLS FIRST,否则 PostgreSQL 默认行为因版本而异,容易导致结果不一致。
- 例如:
ORDER BY create_time DESC NULLS LAST表示:非空时间倒序排,所有NULL排在最后(不会被标为rn = 1) - 如果判重字段本身允许
NULL(如email),PARTITION BY email会把所有email IS NULL的行归为同一组——这通常不是你想要的,应提前过滤:WHERE email IS NOT NULL - 别依赖默认
NULLS行为,PostgreSQL 15 默认仍是NULLS LAST,但显式写出更安全
别碰 ctid 方案,除非你明确接受随机保留
用 ctid 配合 GROUP BY 删除(如 DELETE FROM t WHERE ctid NOT IN (SELECT MIN(ctid) FROM t GROUP BY x))看似简洁,但它有三个硬伤:一是 ctid 只反映物理存储位置,和插入顺序无关,同一组重复数据中哪条被留下完全不可控;二是大数据量下性能极差,70 万行实测耗时超 30 秒;三是语义模糊,后续维护者根本看不出你留的是“最早”还是“最新”,甚至看不出依据哪个字段判重。
- PostgreSQL 15 仍不支持在
DELETE中用ctid做业务逻辑判断,它只适合调试或临时脚本 - 如果你真需要“任意留一条”,也请改用
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ctid),至少语义清晰 -
ctid在 VACUUM 后会变化,该语句不可重复执行
NOT IN 遇到 NULL 就静默失效,务必改用 NOT EXISTS
用 NOT IN 做去重删除时,只要子查询或主查询中任一端出现 NULL,整个条件就变成 UNKNOWN,等价于 FALSE,结果就是——一条都不删,且不报错、无提示。这个坑非常隐蔽,尤其当判重字段(如 email)允许为空时极易触发。
- 正确做法是换成
NOT EXISTS,它对NULL安全:WHERE NOT EXISTS (SELECT 1 FROM ranked r WHERE r.id = t.id AND r.rn = 1) - 或者彻底规避:在 CTE 和主查询中都加
WHERE email IS NOT NULL - 别信“我数据里没 NULL”,查一下
SELECT COUNT(*) FROM t WHERE email IS NULL花不了两秒
实际执行前一定先跑一遍 SELECT 验证 CTE 输出,重点看 rn = 1 的是否真是你想留的那条——窗口函数逻辑写错比 SQL 语法错更难排查。










