ctid可用于临时去重,因其是行的物理位置标识且天然唯一,但仅适用于一次性清理、无并发写入场景;更新、vacuum或null处理不当会导致误删漏删。

直接用 ctid 配合子查询删除重复数据是可行的,但必须清楚它只适合一次性清理、无并发写入、且不依赖物理位置稳定性的场景;否则容易误删或漏删。
为什么 ctid 能用来去重?
ctid 是 PostgreSQL 每行记录的物理位置标识(块号+偏移量),在未执行 VACUUM FULL 或表被重建前,同一行的 ctid 不变。它天然唯一,且比主键更“底层”,即使表没主键也能用。
但它不是逻辑唯一键:同一行被更新后会产生新 ctid,旧版本可能还留在磁盘上(直到被清理);VACUUM 后 ctid 可能重排。所以它只适合“当前快照下临时去重”。
- 适合场景:导入脏数据后的清洗、测试库快速去重、无主键小表
- 不适合场景:生产环境高频更新表、有复制或逻辑订阅、需要保留“业务上最新”的那条(
ctid小 ≠ 插入早或时间新)
DELETE ... WHERE ctid NOT IN (SELECT MIN(ctid) ...) 的坑
这个写法看似简洁,但实际执行时容易出错:
-
NOT IN遇到子查询结果含NULL会整个返回空集,导致一条都不删 —— 如果分组字段允许为NULL,GROUP BY后MIN(ctid)仍正常,但若你误写了WHERE col IS NOT NULL之类逻辑,就可能引入NULL - 子查询里
GROUP BY字段必须和判定重复的字段完全一致,少一个就变成按更粗粒度分组,多一个就可能把本不该去重的行拆开 - 大表执行时,
SELECT MIN(ctid) FROM t GROUP BY a,b会触发全表扫描 + 哈希分组,内存压力大;若没索引,速度很慢
正确写法示例(以 users 表按 email 去重为例):
DELETE FROM users WHERE ctid NOT IN ( SELECT MIN(ctid) FROM users WHERE email IS NOT NULL -- 显式排除 NULL,避免 NOT IN 失效 GROUP BY email );
想保留“最新插入”或“最新修改”的那条?别只信 ctid
ctid 小通常意味着插入早,但不绝对:批量 COPY、事务回滚、热更新(heap-only tuples)都会让新插入的行获得更小的 ctid。真正要留“最新”的,必须依赖业务时间字段,比如 created_at 或 updated_at。
这时应该用窗口函数,而不是 ctid:
WITH ranked AS (
SELECT id, ctid,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY updated_at DESC, id DESC
) AS rn
FROM users
WHERE email IS NOT NULL
)
DELETE FROM users
WHERE ctid IN (SELECT ctid FROM ranked WHERE rn > 1);
- 这里用
ctid作删除锚点,但排序依据是updated_at—— 兼顾语义准确与执行效率 - 加
id DESC是为了在时间相同时有确定性排序(避免不同执行计划选不同行) - 务必在
email和updated_at上建复合索引,否则OVER窗口计算会很慢
真正麻烦的不是语法,而是确认“重复”的定义是否覆盖了所有 NULL 组合、是否要忽略大小写、是否要考虑空格;这些逻辑一旦写进 GROUP BY 或 PARTITION BY,就很难靠 ctid 补救。










