news 2026/9/13 10:17:15

PostgreSQL重复数据处理:从检测到安全删除的实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL重复数据处理:从检测到安全删除的实战指南

最近又被群里的小伙伴问到“PostgreSQL 怎么删重复数据”,说实话这个问题我前前后后帮人处理过不下十次。乍一听很简单,就是查找加删除嘛,可真到了生产环境,你会发现事情远没有 SQL 语句看起来那么单纯——性能、锁、事务大小、数据备份、甚至 NULL 值的坑,随便一个都够你折腾半天。

这篇文章我打算用一套完整的案例,从“怎么查”到“怎么删”再到“删完怎么办”,把 PostgreSQL 里去重这件事讲透。如果你平时用的是 MySQL,这里的思路同样有参考价值,但 PostgreSQL 里有几个特性(比如 ctid、MVCC)是它独有的,处理逻辑会不太一样。无论你是后端开发、数据分析师,还是刚入门 PostgreSQL 的运维同学,这篇文章都能给你一套可以直接抄作业的套路。

1. 动手之前,先搞清楚重复数据到底有哪几种

很多人上来就写 SQL,结果删完之后发现数据不对——不是删多了就是删错了。这往往不是因为 SQL 写错,而是因为没有先区分“重复”的类型。我习惯把重复数据分成三种,处理方式完全不同。

1.1 完全重复:整行所有字段都一模一样

这种是最“老实”的重复,整行数据每个字段的值都完全相同。常见于反复导入同一份文件、两个表做 UNION 之后没去重、或者定时任务重复跑批把相同数据又插了一遍。

完全重复处理起来很简单,每一组重复行保留任意一条即可。难点只在于怎么高效地把重复的行挑出来,这个后面会展开。

1.2 部分字段重复:业务键相同但其他字段不同

这是实际工作中最常遇到的情况。比如用户表里,同一个手机号出现了两次,但昵称、注册时间可能不一样;订单表里,同一个订单编号出现了两条记录,但状态字段不同。

这种重复不能简单“去重”,必须先确定业务上的唯一键是什么——对用户表来说是手机号,对订单表来说是订单编号——然后决定每组保留哪一条(比如按时间保留最新的一条,或者按 id 保留最大/最小的一条)。

这里容易出问题的点是:如果除了业务键以外,其他字段也有差异,直接 DELETE 可能把有用的信息丢掉。我一般的做法是先跟业务方确认,到底是以哪几个字段作为判定重复的唯一依据,再动手。

1.3 空值导致的“伪重复”

PostgreSQL 在唯一约束里有个容易踩坑的小特性:NULL 值不参与唯一性判断。也就是说,你在某一列上建了唯一索引,那这一列里多个 NULL 是可以共存的。但如果你用 GROUP BY 去查,多个 NULL 又会被当成一组。

举个例子:订单表里有个字段叫coupon_code(优惠券编码),对没使用优惠券的订单来说这个字段是 NULL。如果业务上要求每个优惠券编码只能使用一次,你给这个字段建了唯一索引,那 NULL 的订单可以随便插,但你要是拿 GROUP BY coupon_code 来查重复,就会把一堆 NULL 当成“重复”。

如果你没有意识到这个空值特性,很可能会误删大量正常数据。所以查找重复之前,先想清楚:业务上的唯一性到底包不包括空值。

提示:PostgreSQL 里判断“是否重复”有一套自己的逻辑,和 MySQL 的默认行为并不完全一样。建议先在小表上试跑 SELECT,确认结果无误后再执行 DELETE。

2. 定位重复数据:从聚合到窗口函数,一次学透

确定重复类型之后,下一步就是把重复的行找出来。PostgreSQL 查重复数据最常用的是两种思路:GROUP BY 聚合和窗口函数。两者各有优劣,下面我把每种都写出来,你可以按实际场景选。

2.1 GROUP BY + HAVING:快速确认哪些字段有重复

先造一张测试表,方便后面所有例子直接跑。

CREATE TABLE users ( id SERIAL PRIMARY KEY, phone VARCHAR(20), name VARCHAR(50), created_at TIMESTAMP DEFAULT now() ); INSERT INTO users (phone, name, created_at) VALUES ('13800001111', '张三', '2024-01-01 10:00:00'), ('13800001111', '张三', '2024-01-02 09:30:00'), ('13800002222', '李四', '2024-01-03 08:00:00'), ('13800002222', '李四', '2024-01-04 12:00:00'), ('13800002222', '李四', '2024-01-05 18:00:00'), ('13800003333', '王五', '2024-01-06 20:00:00');

现在用最经典的 GROUP BY 写法找到重复的电话号码:

SELECT phone, COUNT(*) FROM users GROUP BY phone HAVING COUNT(*) > 1;

结果一眼就能看出13800001111重复了 2 次,13800002222重复了 3 次。

这种方法的优点是简单、直观、执行效率也还行。缺点是它只能告诉你“哪些值重复了”,不能直接告诉你“具体是哪几行重复”。如果你想看重复行的完整记录,还得再把这些 phone 值拿回去关联一次:

SELECT * FROM users WHERE phone IN ( SELECT phone FROM users GROUP BY phone HAVING COUNT(*) > 1 ) ORDER BY phone, id;

对于数据量不大的表,这种两层查询完全够用。但如果你要在大表上做去重,频繁的子查询会消耗不少资源,这时候就该上窗口函数了。

2.2 ROW_NUMBER() 窗口函数:直接把每一行标记上序号

窗口函数是 PostgreSQL 里处理去重场景的利器。它最大的好处是可以在不丢失原始字段信息的情况下,对每一行计算一个“组内序号”,然后再根据序号筛选。

SELECT *, ROW_NUMBER() OVER ( PARTITION BY phone ORDER BY id ) AS rn FROM users;

来看下结果逻辑:PARTITION BY phone 相当于把相同电话号码的记录分到同一组,ORDER BY id 决定组内从 1 开始编号的顺序。这样每组里的第一条(rn = 1)就是保留项,rn > 1 就是要删除的重复项。

如果你想把“保留最新一条”改成“保留最早一条”,只要调整 ORDER BY 的方向即可:

ROW_NUMBER() OVER ( PARTITION BY phone ORDER BY id DESC ) AS rn

窗口函数的优势是既能看到原始行数据,又能同时得到重复标记。不过要注意,直接在几十亿行的大表上做窗口函数排序,内存和临时文件的开销都不小,后面删数据时我会专门讲怎么分批处理。

2.3 不能只看重复行数,还要做一次规模评估

再分享一个习惯:动手删之前,我几乎一定会先跑一条统计,看看重复数据到底占多大比例。如果重复率很低,直接小批量删除问题不大;如果重复率特别高,比如一张 1 亿行的表里 30% 都是重复,那就要考虑重建表而不是逐行删除。

SELECT COUNT(*) AS total_rows, COUNT(DISTINCT (phone, name)) AS distinct_rows, COUNT(*) - COUNT(DISTINCT (phone, name)) AS duplicate_rows FROM users;

确认重复规模和分布之后,再去选删除方案。这一步其实花不了多少时间,但能避免很多后患。

注意:COUNT(DISTINCT (col1, col2)) 这种写法 PostgreSQL 支持,MySQL 也支持,它可以把多列拼接成一个复合值去重。不过如果列里面含 NULL,统计结果可能会有偏差,这点心里有数就行。

3. 删除重复数据:三种主流方案怎么选

定位完成之后,真正动手删除的时候,方案选择比想象中更重要。我实际用下来,主流方案大致有三种:临时表重建法、ctid 删除法、窗口函数一步到位删除法。各有各的适用场景,下面逐一拆开讲。

3.1 临时表法:最稳,适合数据量大或结构复杂的场景

思路很简单:把去重后的数据放进一张临时表,然后清空原表再插回去。

-- 第一步:创建临时表,保存去重后的数据 CREATE TABLE users_dedup AS SELECT DISTINCT ON (phone) * FROM users ORDER BY phone, id; -- 第二步:确认临时表数据无误后,删除原表(或 TRUNCATE) TRUNCATE TABLE users; -- 第三步:把去重后的数据写回原表 INSERT INTO users SELECT * FROM users_dedup; -- 第四步:清理临时表 DROP TABLE users_dedup;

这里用到了 PostgreSQL 特有的DISTINCT ON (phone)语法,它的作用是按 phone 分组,返回每组里按 ORDER BY 排序后的第一条。这个写法比“子查询 + 窗口函数”更简洁。

临时表法的优点非常突出:即使原表结构复杂、字段很多、有外键约束,也不用担心 DELETE 语句写错导致误删,因为数据先在临时表里躺着,确认无误再回写。缺点是需要额外的存储空间,而且在 TRUNCATE 到 INSERT 之间,表上的数据对业务来说是不可见的,会有短暂的空窗期,必须安排在低峰期操作。

如果你的表有外键被其他表引用,TRUNCATE 可能会因为外键约束而失败,或者级联删除关联数据。这时候我建议你放弃临时表法,改用下面的 ctid 删除法。

3.2 利用 ctid 原地删除:不走索引也能精确删

ctid 是 PostgreSQL 里的一个隐藏物理行标识,它表示每一行在数据页里的物理位置。即使表里没有主键,每一行也一定有一个唯一的 ctid(在正常不移动的情况下)。这特性用来删重复数据非常方便,因为你可以指定“每组保留 ctid 最小的一条(或最大的一条),其余全部删掉”。

DELETE FROM users WHERE ctid NOT IN ( SELECT MIN(ctid) FROM users GROUP BY phone );

这条 SQL 的含义是:按 phone 分组,在每个组里找 ctid 最小的那一行,然后把不属于“每组的 MIN(ctid)”的行全部删除。由于每组最小 ctid 是稳定存在的,所以实际效果就是每组保留一条,其余全删。

这个方案的优点是不需要临时表、不锁全表、可以在线执行,对表结构没有任何额外要求。缺点是逻辑上需要理解 ctid 是什么,而且在大表上NOT IN的性能不够好——子查询返回的行数越多,外层 DELETE 扫描就越慢。

所以 ctid 方案更适合中小表,或者你已经通过前面的 SELECT 确认重复行数量可控的情况。如果你要删除的重复行非常多,可以把它改成“分批删除”的办法:

DELETE FROM users WHERE ctid IN ( SELECT ctid FROM ( SELECT ctid, ROW_NUMBER() OVER (PARTITION BY phone ORDER BY id) AS rn FROM users ) t WHERE t.rn > 1 LIMIT 10000 );

每次只删 1 万行,配合循环或者多次执行,能有效避免长事务和表锁问题。

3.3 一条 DELETE 加窗口函数:代码最优雅,但要控好事务

还有一种比较“炫”的写法,把窗口函数直接嵌在 DELETE 里,一步到位:

DELETE FROM users WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY phone ORDER BY id ) AS rn FROM users ) t WHERE t.rn > 1 );

这条 SQL 会把相同 phone 分组后 rn 大于 1 的所有记录全部删除,每组保留 id 最小的那条。如果你没有 id 字段,可以把id换成ctid

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

这种写法最优雅,但一个 DELETE 会扫描全表,还会对涉及的行加锁。表很大的时候,事务可能执行很久,甚至拖垮主库。我一般只在确认重复数据量占比较低、表规模可控的情况下才会用。

3.4 三种方案对比与选型建议

为了方便你决策,我把三种方案的特点整理成了表格:

方案优点缺点适合场景
临时表法最安全、可控,不会误删需要额外空间,有空窗期数据量大、结构复杂、重复率高
ctid 删除法不需要主键,可在线执行大表 NOT IN 性能一般中小表、重复行数较少
窗口函数 DELETE写法简洁、一步到位全表扫描、锁范围大小表、低峰期操作

选型逻辑总结一下:如果你不确定怎么选,优先用临时表法,它是“下限最高”的方案;如果表特别大且要求在线操作,就用 ctid 或窗口函数结合 LIMIT 分批删;如果数据重复率极高,临时表法几乎是唯一合理的选择。

提示:不管用哪种方案,动手前都建议先执行一遍等价的 SELECT,确认“将要删除的行数”和“将保留的行数”符合预期,再改写成 DELETE 执行。这个习惯能帮你避免绝大多数低级事故。

4. 实战中的坑:为什么删完问题还在

方案选好、SQL 也写完了,很多人以为到这里就结束了。实际上我在实战中踩过不少坑,有些问题甚至是在删除完成之后才暴露出来的。下面这些情况如果你没遇到过,算我输。

4.1 表里明明有主键,为什么还能产生重复数据

这是被问得最多的一个问题。很多人很困惑:users 表不是已经有 id 主键了吗,为什么 phone 还能重复?

原因很简单:主键只保证 id 不重复,并不会自动约束 phone 不能重复。如果你希望 phone 也不重复,需要额外在 phone 上建唯一索引:

CREATE UNIQUE INDEX idx_users_phone ON users (phone);

如果你的业务场景确实需要 phone 唯一,建了这个索引之后,以后再插入重复数据会被数据库直接拒绝,从源头上杜绝重复。但要注意,如果表里现在已经有重复数据,直接建唯一索引会报错,必须先清理干净再建。

还有一种情况是数据是绕过了约束被插进来的,比如导数据时设置了session_replication_role = replica或者临时关闭了触发器,唯一索引也有可能漏掉重复值,所以不要以为有索引就万事大吉。

4.2 删除完之后,表空间为什么没变小

这是最让人抓狂的问题之一。明明 DELETE 删了几千万行,看表里的数据量确实少了,但磁盘空间一点没释放。

原因是 PostgreSQL 的多版本并发控制(MVCC)机制。DELETE 并不会立刻把数据从磁盘上物理抹掉,只是把这些行标记为“已删除”,让后续的事务看不到而已。这些被标记的行仍然占据着原来的磁盘空间,需要等 VACUUM 来清理。

如果你只是想让空间尽快释放出来,可以执行:

VACUUM FULL users;

注意,VACUUM FULL会重写整张表,执行期间会锁表,业务读写都会受影响。所以这个方法只能在低峰期执行。如果只是想让空间能被后续插入复用,不急着还给操作系统,跑一次普通的VACUUM就够了,成本低很多。

另一个容易忽略的点是:如果删完之后表上的索引也变得很大,比如索引膨胀严重,你还需要重建索引:

REINDEX TABLE users;

索引膨胀会导致查询变慢,这在处理完大量删除后非常常见。我处理大表去重之后,一般会把 VACUUM FULL 和 REINDEX 一起纳入操作清单。

4.3 大批量删除时的死锁和锁等待

如果你按 3.2 或 3.3 的做法在大表上执行 DELETE,很容易遇到锁等待超时,甚至死锁。这是因为 DELETE 的扫描顺序和数据页的物理顺序不完全一致,多个并发事务去删不同行的时候,可能会导致互相等待锁。

我自己的一个习惯是:如果删除任务会持续较长时间,就分批执行,每次提交一个事务,并在两次删除之间停顿一小会。这样做的好处是事务不会太大,锁的持有时间短,其他业务 SQL 不容易被长时间阻塞。

一个简单的分批循环写法(在 psql 里可以用 DO 块实现):

DO $$ DECLARE batch_count INT; BEGIN LOOP DELETE FROM users WHERE ctid IN ( SELECT ctid FROM ( SELECT ctid, ROW_NUMBER() OVER (PARTITION BY phone ORDER BY id) AS rn FROM users ) t WHERE t.rn > 1 LIMIT 5000 ); GET DIAGNOSTICS batch_count = ROW_COUNT; RAISE NOTICE 'Deleted % rows', batch_count; EXIT WHEN batch_count < 5000; COMMIT; PERFORM pg_sleep(0.1); END LOOP; END $$;

这里的 COMMIT 在 DO 块里其实不会立即生效,实际生产环境建议用外部脚本(比如 Python 或 Shell 循环)逐批执行 DELETE,每批单独提交。思路是控制单次删除量,避免长事务。

另外,如果你要删除的表有外键被其他表引用,删除期间可能因为外键检查导致锁的范围扩大,此时最好先跟业务确认关联表的情况,或者选择在维护窗口执行。

4.4 空值数据和 DISTINCT 的坑再补充一个细节

前面我在 1.3 里提到空值会导致“伪重复”,这里再补充一个删除时容易踩坑的点。

假设你要用 DISTINCT ON 或者 GROUP BY 去重,而判定重复的字段里有 NULL,那么 PostgreSQL 会把所有 NULL 归为一组。比如你执行:

SELECT DISTINCT ON (coupon_code) * FROM orders ORDER BY coupon_code, id;

没使用优惠券的订单(coupon_code 为 NULL)会被当成同一组,最终只保留一条。这肯定不是你想要的结果。

所以处理前先明确:空值那一组的保留策略是什么。如果业务上空值代表“没有使用优惠券”,它们本来就是合法存在,那你就应该用COUPON_CODE IS NOT NULL先过滤掉空值记录,只对非空值做去重:

DELETE FROM orders WHERE coupon_code IS NOT NULL AND ctid NOT IN ( SELECT MIN(ctid) FROM orders WHERE coupon_code IS NOT NULL GROUP BY coupon_code );

这样空值记录完全不受影响。类似的情况还包括空字符串和 NULL 混存的情况,处理逻辑要按业务语义来定。

5. 删除完成后的收尾检查清单

很多人删完数据就以为任务结束了,但我建议再做一轮收尾检查,避免留下隐患。

5.1 核对删前删后的行数

这是最基础的一步。删除前记录表的总行数,删除后再次统计,确认“剩余行数 = 去重后的行数”。千万别只凭 DELETE 命令返回的影响行数来判断,那个数字只能说明删了多少行,不能说明剩下的是否正确。

SELECT COUNT(*) FROM users;

如果结果符合预期,再随机抽查几个重复组的记录,确保保留的是你想要的那一条。这一步说起来简单,但在生产环境真的帮过我避免几次大事故。

5.2 补充唯一约束,防止重复再次产生

重复数据清理干净之后,如果业务上确实要求这些字段唯一,一定要补上唯一索引。不然过段时间重复数据又会长出来,白忙活一场。

CREATE UNIQUE INDEX idx_users_phone ON users (phone);

如果判定重复的字段有多个列,就建复合唯一索引。注意先确认没有 NULL 重复的坑,再来建索引。

5.3 检查表膨胀和索引健康度

删除大量数据后,表的膨胀可能会让你后续的查询越来越慢。可以查一下表的实时状态:

SELECT relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'users';

如果 n_dead_tup 很大,说明积累了不少死元组,该 VACUUM 了。如果表物理文件很大,该 VACUUM FULL 就 VACUUM FULL。这些操作我通常安排在维护窗口执行。

提示:VACUUM FULL 会锁表,在线业务环境慎用。如果只是想让死元组占比降下来,先跑 VACUUM,看看效果,不行再考虑 VACUUM FULL。

写在最后的一点个人体会

PostgreSQL 删除重复数据这件事,表面上是几条 SQL 的问题,实际上是“确认业务规则、评估数据规模、选择安全策略、执行后清理验证”的一整套流程。我刚开始处理这类需求时也吃过亏,有一次直接跑了一版大 DELETE,把一张订单表锁了将近十分钟,业务方差点炸毛。后来学乖了,每次都先备份、再分批、再验证,一套流程走下来,稳得一批。

如果你也在处理类似问题,我最想强调的就三个字:先备份。无论是 CREATE TABLE 备份一份原表,还是先 SELECT 确认影响行数,都比直接执行 DELETE 稳妥得多。数据这东西,删错了想找回,代价往往超乎你的想象。希望这篇内容能帮你少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 10:14:43

MMC变流器在电力质量调节中的Simulink仿真与应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 10:14:23

局部模糊C均值聚类在图像分割中的MATLAB实现与调参实践

简介&#xff1a;基于MATLAB实现的局部模糊c均值聚类&#xff08;FLICM&#xff09;代码包&#xff0c;面向图像分割、聚类分析领域的研究生、科研人员及工程开发者&#xff0c;用于解决传统FCM算法对噪声敏感、分割不稳定的问题。压缩包共7个文件、容量87KB&#xff0c;包含2个…

作者头像 李华
网站建设 2026/9/13 10:13:52

Java对象比较:==与equals()的深度解析与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 10:09:34

C++ emplace_back与push_back性能差异深度解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 10:08:50

多智能体PR审查实战:基于OpenClaw 2.0构建自动化代码审查流水线

1. 为什么说PR审查是检验多智能体框架的试金石先说个真实场景。我维护的开源项目最近几个月PR越积越多&#xff0c;核心维护者只有两个人&#xff0c;其中一个还去休产假了。团队里有个新人提交了一版重构&#xff0c;改动量将近两千行&#xff0c;把好几个工具函数全部挪了位置…

作者头像 李华