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

用 ROW_NUMBER() + CTE 标序号再删,最稳
PostgreSQL 从 8.4 就支持窗口函数,ROW_NUMBER() 是去重删除的核心工具。它不依赖主键,只看你指定的「重复判断字段」和排序逻辑——比如按 create_by 分组、按 create_time DESC 排,就能精准标出每组最新那条是第 1 名。
关键不是“怎么写”,而是“怎么写才安全”:
- 必须用
WITHCTE 包一层,不能在DELETE里直接嵌套窗口函数(PostgreSQL 不允许) -
PARTITION BY的列要严格对应你定义“重复”的业务逻辑,少一个就漏删,多一个就误删 -
ORDER BY决定留哪条:想留最新的就DESC,留最早的就ASC;如果排序字段有NULL,记得加NULLS LAST或NULLS FIRST显式控制
示例(保留每组 email 中 id 最小的):
WITH ranked AS (
SELECT id, email,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY id ASC) AS rn
FROM people
)
DELETE FROM people
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
别直接 DELETE FROM ... WHERE ctid IN (...),性能差还难读
有人图省事用 ctid 做物理行标识去删,比如:
DELETE FROM people WHERE ctid NOT IN ( SELECT MIN(ctid) FROM people GROUP BY email );
这看似不用写排序逻辑,但问题明显:
-
ctid是物理位置标识,不反映业务意义;同一组重复数据中哪条“更早插入”无法保证,可能随机留一条 -
GROUP BY+MIN(ctid)在大数据量下会触发全表扫描+哈希分组,70 万行实测耗时超 30 秒(见知识库数据) - 语句不可读:后续维护者根本看不出你留的是“最早”还是“最新”,也看不出依据哪个字段判重
除非你明确只要“任意留一条且不在乎顺序”,否则别碰 ctid 方案。
NOT IN 子查询在有 NULL 时会静默失效
这个坑非常隐蔽:如果用来判重的字段(比如 email)允许为 NULL,下面这种写法会漏删所有含 NULL 的重复组:
DELETE FROM people WHERE email NOT IN ( SELECT MIN(email) FROM people GROUP BY email );
原因:NOT IN (..., NULL) 整个表达式结果恒为 UNKNOWN,被当作 FALSE 处理,导致匹配不到任何行。
绕过方法只有两个:
- 提前过滤掉
NULL:WHERE email IS NOT NULL加到子查询和主查询里 - 改用
NOT EXISTS,它对NULL安全:
DELETE FROM people p1
WHERE EXISTS (
SELECT 1 FROM people p2
WHERE p2.email = p1.email
AND p2.id < p1.id
AND p2.email IS NOT NULL
);
大表删重前务必先建索引,不然删着删着就锁表
窗口函数本身不依赖索引,但 DELETE 的最终执行阶段需要定位并锁定要删的行。如果 PARTITION BY 字段没索引,PostgreSQL 只能走顺序扫描,70 万行的表可能锁表数分钟——期间所有 INSERT/UPDATE 都被阻塞。
正确做法:
- 在去重字段组合上建索引,例如:
CREATE INDEX idx_people_email ON people(email); - 如果是多字段判重(如
name, email),建联合索引:CREATE INDEX idx_people_name_email ON people(name, email); - 索引建完跑一遍
ANALYZE people;更新统计信息,让查询计划器选对路径
没索引的删重操作,表面看 SQL 跑得通,实际是拿生产稳定性换来的“省事”。

















