搞后端的人,数据库里最容易翻车的操作就是 DELETE。它不像 SELECT 那样人畜无害,也不像 INSERT 那样目标明确,DELETE 一个条件写错,轻则删掉不该删的数据,重则把一张千万级大表拖进锁等待风暴,甚至在主从架构里把从库延迟直接拉满。这篇文章不打算复述官方文档里那几行语法,而是把我这些年在 PostgreSQL 里实际处理 DELETE 操作踩过的坑、总结出来的套路、以及排查思路完整梳理一遍。无论你是刚接触 PostgreSQL 的新手,还是已经在生产环境里摸爬滚打一段时间,这篇内容都能帮你在“删数据”这件事上更稳一点。
我把 DELETE 拆成几个层面来讲:先是基础语法和语义,然后是它在 MVCC 下的执行机制,再是大家最关心的性能问题和批量删除方案,最后是误删之后的应急恢复手段。每一块我都会结合真实场景说话,尽量做到可落地、可直接参考。
1. DELETE 的基础语法与应用场景
1.1 一条 DELETE 语句的完整结构
PostgreSQL 的 DELETE 语法看起来非常简单:
sql复制DELETE FROM table_name
WHERE condition
RETURNING column1, column2, ...;
但越简单的东西越容易在细节上翻车。
首先是 WHERE 子句。DELETE 之所以是危险操作,核心就在于 WHERE 是可选的。如果你写了 DELETE FROM users; 而没有 WHERE,那就是清空整张表,而且不会像某些图形化工具那样再弹一次二次确认框。我在刚入职那会儿就见过一次同事在测试库上顺手清了全表,虽说测试库不心疼,但这个习惯到了生产环境就是事故。
所以我的建议是,任何 DELETE 语句在提交之前,先把 WHERE 换成一个 SELECT 跑一遍,确认你要删除的行数和预期一致。比如:
sql复制-- 先看数量
SELECT count(*) FROM orders WHERE status = 'expired' AND create_time < now() - interval '90 days';
-- 再执行删除
DELETE FROM orders WHERE status = 'expired' AND create_time < now() - interval '90 days';
这个操作看似多了一步,却能拦截掉绝大部分误删。如果你习惯用事务包裹,可以在同一个事务里先 SELECT 再 DELETE,确认无误后 COMMIT,那是更稳妥的做法。
其次是 DELETE 的返回结果。PostgreSQL 执行 DELETE 后会在客户端返回一个命令标签,比如 DELETE 1024,表示删除了 1024 行。这个数字对你的脚本和日志监控很有用,后面我会提到怎么利用它。
还有一个容易忽略的点:DELETE 支持 USING 子句,可以引用其他表来过滤要删除的行。比如:
sql复制DELETE FROM orders o
USING users u
WHERE o.user_id = u.id
AND u.status = 'banned';
这种方式在关联删除的场景里很常用,但要注意别把 USING 表的数据也误删了——USING 只起过滤作用,真正删除的永远只是主表里的行。
1.2 用 RETURNING 拿回被删的数据
这是 PostgreSQL 一个非常好用、但很多人根本没用的特性:DELETE 之后可以把被删除行的内容返回给客户端。
sql复制DELETE FROM token_blacklist
WHERE expire_time < now()
RETURNING id, token, user_id;
RETURNING 的应用场景很实际。我做过一个登录安全模块,用户登出的时候要清除一个旧的令牌,同时要把这个令牌的信息写入审计日志。以前是三步:先 SELECT 查到要删的行,再 DELETE,最后 INSERT 行为日志。有了 RETURNING 可以直接变成两行:
sql复制WITH deleted AS (
DELETE FROM sessions
WHERE token = $1
RETURNING user_id, token, login_time, logout_time
)
INSERT INTO session_logs (user_id, token, login_time, logout_time)
SELECT * FROM deleted;
一个事务搞定,既保证了原子性,又省了一次无谓的查询。这种写法在做数据归档、事件上报、同步删除确认的时候非常有用。
还有一个小技巧是利用 RETURNING 配合应用层做消息通知。比如删除某个缓存记录后,要把被删的主键返回给业务层,让它去触发缓存重建逻辑。虽然传统做法是先查再删,但 RETURNING 能确保你拿到的就是真实被删掉的那几行,不存在并发下查完又被别人删了、最后删了个寂寞的尴尬情况。
1.3 DELETE、TRUNCATE、DROP PARTITION 怎么选
很多新手不知道,清空一张表的数据有多种方式,不只是 DELETE。PostgreSQL 里常见的有三条路:
DELETE FROM table;:逐行标记删除,支持事务回滚,可以加 WHERE 条件。TRUNCATE TABLE table;:直接清空表数据,速度极快,但在 PostgreSQL 中它会获取 ACCESS EXCLUSIVE 锁,并且会重置表的存储水位线。- 如果表是分区表,直接
DROP TABLE某一个分区,或者ALTER TABLE ... DETACH PARTITION再丢弃。
选择的关键在于:你到底是想“删掉一部分数据”,还是“清空整张表”。
如果你的目标是清空全部数据,而且表不是被频繁并发访问的核心业务表,TRUNCATE 的处理速度比 DELETE 快出几个数量级。原因是 TRUNCATE 不需要逐行判断条件、不产生 MVCC 死元组、不需要逐行写 WAL 日志的完整行数据,它只是把表的存储文件重新初始化。但要注意,TRUNCATE 在 PostgreSQL 里也算事务性的,放在事务块里可以 ROLLBACK,这是很多人不知道的一个点。
如果你的目标是删除一个历史分区,直接 DROP PARTITION 往往比 DELETE 快得多,因为不需要逐行产生删除记录,只需要把整个物理段文件摘掉。这也是为什么我一直建议:凡是按时间维度大量清理的数据,在设计表结构的时候就该考虑按时间分区。加了分区之后,历史数据过期就 DROP 分区,连 VACUUM 的压力都省了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深入 DELETE 的执行与锁机制
2.1 DELETE 到底做了什么:MVCC 视角
要理解 DELETE 的性能问题,首先得明白 PostgreSQL 的 MVCC 机制。
在 PostgreSQL 里,DELETE 并不是物理上把数据文件里的那行直接抹掉,而是在该行数据上打一个“已删除”的标记,对应一个事务 ID。这个标记决定了该行对其他事务是否可见。具体来说:
- 正在执行删除的事务,在它自己内部仍然能看到这一行(如果没有删除完的话)。
- 删除事务提交之后,该行对其他新的读事务就不可见了。
- 但这行数据依然物理存在,占着存储空间,直到 VACUUM 进程来清理。
这就像你在一本书上用铅笔划掉一行字,虽然读者看不见了,但纸张上确实还留着痕迹。如果划掉的字太多,整本书就会变厚,翻起来变慢。PostgreSQL 里把这个“变厚”称为表膨胀,被标记删除的行则叫死元组。
所以 DELETE 本身不是那么“轻”的操作。你每删一行,系统就要维护多版本信息、写 WAL 日志、可能触发索引更新。当你一次性删除几百万行时,压力和 UPDATE 大批数据是类似的。
我建议你在排查 DELETE 慢的问题时,先打开死元组监控:
sql复制SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE relname = 'orders';
如果 dead_ratio 长期超过 20%,说明 VACUUM 没跟上,DELETE 产生的死元组堆积得比较严重了。这种表不仅 DELETE 慢,SELECT 也会变慢,因为查询扫描时不得不跳过大量已经不可见的行。
2.2 行级锁与表级锁:delete 卡住的主因
DELETE 在锁这件事上也比较有意思。它会对被删除的行加行级排他锁,同时会向表加一个 ROW EXCLUSIVE 锁(行级排他表锁模式)。
行级排他锁意味着,两个事务可以同时删除不同的行,不会互相阻塞,这是典型的并发友好特性。但问题往往出现在以下几种情况:
-
两个事务想要删除同一行,或者一个事务想 UPDATE 某行,另一个事务想 DELETE 同一行。此时后到的事务必须等待先到的事务提交或回滚。如果先到的事务迟迟不提交,后到的 DELETE 就会卡在锁等待上。
-
外键约束产生的锁。如果一张表有外键引用它,删除父表行时,系统会对子表相关行加 FOR KEY SHARE 锁,用来确认没有子记录引用该行。如果你的表外键关系复杂,DELETE 父表数据时可能被子表的写入操作阻塞。
-
DELETE 执行期间,Schema 的变更、TRUNCATE、ALTER TABLE 这类操作都会被锁住,因为它们需要更高等级的锁。
实际排查看起来就是:某个 DELETE 语句一直执行不完,连接卡在 active 状态,CPU 和 IO 看起来都不高。此时大概率就是锁等待。
排查锁等待最快的办法是查 pg_stat_activity:
sql复制SELECT pid, state, wait_event_type, wait_event, query_start, now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event IS NOT NULL;
如果看到字段 wait_event_type 是 Lock,wait_event 是类似 transactionid 的值,说明这个 DELETE 正在等另一个事务释放锁。你可以继续通过 pg_locks 反查是谁占着锁:
sql复制SELECT blocked.pid AS blocked_pid,
blocking.pid AS blocking_pid,
blocking.query
FROM pg_locks blocked
JOIN pg_stat_activity blocking ON blocking.pid = blocked.pid
WHERE blocked.granted = false;
查到阻塞源头之后,再根据业务判断是等它提交,还是联系对应会话的管理者处理。千万不要随便 pg_terminate_backend,误杀了一个正在跑重要任务的事务,那就是火上浇油。
2.3 锁等待与死锁的现场排查
死锁和普通的锁等待不同,它在 PostgreSQL 里会被自动检测并处理。当两个事务互相持有对方需要的资源时,数据库会选择回滚其中一个事务来解开死锁,错误信息通常是:
code复制ERROR: deadlock detected
死锁最常见于批量更新和批量删除场景。比如一个事务先删 A 表再删 B 表,另一个事务先删 B 表再删 A 表。如果并发量一上来,互相等对方释放行锁,死锁就会出现。
避免死锁的方法其实很朴素:所有事务都按照相同的顺序操作表或行。比如约定“先删子表,再删父表”或者“按主键从小到大删”,能大幅降低死锁概率。
另外,PostgreSQL 的 log_lock_waits 参数也值得开启。它会在锁等待超过 deadlock_timeout 的时候把等待关系写进日志,是非常有用的排查线索。我习惯在开发库上设置:
code复制deadlock_timeout = 1s
log_lock_waits = on
上线前如果压测发现有锁等待,日志里一目了然。
3. 大规模数据删除的稳妥姿势
3.1 为什么单次 DELETE 太大会出问题
很多团队第一次遇到 DELETE 性能事故,都是从“删除历史数据”开始的。
假设一张订单表有 5000 万行,其中 3000 万是三个月前的老数据,业务要求把它们清理掉。直觉上一条 DELETE 就完事了:
sql复制DELETE FROM orders WHERE create_time < now() - interval '3 months';
理论上这条语句没错,但在生产环境执行时,你可能会遇到以下问题:
- 单条 DELETE 会持有事务打开状态下所有死元组的管理信息,3000 万行的死元组在事务提交前都无法被 VACUUM 清理,存储占用高峰非常吓人。
- WAL 日志会剧烈增长,因为每个被删除的行都要记录。
- 如果是主从架构,这条巨大的 DELETE 会同步到从库执行,从库的回放压力同样巨大,可能导致主从延迟飙升。
- DELETE 长时间持有行锁,一旦业务正在并发读写同一批数据,锁冲突的概率会急剧上升。
- 如果中途失败回滚,回滚的代价同样很高,甚至比删除本身更耗时。
我自己就遇到过一次:一条 DELETE 跑了四十多分钟,把磁盘 WAL 目录写满了,最后事务回滚,整整折腾了两个小时。从那以后我再也不在生产环境执行这种一次删几千万行的 SQL。
3.2 分段删除方案:数量、节奏、提交时机
大规模删除的正确姿势是分段删除,也就是把一个大事务拆成很多个小事务,每次只删一小批,提交后再继续下一批。
常见的做法是利用主键范围来一段一段删:
sql复制DELETE FROM orders
WHERE create_time < now() - interval '3 months'
AND id > %s
AND id <= %s;
或者用子查询限制每次删除的规模:
sql复制DELETE FROM orders
WHERE id IN (
SELECT id FROM orders
WHERE create_time < now() - interval '3 months'
ORDER BY id
LIMIT 5000
);
第二种写法更通用,但要注意 IN 子查询在数据量大的情况下也可能有性能问题。更推荐的做法是每次取一个主键范围,按 id 从小到大推进。
还有一个没有标准答案的问题是:每批删多少行合适?我的经验是 5000 到 20000 行比较合适,具体取决于表的宽度、磁盘 IO 能力和数据库负载。表比较宽、磁盘 IO 一般的情况下,5000 行一批会比较安全;表很窄、机器性能好的话,可以放宽到 50000 行。每批之间建议加一点间隙,比如 0.5 秒到 1 秒,给其他业务查询让让路。
把这套逻辑封装成 SQL 是不可能的,因为要控制循环和提交,所以一般用脚本或者存储过程来做。我写过一种基于 DO 块的写法,适合在 PostgreSQL 里直接跑:
sql复制DO $$
DECLARE
batch_size int := 10000;
deleted_count int;
BEGIN
LOOP
DELETE FROM orders
WHERE ctid IN (
SELECT ctid FROM orders
WHERE create_time < now() - interval '3 months'
LIMIT batch_size
);
GET DIAGNOSTICS deleted_count = ROW_COUNT;
COMMIT;
RAISE NOTICE 'deleted % rows', deleted_count;
EXIT WHEN deleted_count < batch_size;
PERFORM pg_sleep(0.5);
END LOOP;
END $$;
这里用了 ctid 来定位要删除的行。ctid 是 PostgreSQL 里每个行物理位置的标识,用它做分批删除可以不用依赖业务主键,也不需要额外维护游标。但需要注意,ctid 不是稳定不变的,表在并发写入时可能出现变化,所以这种写法尽量在低峰期执行。如果你有稳定有序的主键,用主键分段更保险。
3.3 删除前的触发器与外键检查
很多人在删除大量数据前会忽略触发器的影响。PostgreSQL 里如果有 AFTER DELETE 触发器,尤其是那种每删一行就把数据写入审计表、或者去做外部同步的触发器,那 DELETE 的性能会被放大好几倍。
我见过一个系统,删除一张表的数据,每次删 100 行,实际耗时却有几十秒。查了半天发现表上有个 AFTER DELETE 触发器,每删除一行都会调用一次外部 HTTP 接口做通知。数据库本身删除是快的,全耗在触发器里了。
在大批量删除前,如果你确定不需要触发器的逻辑,可以考虑临时禁用触发器:
sql复制ALTER TABLE orders DISABLE TRIGGER trg_orders_delete_notify;
删除完成后再重新启用:
sql复制ALTER TABLE orders ENABLE TRIGGER trg_orders_delete_notify;
但要非常清楚一点:禁用触发器是有副作用的,业务层的完整性校验也可能被绕过去。所以操作前一定要和负责该表业务的同事确认,并且记录在变更单里。
外键也是一个隐藏雷区。如果要删除父表数据,先看子表有没有引用:
sql复制SELECT
tc.table_name AS child_table,
kcu.column_name AS child_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND ccu.table_name = 'orders';
如果有子表引用,直接 DELETE 父表的行会违反外键约束而报错。如果子表定义了 ON DELETE CASCADE,那 DELETE 会连带把子表相关数据也删掉,这个行为的杀伤力比你想的要大得多,务必确认清楚。
3.4 删除后的 VACUUM 与索引维护
大批量 DELETE 完成之后,死元组堆积的问题是绕不开的。即使你分批删、分批提交,整体上还是会产生大量死元组,这时候需要 VACUUM 把这些空间标记为可用。
PostgreSQL 的自动清理进程(autovacuum)会在后台做这件事,但它有自己的节奏。如果死元组增长太快,autovacuum 可能来不及处理,这个时候手动 VACUUM 一下是合理的:
sql复制VACUUM (VERBOSE, ANALYZE) orders;
ANALYZE 会顺便更新统计信息,让查询规划器拿到最新数据分布,避免后续查询走错执行计划。
但要注意,普通的 VACUUM 只是把空间标记为可复用,并不会把文件缩小。如果你删除的数据量非常大,比如清理了表里 80% 的行,表的物理文件依然很大,后续查询虽然能复用这些空间,但磁盘空间本身不会被释放。这个时候要用 VACUUM FULL 才能压缩物理文件:
sql复制VACUUM FULL orders;
不过 VACUUM FULL 会获取 ACCESS EXCLUSIVE 锁,相当于把表锁住,期间任何读写都不能进行,所以必须在维护窗口执行。对于核心业务表,不建议频繁做 VACUUM FULL,可以通过分区表来尽量避免这种需求。
另外,大批量删除后索引也可能出现膨胀。索引中指向死元组的索引项需要清理,VACUUM 会处理这部分。如果索引膨胀特别严重,可以考虑重建索引:
sql复制REINDEX TABLE orders;
重建索引同样会锁表,建议放在业务低峰期。
4. 提升删除效率的更多实用技巧
4.1 使用子查询限制删除规模
分段删除的本质是用小事务替换大事务,但很多时候业务并不需要写循环。如果删除范围本身不大,只是不希望一次锁太多行,完全可以用子查询来限制规模。
比如你想删除某个用户最近 30 天没活跃的记录,且一次最多处理 1000 条:
sql复制DELETE FROM user_logs
WHERE user_id = 12345
AND last_active_time < now() - interval '30 days'
AND ctid IN (
SELECT ctid FROM user_logs
WHERE user_id = 12345
AND last_active_time < now() - interval '30 days'
LIMIT 1000
);
这个写法执行完一次只删最多 1000 行,应用程序可以循环调用,直到返回的删除行数小于 1000 为止。这种方式的优点是语句简单、可控性强,适合放在定时任务里反复执行。
不过有个细节要注意:用 ctid IN (SELECT ctid ...) 在并发写入环境下,可能出现批内某些行已经被其他事务删除的情况。不过 DELETE 操作对这种“已不存在”的行并不报错,只是少删几行而已,下一轮再补就行,整体影响不大。
4.2 借助 RETURNING 做数据同步
前面提到 RETURNING 可以把被删的数据返回给应用。如果结合逻辑复制或者消息队列,它可以成为一个简单的 CDC(数据变更捕获)工具。
举个例子,我们有一个订单归档需求:删除 90 天以前的状态为“已完成”的订单,同时把删掉的订单数据推送到离线的分析系统。如果原始表没有单独设置归档机制,直接用 RETURNING 加一条消息发送逻辑就能完成:
sql复制WITH archived AS (
DELETE FROM orders
WHERE status = 'completed'
AND create_time < now() - interval '90 days'
RETURNING id, user_id, amount, create_time
)
INSERT INTO order_archive SELECT * FROM archived;
在同一个事务里,插入归档表的操作和删除操作具有原子性,要么都成功,要么都回滚。这比“先 SELECT、再 DELETE、再 INSERT”的方案更不容易出现数据不一致。
很多消息队列方案也会利用 RETURNING 把删除事件的完整数据发到 MQ。这样下游消费者拿到的不是干巴巴的 id,而是那一整行的完整快照,非常方便。
4.3 删除重复数据:ctid 的妙用
数据清洗中有一类高频需求是去重,通常也是 DELETE 操作的一个坑:保留每组重复记录里最小/最大的一条,删除其余。
在 PostgreSQL 里,ctid 又派上用场了。假设有一张 user_tag 表,理论上每一对 (user_id, tag) 只能出现一次,但线上数据因为历史 bug 存在不少重复。要保留每组里最早的物理记录(即 ctid 最小的那条),删除其他重复记录:
sql复制DELETE FROM user_tag a
USING user_tag b
WHERE a.user_id = b.user_id
AND a.tag = b.tag
AND a.ctid < b.ctid;
这个语句会把每组重复记录中,ctid 较大的那些全部删除。执行前先查一下有多少重复:
sql复制SELECT count(*) FROM (
SELECT user_id, tag, count(*) FROM user_tag GROUP BY user_id, tag HAVING count(*) > 1
) t;
确认数量在预期范围内再执行删除。ctid 不依赖业务字段,很多时候比“按 id 去重”更直观,也不用担心 id 为空的问题。
但还是要提醒一点:ctid 是物理位置标识,它会因为 VACUUM FULL、CLUSTER 等操作发生变化。如果你在删除过程中表正在被重写,那么 ctid 也会变。正常在线业务里极少发生这种情况,但在执行 DELETE 之前,最好确认没有 DBA 同时在维护这张表。
5. 误删数据的应对与恢复实操
5.1 最有效的后悔药:事务回滚
如果你执行 DELETE 之后发现删错了,第一反应应该是:这个 DELETE 是不是已经被 COMMIT 了?
如果 DELETE 还没有提交,或者你是在一个未提交事务里执行的,那么最省事的恢复方式就是 ROLLBACK。
sql复制BEGIN;
DELETE FROM orders WHERE status = 'pending';
-- 发现不对劲
ROLLBACK;
这个看起来太基础,但实际操作中很多人容易慌。尤其是用图形化连接工具时,工具默认开启了自动提交,语句一执行完,事务就悄悄提交了,想回滚都来不及。
所以我的一个强烈建议是:在连接工具里关闭自动提交,把常用的写操作放在显式事务里,确认后再 COMMIT。不要嫌麻烦,这个习惯能在关键时刻保命。
5.2 备份恢复与时间点恢复(PITR)
如果 DELETE 已经提交,应用层没有 undo 机制可用,最可靠的恢复手段就是备份。
PostgreSQL 的备份方案一般是逻辑备份加物理备份的组合:
pg_dump逻辑备份适合恢复单表或单个库,但通常有备份周期,恢复出来的数据可能滞后一段时间。- 物理备份 + WAL 归档适合做时间点恢复(PITR),可以把数据库恢复到任意一个时间点,前提是归档日志完整。
如果删数据是刚刚发生的,而且归档配置完整,PITR 是恢复误删最好的选择。恢复的目标时间点精确到删除前的一秒,然后用 pg_restore 或者直接利用恢复出来的库把数据导回原库。
不过 PITR 恢复通常需要搭建恢复实例,不能在原库上直接回退。操作流程大致是:找一台新机器或者新的实例,把基础备份恢复到某个时间点,再应用 WAL 到指定时刻,然后导出需要恢复的数据,导回线上库。整套流程在大型系统里可能要几小时甚至更久,所以日常对备份可用性的验证非常关键。我见过不少团队做完备份就再也不管了,真到恢复的时候发现归档有缺、备份文件损坏,那种绝望感我不想体验第二次。
5.3 防御性操作:防止手滑删全表
比起事后恢复,我更推崇事前防御。有些 DBA 会把 DELETE 和 UPDATE 语句通过中间件强制要求带 WHERE 条件,这确实能挡住一部分误操作。但在 PostgreSQL 里,我们也可以在数据库层面上做一些措施。
一个做法是使用 row_security 行级安全策略。给表加一个永远为 true 但实际想要限制的条件是不现实的,但可以对敏感表加一个默认的 RLS 策略,让没有指定特定条件的 DELETE 被系统拒绝。比如设置一个默认策略 USING (false),拒绝所有普通用户的删除操作,再单独给应用账号开一条允许按条件删除的策略。
还有一个更简单的方案:用触发器禁止不带 WHERE 的 DELETE。在 PostgreSQL 里写一个 BEFORE DELETE 触发器,如果发现 current_query() 里没有 WHERE 关键字,就 RAISE EXCEPTION。这种做法虽然有点土,但在防止手滑清空小配置表的时候特别有效。
sql复制CREATE OR REPLACE FUNCTION forbid_full_delete() RETURNS trigger AS $$
BEGIN
IF current_query() !~ 'WHERE' THEN
RAISE EXCEPTION 'DELETE without WHERE is not allowed on this table';
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_forbid_full_delete
BEFORE DELETE ON system_config
FOR EACH ROW EXECUTE FUNCTION forbid_full_delete();
注意这个触发器对于 TRUNCATE 是无效的,TRUNCATE 不会触发行级触发器,这点要单独防。
5.4 误删后应急处理的步骤清单
真遇到误删别慌,我按优先级排一个清单:
- 先确认是不是真的提交了:查看当前连接的事务状态,看有没有机会 ROLLBACK。
- 关闭应用侧对这张表的写入,避免后续数据覆盖掉残留的恢复线索。
- 检查当前有没有 wal 归档、基础备份,评估 PITR 的可行性和恢复时间。
- 如果无法 PITR,看看有没有其他的逻辑备份,比如某个时点的 pg_dump。
- 如果都没有,退而求其次,从从库、只读副本或者监控系统里把最近的数据捞出来,做人工补偿。
最后一步听起来很无奈,但在现实中确实发生过。它在提醒我们:备份策略不是写在文档里就完了,要定期做恢复演练,而且要确保备份的保留周期能覆盖业务上可能出现的“误删发现时间”。很多误删是在几天甚至几周后才被业务方发现的,如果备份只有一天,那就只能靠 WAL 归档救命了。
6. 高频问题速查与最后的经验分享
6.1 常见问题排查表
我把平时被问得最多的一些 DELETE 相关问题整理成了表格,方便你在出问题时快速定位方向。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| DELETE 执行很慢,CPU 和 IO 都不高 | 锁等待 | 查 pg_stat_activity 的 wait_event,反查 pg_locks |
| DELETE 报 deadlock detected | 两个事务资源获取顺序不一致 | 统一操作顺序,开启 log_lock_waits 分析日志 |
| DELETE 几十秒才删几百行 | AFTER DELETE 触发器拖累 | 检查触发器逻辑,确认是否需要临时禁用 |
| 删除后表文件大小没变 | 死元组等待 VACUUM 清理 | 执行 VACUUM,必要时 VACUUM FULL |
| 删除大表时从库延迟飙高 | 大批量删除同步到从库回放 | 分批删除,控制单次删除行数 |
| 删除父表数据报外键约束错误 | 子表有引用且未设置级联 | 先处理子表数据,或者确认级联行为 |
| 删完数据后查询依然慢 | 统计信息过期或索引膨胀 | VACUUM ANALYZE,必要时 REINDEX |
| 删掉的重复数据里保留了错误那行 | 去重条件选错 | 先用 SELECT 验证要保留的行,再执行 DELETE |
| DELETE 一直在等锁,但查不到阻塞者 | 长事务空闲在事务内 | 查找 state = 'idle in transaction' 的连接 |
6.2 我的几条 DELETE 操作心得
做了这么多年数据库相关工作,我对 DELETE 最大的体会是:它不是一个单纯写 SQL 的问题,而是风险评估和工程习惯的问题。
第一,永远不要在生产环境的连接工具里开着自动提交乱删数据。关掉自动提交、用事务包裹、习惯性地 ROLLBACK,比任何工具都实在。
第二,给表加分区这件事,最好在设计早期就做。不是所有表都需要分区,但凡是带有明显时间序列属性、且明确会有周期清理需求的大表,分区能帮你把 DELETE 转成 DROP PARTITION,性能差距是数量级的。
第三,多关注 PostgreSQL 的 VACUUM 机制。很多人只盯着删除语句本身快不快,忽略了删除之后留下的死元组对整张表后续查询的巨大影响。删除前评估一下产生多少死元组,删除后及时 VACUUM,这比优化 SQL 本身还重要。
第四,别怕使用 RETURNING。它让 DELETE 不再只是“把数据丢掉”,而是可以变成“把数据交接出去”的桥梁。归档、审计、同步、事件通知,都能借助 RETURNING 在一个事务里优雅完成。
最后分享一个小技巧:如果你不确定某条 DELETE 语句在并发环境下会不会锁表或者卡住,可以在事务里先执行 SET lock_timeout = '5s';。有了这个设置,一旦 DELETE 需要等待锁超过 5 秒,会直接报错退出,而不是无限期挂在那里。处理紧急变更时,这能帮你避免一条 SQL 把整个连接池拖垮。
PostgreSQL 的 DELETE 还有很多边角细节,比如视图上的 DELETE 规则、继承表的行为、DELETE 与逻辑复制的交互关系。这些内容如果你有兴趣,后续我可以继续展开聊。先把自己最常用的这些经验沉淀下来,希望对正在跟数据库打交道的你有帮助。
