广告位 · 页头(后台-模块管理-新建模块后替换此区域)

PostgreSQL中如何高效删除重复数据并保留一条_利用CTID和SQL子查询

PostgreSQL的每张表都包含一个系统列ctid,它记录的是行在物理存储中的位置,形式为(页码, 行偏移)。即使两条记录的业务字段完全一致,只要它们占用不同的物理位置,ctid就不同。这个特征在缺少主键、需要清理重复数据的场景下尤其有用。你可以先把要删除的行通过ctid标记出来,再执行删除,不会误伤同一组中需要保留的那一行。

PostgreSQL中如何高效删除重复数据并保留一条_利用CTID和SQL子查询

不过要注意,ctid并不是一个稳定的业务标识。执行VACUUM FULL、重建表或者某些更新操作后,行的物理位置可能会发生变化。因此,ctid适合在一次删除事务中作为定位依据,不适合长期存储在业务表里当作主键使用。理解了这一点,再看下面的删除方案会更稳妥。

一、先理解重复数据和ctid的作用

删除重复数据的难点通常不在删除本身,而在如何准确圈定哪些行是重复的、哪些行需要保留。比如订单表中,order_no 和 customer_id 两个字段组合起来应该唯一,但由于历史导入或并发问题,出现了一模一样的多行。此时没有主键可以区分它们,普通的 DELETE ... WHERE id = ? 无法定位。

PostgreSQL 的 ctid 恰好能解决这个问题。可以先查询一下重复数据的分布情况,顺便看看每条重复行对应的物理位置:

SELECT order_no,
       customer_id,
       COUNT(*) AS duplicate_count
FROM orders
GROUP BY order_no, customer_id
HAVING COUNT(*) > 1;

这段SQL只返回重复组的统计信息。若要观察每组里每一行的差异,可以在SELECT中再加上ctid,例如SELECT ctid, order_no, customer_id FROM orders WHERE (order_no, customer_id) IN (...)。你会看到,同一组内的多行拥有不同的ctid,这就为后续删除提供了精确的区分依据。

二、利用子查询定位要删除的ctid

最直观的做法是使用窗口函数生成行号,再筛选出行号大于1的记录。下面假设保留每组中ctid最小的那一行,其余全部删除:

DELETE FROM orders
WHERE ctid IN (
    SELECT ctid
    FROM (
        SELECT ctid,
               ROW_NUMBER() OVER (
                   PARTITION BY order_no, customer_id
                   ORDER BY ctid
               ) AS rn
        FROM orders
    ) t
    WHERE t.rn > 1
);

这个写法的好处是逻辑清晰,PARTITION BY 指定了判断重复的业务键,ORDER BY ctid 则决定了每组内部的保留优先级。如果你想保留最新记录,可以把排序字段换成 created_at DESC,这样行号为1的就是最新行,其余旧行会被删除。

如果不想使用窗口函数,也可以用自连接或EXISTS 子查询来表达同样的语义。下面这个语句通过自连接删除所有不是最小ctid的行:

DELETE FROM orders o1
USING orders o2
WHERE o1.order_no = o2.order_no
  AND o1.customer_id = o2.customer_id
  AND o1.ctid > o2.ctid;

这种方式在数据量不大时足够简单,但自连接可能产生较大的中间结果。对于几十万行以上的表,窗口函数配合子查询往往更可控。无论选择哪种写法,执行前都建议在事务中先运行对应的SELECT 版本,确认圈定的ctid 范围没有异常。

三、大表删除的性能优化与分批处理

如果直接对百万行级别的表执行上面的DELETE,可能会带来三个问题:长事务持有的锁影响并发写入、大量死元组导致表膨胀、单条SQL运行时间过长难以中断。更推荐的做法是先把待删除的ctid 物化到临时表,再分批执行删除。

下面的语句先创建临时表保存所有需要删除的ctid,再通过关联删除第一批数据:

CREATE TEMP TABLE dup_ctids AS
SELECT ctid
FROM (
    SELECT ctid,
           ROW_NUMBER() OVER (
               PARTITION BY order_no, customer_id
               ORDER BY ctid
           ) AS rn
    FROM orders
) t
WHERE t.rn > 1;
CREATE INDEX ON dup_ctids (ctid);
DELETE FROM orders
USING dup_ctids
WHERE orders.ctid = dup_ctids.ctid
  AND orders.ctid IN (
      SELECT ctid
      FROM dup_ctids
      LIMIT 1000
  );

上面最后一条语句中的 LIMIT 1000 控制了单批删除规模,实际使用时可以根据硬件和并发情况调整。若要全自动循环,可以在应用层遍历临时表中的ctid,每次取出一千条传给删除语句,或者用存储过程配合游标逐批提交。关键原则是:不要让一条DELETE 处理过多行,也不要让整个清理过程包在一个大事务里。

删除完成后,记得执行 VACUUM orders; 和 ANALYZE orders;。前者回收死元组占用的空间,后者更新统计信息,帮助优化器生成更准确的执行计划。如果删除比例很高,也可以考虑重建表,例如通过 CREATE TABLE new_orders AS SELECT DISTINCT ON ... 再用新表替换旧表,但这需要短暂停机或处理依赖关系,适用于维护窗口充足的场景。

四、不同保留策略和常见误区的补充

上面的例子都是保留每组中ctid 最小的行,但很多业务场景希望保留最新插入或最新更新的记录。这时候不能按ctid 排序,因为ctid 只反映物理写入位置,不代表业务时间。应该使用业务时间字段,例如 created_at 或 updated_at,并在 ORDER BY 中显式指定。

下面的模板保留了每组中 created_at 最大的记录,created_at 相同时再按 ctid 兜底,避免仍有并列:

DELETE FROM orders
WHERE ctid IN (
    SELECT ctid
    FROM (
        SELECT ctid,
               ROW_NUMBER() OVER (
                   PARTITION BY order_no, customer_id
                   ORDER BY created_at DESC, ctid DESC
               ) AS rn
        FROM orders
    ) t
    WHERE t.rn > 1
);

另一个容易忽略的点是,重复定义必须与业务唯一约束一致。如果表中已经存在唯一索引,重复数据通常无法写入;但如果是清理历史数据,应该先明确哪些字段组合可视为重复,避免把同一天同一客户的不同订单误删。必要时可以先在测试库或事务中执行SELECT 查询,导出将被删除的行进行人工抽查。若删除条件写反,可能导致保留行不是预期的那一条,所以建议每次执行前先在事务中运行,确认影响行数后再提交。

最后,ctid 不能跨事务长期使用。如果删除操作分成多个事务,并在中途执行了 VACUUM FULL 或表重写,临时表中保存的ctid 可能已经失效。对于分批删除场景,应确保在删除过程中不要对同一张表执行会导致物理行移动的维护操作。

广告位 · 文章底部(后台-模块管理替换此区域)