最稳方案是用row_number() + cte一次删净,核心是按指定顺序编号后删除rn>1的行,需建复合索引并优先使用主键而非ctid。

用 ROW_NUMBER() + CTE 一次删净,最稳
对中等以上数据量(10万+行),这是目前最通用、可读性好且性能可控的方案。核心是给每组重复记录编号,只删 rn > 1 的行。
- 必须指定
ORDER BY—— 否则ROW_NUMBER()结果不稳定,可能每次删掉不同行;想留最新的一条就按时间字段倒序,想留 ID 最小的就按主键正序 - 别直接在
DELETE里嵌套窗口函数:PostgreSQL 不允许DELETE ... WHERE id IN (SELECT ... ROW_NUMBER())这种写法(会报错“subquery must return only one column”) - 正确写法是用 CTE 先算出要删的
ctid或主键,再关联删除:WITH duplicates AS ( SELECT ctid, ROW_NUMBER() OVER ( PARTITION BY col1, col2 ORDER BY created_at DESC ) AS rn FROM my_table ) DELETE FROM my_table WHERE ctid IN (SELECT ctid FROM duplicates WHERE rn > 1); - 如果表有主键(比如
id),把ctid换成id更安全;ctid在 VACUUM 或并发写入时可能变化,主键更可靠
大表慎用 NOT IN (SELECT ... GROUP BY)
这个写法看着简洁,但实际执行时容易卡死或超时,尤其在百万级表上。
- 典型错误写法:
DELETE FROM t WHERE id NOT IN (SELECT MIN(id) FROM t GROUP BY col1, col2)—— 当子查询结果含NULL时整条语句返回空集,一条都不删;且子查询无法走索引,全表扫描 + 嵌套循环,6600万行实测耗时超 67 秒 - 即使加了
WHERE ... IS NOT NULL,优化器仍大概率选错执行计划,GROUP BY阶段内存压力大,可能触发磁盘临时文件 - 替代思路:先用
CREATE TABLE AS SELECT DISTINCT ...建新表,再重命名交换,比原地DELETE快得多,但需要额外磁盘空间和停写窗口
DELETE USING 适合有自增主键的小到中型表
利用自连接匹配重复行,逻辑直白,对几十万行以内效果不错,但要注意保留策略方向。
- 保留最大
id(删小 ID):DELETE FROM t a USING t b WHERE a.id - 保留最小
id(删大 ID):DELETE FROM t a USING t b WHERE a.id > b.id AND a.col1 = b.col2 AND a.col2 = b.col2—— 注意这里字段对应别写反 - 没主键?不能用这个方法 ——
USING依赖可比较的唯一标识,ctid虽然可用,但并发下不保险,不建议生产环境这么干 - 执行前务必加
EXPLAIN ANALYZE看是否走了索引;如果col1, col2上没复合索引,会退化成笛卡尔积,N² 复杂度直接拖垮数据库
ctid 方案快但有隐藏风险
对无主键、无业务唯一键的表,ctid 是唯一物理地址,能快速定位行,但仅限紧急修复或测试环境。
- 常见写法:
DELETE FROM t WHERE ctid NOT IN (SELECT MIN(ctid) FROM t GROUP BY col1, col2) -
ctid不稳定:VACUUM FULL、CLUSTER、或高并发 UPDATE/INSERT 后,同一行的ctid可能改变;这意味着你查出来的“要保留的ctid”可能在DELETE执行时已失效 - 无法跨事务保证一致性:CTE 中引用
ctid时,若其他会话正在修改该行,可能删错或漏删 - 真正安全的做法是:先用
SELECT ctid, ...把待删ctid导出到临时表,再用DELETE ... WHERE ctid = ANY(ARRAY[...])批量删 —— 但这就失去“一键”的便利性了










