1. 写在前面:你真的搞懂 DELETE 了吗?
作为一个天天跟 PostgreSQL 打交道的开发者,我见过太多人在 DELETE 这条语句上栽跟头。有人因为漏掉 WHERE 条件把整张表清空,有人因为删除大表导致数据库卡死,还有人误删之后才发现备份策略形同虚设。老实说,DELETE 是 SQL 里最基础的操作之一,但恰恰是这种"太简单"的错觉,让很多人忽略了它背后那些足以让你加班到凌晨的细节。
这篇文章不是帮你背语法手册,而是想跟你聊聊我在实际项目中用 PostgreSQL 做 DELETE 操作时沉淀下来的经验。我会从最基本的语法讲起,逐步深入到批量删除的性能优化、误操作后的数据恢复、以及 DELETE 与 VACUUM、事务、锁机制之间的微妙关系。无论你是刚入门的新手,还是已经写过几年 SQL 的老手,这篇文章里应该都有你能用上的东西。
先说清楚,本文基于 PostgreSQL 15/16 版本的行为来讲解,如果你还在用 9.x 或者 10.x,个别行为会有差异,我会在涉及的地方特别说明。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DELETE 的基本语法与执行机制拆解
2.1 语法结构:从最简单到最复杂
PostgreSQL 的 DELETE 语句基本语法长这样:
sql复制DELETE FROM table_name
[WHERE condition]
[RETURNING * | column1, column2 ...];
看起来很简单对吧?但这里有几个容易被忽略的细节。首先是 FROM 关键字后面的表名,它支持别名,比如 DELETE FROM users AS u WHERE u.id = 100,这在多表关联删除时特别有用。
其次是 WHERE 条件。如果你不加 WHERE,那就是全表删除,等价于 TRUNCATE 但又不完全等价——这个区别我后面会专门讲。
最后是 RETURNING 子句,这个很多人不知道或者不习惯用。它可以在删除的同时返回被删除的行数据,省去一次单独的 SELECT 查询。比如你删除一个订单后需要记录日志,直接用 RETURNING 就很方便:
sql复制DELETE FROM orders
WHERE id = 12345
RETURNING id, customer_id, created_at;
这条语句会返回被删除订单的 id、customer_id 和 created_at,你可以直接拿来写审计日志或者做后续的级联操作。
还有一点值得注意,DELETE 语句支持使用子查询来指定要删除的行集。比如你要删除 "最近 30 天没有任何订单的用户":
sql复制DELETE FROM users
WHERE id NOT IN (
SELECT DISTINCT customer_id
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
);
这种用法在实际业务中非常常见,但要小心子查询的性能问题,这个后面会详细展开。
2.2 执行过程:DELETE 在数据库内部到底做了什么
理解 DELETE 内部机制对排查问题和优化性能至关重要。当你在 PostgreSQL 中执行一条 DELETE 时,数据库内核实际做的事情比你想象的要复杂得多:
第一阶段是条件扫描。PostgreSQL 会根据 WHERE 条件扫描表中满足条件的行,这个阶段涉及到索引扫描(Index Scan)还是顺序扫描(Seq Scan)的选择,由查询优化器根据统计信息决定。如果表很小或者条件列上没有索引,大概率会走全表扫描;如果条件列上有合适的索引,则会走索引扫描。
第二阶段是标记删除。找到目标行之后,PostgreSQL 并不是真的把数据从磁盘上抹掉,而是在行头上打一个"已删除"的标记(xmax 字段被设置为当前事务 ID)。这个设计是 MVCC(多版本并发控制)的核心——其他事务仍然可以读到删除前的旧版本数据,除非它们的事务隔离级别和提交状态允许看到这个删除。
第三阶段是索引维护。如果表上有索引,删除操作需要同步更新索引结构。PostgreSQL 的 B-tree 索引在处理删除时是逻辑删除索引条目,后续再由清理过程回收空间。这也是为什么频繁 DELETE 会导致索引膨胀的原因之一。
第四阶段是 WAL 日志写入。所有删除操作都会生成 WAL(Write-Ahead Logging)记录,以保证崩溃恢复时数据的完整性。即使开启了同步复制或者高可用部署,这些 WAL 记录也会被传输到备机。
理解这个流程你就明白了几个关键点:
- DELETE 不是物理删除,磁盘空间不会立刻释放
- 频繁 DELETE 会产生大量死元组,需要 VACUUM 来清理
- 事务回滚是可能的,范围内所有已做的删除都会被撤销
2.3 DELETE 与 TRUNCATE 的本质区别
很多人会混淆 DELETE 和 TRUNCATE,觉得都是删除数据,随便用哪个都行。这里必须郑重提醒:这两个操作的本质差异非常大,选错会出大问题。
| 比较维度 | DELETE | TRUNCATE |
|---|---|---|
| 是否触发触发器 | 会 | 不会 |
| 是否返回删除行数 | 会 | 不会 |
| 是否产生 WAL | 每条删除都记录 | 只记录文件级别的变更 |
| 表锁级别 | 行级锁 | ACCESS EXCLUSIVE 锁 |
| WHERE 条件 | 支持 | 不支持 |
| 事务回滚 | 支持 | 支持(但无法恢复表的 OID 等) |
| 空间释放 | 不会立即释放,需要后续 VACUUM | 立即释放空间给操作系统 |
| 速度 | 慢,逐行处理 | 极快,整体标记 |
| 自动递增序列 | 不受影响 | 不重置(要重置需手动操作) |
实际项目中,如果确认要清空整张表且不需要回滚,TRUNCATE 是更好的选择。但如果只是删除"部分数据",就只能用 DELETE。还有一个取舍场景:你要删除一张表中的 90% 数据,如果业务允许停机,可以考虑先把保留的数据插入新表,TRUNCATE 原表,再把数据插回去——这个操作的耗时往往比 DELETE 逐行删除 90% 数据快得多。
3. 条件删除与多表关联:WHERE 的进阶打法
3.1 三种 WHERE 常见陷阱
WHERE 条件写不好,轻则删除错误数据,重则删库跑路。我总结了三种最常见的陷阱:
陷阱一:NULL 值比较。 在 SQL 中,NULL 不等于 NULL,也不等于任何值。如果你写 WHERE deleted_at = NULL,那结果永远是空集。正确写法是 WHERE deleted_at IS NULL。这一点虽说是新手都会踩的坑,但我见过不少工作三五年的开发者也时不时犯这个错。
陷阱二:IN 子句的性能陷阱。 如果 IN 后面的子查询返回的行数是几万甚至几十万,那么原表每扫描一条记录就要去哈希表里查一次,性能可能急剧下降。尤其是在 PostgreSQL 老版本中,优化器对 IN 子查询的优化不够好,可能导致严重的偏差。更稳妥的做法是改用 JOIN 或者 EXISTS,比如:
sql复制-- 不推荐:IN 子查询可能性能很差
DELETE FROM users
WHERE id IN (SELECT user_id FROM user_logs WHERE type = 'temp');
-- 推荐:使用 EXISTS 关联
DELETE FROM users u
WHERE EXISTS (
SELECT 1 FROM user_logs l
WHERE l.user_id = u.id AND l.type = 'temp'
);
陷阱三:时间条件边界问题。 比如你想删除"2024-01-01 之前的数据",写成 WHERE created_at < '2024-01-01' 是准确的;但如果写成 WHERE created_at <= '2024-01-01',就会把 1 月 1 日当天创建的数据也删掉。更隐蔽的问题是时区:PostgreSQL 的 timestamp with time zone 类型在不同时区设置下,同一个字面量的含义会完全不同。如果你在配置了不同 timezone 的客户端上执行 DELETE,结果可能大相径庭。
3.2 USING 子句实现多表关联删除
PostgreSQL 有一个专属的 DELETE 扩展语法:USING 子句。它允许你引入其他表来辅助指定删除条件:
sql复制DELETE FROM orders o
USING users u
WHERE o.customer_id = u.id
AND u.status = 'banned';
这个语句的含义是:删除所有"状态为 banned 的用户所下的订单"。底层逻辑是先做一个内连接,找到符合条件的行集,然后删除原表中匹配的那些行。
使用 USING 时要注意一个关键点:USING 里列出的表只用于条件过滤,不会因为连接而产生重复删除。比如 USING 表中有多条记录匹配同一行,那么原表中该行也只会被删除一次,不用担心重复删除的问题。
但有个隐患需要提醒:如果你的关联条件写得不细致,USING 可能会扩大删除范围。例如你只想删除最近一个月被禁用用户的订单,但忘记加上时间过滤条件,那可能把历史所有订单都连带删掉。
3.3 级联删除:ON DELETE CASCADE 的正确打开方式
在表设计阶段,外键约束可以指定 ON DELETE CASCADE,这样删除主表记录时,PostgreSQL 会自动删除子表中引用了该记录的所有行:
sql复制CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES users(id) ON DELETE CASCADE
);
这套机制用好了能省很多事,但用不好是灾难。试想一下:一个核心业务表被几十张子表通过外键引用,且都设置了 CASCADE,那么你执行一条 DELETE 可能触发几十条隐式删除——如果你的 WHERE 条件写错,那么所有关联数据会像多米诺骨牌一样全部消失。
我个人的建议是:CASCADE 只适用于强所属关系的场景,比如"订单明细随主订单一起删除"、"用户上传的文件记录随用户删除而删除"。对于跨模块的引用关系,宁可手动先删子表再删主表,也别用 CASCADE 图省事。
另外注意,如果在事务中执行 CASCADE 删除,且删除链条很深,会带来较重的锁竞争。因为级联删除每个子表都会加上 AccessShareLock,父表则持有 RowExclusiveLock,多个并发删除可能会互相阻塞。
4. 批量删除的性能优化:删百万数据不卡库的实战方案
4.1 为什么大批量 DELETE 会拖垮数据库
一次 DELETE 删几十条记录没啥感觉,但如果你要删除一张一亿行表中的 8000 万行,那就完全是另一回事了。这种大批量删除会带来三个问题:
第一是锁范围扩大。DELETE 每处理一行,都要获取行级锁。对于大批量删除,PostgreSQL 锁定的行数可能超过一定阈值,从而升级为表级锁(通常是与 RowExclusiveLock 配合的 AccessExclusiveLock 不会轻易升级,但会导致其他事务对同一表的写操作阻塞更久)。
第二是死元组堆积。PostgreSQL 的 MVCC 机制要求,被删除的行在事务提交后成为死元组。如果一次性删几千万行,死元组的数量会让表的物理文件迅速膨胀,后续的 SELECT 扫描也需要跳过这些死元组,导致查询性能下降。
第三是 WAL 日志膨胀。每条 DELETE 都产生 WAL 记录,大批量删除会生成大量 WAL,对磁盘 IO 和复制流造成巨大压力。在高可用部署中,这会直接导致备机回放延迟。
4.2 分批删除:最实用的优化模式
面对大批量删除,最有效的策略是分批提交。基本思路是:一次删除一批(比如 1000 或 5000 行),每批之间间隔短暂时间,让数据库有机会处理其他事务、回收资源。
sql复制DO $$
DECLARE
batch_size INTEGER := 5000;
deleted_count INTEGER;
BEGIN
LOOP
DELETE FROM logs
WHERE created_at < NOW() - INTERVAL '90 days'
AND ctid IN (
SELECT ctid FROM logs
WHERE created_at < NOW() - INTERVAL '90 days'
LIMIT batch_size
);
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RAISE NOTICE 'Deleted % rows in this batch', deleted_count;
EXIT WHEN deleted_count < batch_size;
COMMIT;
END LOOP;
END $$;
这段代码用 ctid 来定位要删除的行。ctid 是 PostgreSQL 内部的行物理标识符,用它来分页可以避免用 OFFSET 分页导致的性能问题——OFFSET 在数据量大的时候会越来越慢,而基于 ctid 的删除方式每一批都是快进快出。
还有一个更简单的方法,直接使用主键范围:
sql复制DELETE FROM logs
WHERE id IN (
SELECT id FROM logs
WHERE created_at < NOW() - INTERVAL '90 days'
LIMIT 5000
);
这种写法在 id 是主键(有索引)的前提下表现也不错。但要注意,IN (SELECT ... LIMIT 5000) 这种方式每批都要执行一次子查询,如果表非常大,子查询本身的扫描成本也不容忽视。
在实际项目中,我通常会组合使用:先按主键排序,用一个游标或者循环来记录上一次删除的最大 ID,然后删除小于等于该 ID 的一批数据。这种方式更可控,也更容易监控进度。
4.3 什么时候该用 TRUNCATE 重建表
如果删除的数据占比太高(比如超过了表中 50% 的行数),分批 DELETE 可能仍然很慢,因为死元组的清理和索引的维护成本很高。这种情况下,如果业务允许,可以选择"重建表"的策略:
- 创建一个新表,结构与原表一致(
CREATE TABLE new_table (LIKE old_table INCLUDING ALL)) - 将需要保留的数据插入新表(
INSERT INTO new_table SELECT * FROM old_table WHERE ...) - 在原表上获取 ACCESS EXCLUSIVE 锁,进行表替换(先 DROP 原表,再 RENAME)
- 重建索引、外键约束、触发器等
这个方案的本质是为了避免删大量数据时遍历全表加标记死元组的开销,只写一遍"保留数据",然后直接扔掉旧表。磁盘空间是瞬间释放的,效果立竿见影。当然代价是操作期间要停写或至少保证没有并发写入,否则会丢数据。
4.4 索引对 DELETE 的影响
你可能会觉得"索引只对查询有用,跟删除有什么关系?"关系太大了。DELETE 的 WHERE 条件能否高效命中目标行,直接取决于条件列上是否有合适的索引。如果没有索引,PostgreSQL 只能顺序扫描整张表去逐行匹配条件,时间成本是 O(N)。
在批量删除场景中,一个常见的优化技巧是:先删除索引,再执行 DELETE,最后重建索引。虽然听起来反直觉,但在删除超大比例数据时确实更快。原因在于:删除过程中索引维护的代价非常高,每次删除都要同步修改索引结构;而先删索引再重建,相当于只做一次全量索引构建,总消耗可能更低。
但这个方案也有代价:删除期间如果有其他查询依赖该索引,这些查询的性能会急剧退化。所以这种方法只适合业务低峰期使用。
sql复制-- 阶段一:删除所有非主键索引
DROP INDEX idx_logs_created_at;
DROP INDEX idx_logs_user_id;
-- 阶段二:分批删除数据(代码略)
-- 阶段三:重建索引
CREATE INDEX idx_logs_created_at ON logs(created_at);
CREATE INDEX idx_logs_user_id ON logs(user_id);
另外,对于 WHERE 条件常涉及组合字段的表,可以考虑用复合索引来加速删除条件的过滤。比如经常按 (user_id, created_at) 范围删除数据,创建一个复合索引 (user_id, created_at) 比单独在 user_id 和 created_at 上分别建索引效率更高。
4.5 autovacuum 的配合
大批量 DELETE 之后,死元组大量堆积,autovacuum(自动清理进程)会在一定阈值后被触发。默认配置下,autovacuum 的阈值是"表大小加 20% 的死元组比例"。对于超大表,这个阈值可能需要很久才触及,导致死元组堆积时间过长。
你可以主动执行 VACUUM 来加速清理,但要注意 VACUUM 和 VACUUM FULL 的区别:
VACUUM:只清理死元组并把空页加回空闲空间映射,不物理压缩文件,不会长时间锁表,业务可以继续执行VACUUM FULL:重写整个表的物理文件,回收空间给操作系统,但会阻塞对该表的读写,适合数据大量删除后彻底压缩表体积
我一般建议:大批量 DELETE 后,先跑一次普通 VACUUM,让统计信息和空闲空间映射保持更新;如果表体积确实太大,且业务可接受停机窗口,再考虑 VACUUM FULL。
5. 误删数据的救援:能救回来的方案和救不回来的局面
5.1 延迟提交与事务回滚
先说最理想的情况:DELETE 和误操作之间隔了还没提交。你执行了 DELETE,但没有 COMMIT,发现数据没了,这时候救赎之道很简单——ROLLBACK 回滚即可。
但绝大多数误删场景是 DELETE 之后工作人员习惯性地提交了,或者 DELETE 在自动化脚本里已经自动提交了。这个时候怎么办?下面有几个方案,按可行性排序。
5.2 依赖 PITR:备份是最后一道防线
如果你有做持续归档(WAL archiving),可以实施时间点恢复(PITR,Point-In-Time Recovery),把数据库还原到误删之前的某个时刻。步骤简述如下:
- 找到误删的大致时间,比如 14:30 误删,你在 14:00 有全量备份,备份后到 14:30 的所有 WAL 日志都还在
- 用备份集启动一个临时实例,配置
recovery_target_time = '2024-01-15 14:29:00' - 启动恢复进程,等待达到目标时间点
- 把恢复出的目标表数据导出,再导入生产库
这个过程说起来简单,但实操中坑不少:
- 需要确保 WAL 归档完整,尤其在持续归档配置不完善时,可能缺失某个时段日志,导致恢复失败
- 恢复过程中无法精确到"删掉的那一条语句之前",只能恢复到某个时间点,时间点之后的所有写入也会丢失
- 如果误删之后还有大量新数据写入,那么恢复出的数据 + 这段时间产生的新数据需要做合并对比,工程量大
所以正确姿势是:你能恢复,但不一定恢复得"干净"。无论如何,完善的备份归档机制是 PostgreSQL 生产环境的前提条件,这点真不是吓唬你,是常年跟数据打交道得出的血泪经验。
5.3 pg_dirtyread 与修复工具
如果没有做 WAL 归档,但数据库文件还在,死元组尚未被 VACUUM 清理,那么理论上可以通过第三方工具 pg_dirtyread 来读取数据文件中的可见死元组。
pg_dirtyread 不是一个官方模块,而是一个社区扩展工具,它提供 pg_dirtyread 函数,可以绕过 MVCC 的可见性规则,直接读取表中物理存在的行(包括已经删除但尚未清理的行):
sql复制-- 安装扩展(在数据库服务器上执行)
CREATE EXTENSION pg_dirtyread;
-- 读取被删除的行(包括当前可见和不可见的)
SELECT * FROM pg_dirtyread('orders')
WHERE id = 10086;
但有几个前提条件:删除操作发生之后不能执行过 VACUUM(手动或自动),否则死元组会被清理掉,物理行就真的没了;表结构没有发生过变更;表没有被 TRUNCATE 或 DROP。说白了,它是一个"事故现场救援"工具,时效性很关键——误删后越早停手越有利。
还有一个更底层的思路:直接分析 WAL 日志或者数据文件,使用专门的数据恢复工具。但这种方法对专业技能要求极高,通常是专业 DBA 或者数据恢复公司才做得到,而且不保证 100% 成功。所以打铁还需自身硬,备份才是硬道理。
5.4 防误删的三大安全手段
与其事后补救,不如事前预防。下面三招是我在项目里强制团队执行的:
第一,给关键表设置删除保护。PostgreSQL 没有直接的内置机制禁止 DELETE,但可以通过触发器在特定条件下抛出异常。比如:
sql复制CREATE OR REPLACE FUNCTION prevent_critical_delete()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'DELETE is not allowed on % table without explicit approval', TG_TABLE_NAME;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_prevent_delete
BEFORE DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION prevent_critical_delete();
如果你真想删除,可以先把触发器停掉再删:ALTER TABLE accounts DISABLE TRIGGER trg_prevent_delete;。
第二,强制要求 DELETE 必须带 WHERE。这个在 PostgreSQL 中没有直接的配置项,但可以通过自定义的封装或者代理层来做。有些团队会开发一个 SQL 审核中间件来拦截不带 WHERE 条件的 DELETE 语句,这不算少见。
第三,使用逻辑备份加时点恢复的自动化产品,甚至考虑用专业的数据库同步软件做跨库实时备份。像热搜里提到的 MySQL/SQLServer/PostgreSQL 数据库同步软件,它们通常基于日志解析(如 PostgreSQL 的 logical decoding)将变更实时同步到另一个实例,一旦主库误删,备库的数据还在。这个思路在金融、电商等高要求场景下非常常见。
6. 常见问题与排查技巧:DELETE 相关的典型故障实录
6.1 删除卡住不动,大概率是锁等待
症状:执行 DELETE 后一直没返回,也不报错。这种情况下,大概率是有其他事务持有了表或行上的锁。
排查方法:查询 pg_stat_activity 和 pg_locks 视图,找出阻塞源:
sql复制SELECT
blocked.pid AS blocked_pid,
blocking.pid AS blocking_pid,
blocked.query AS blocked_query,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid
JOIN pg_stat_activity blocking ON blocking.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
运行的常见做法是:先看一下 pg_stat_activity 里 state = 'active' 且 wait_event_type = 'Lock' 的会话,然后 pg_terminate_backend(pid) 干掉阻塞事务,或者等它自然结束。
经验教训:长时间未提交的事务是 DELETE 卡顿的头号元凶。我会在项目里给所有事务性操作设置超时,比如 statement_timeout 和 lock_timeout,避免一个失控事务拖垮所有人。
6.2 DELETE 执行慢且 CPU 高
如果 DELETE 语句没有卡在锁上,但执行时间非常长、CPU 占用高,那通常是以下原因之一:
- WHERE 条件列缺少索引,全表扫描导致 CPU 高
- 数据量太大,且每行删除触发了额外的操作(触发器、外键检查)
- 表上有大量索引,删除行时索引维护成本高
- 并发量大,锁竞争严重
排查建议:
- 用
EXPLAIN ANALYZE DELETE FROM ...看执行计划,观察是否走了索引扫描。这个步骤能直接看出问题。 - 检查是否有触发器,触发器中是否做了复杂的业务逻辑。如非必要,删除数据时可以先临时禁用触发器。
- 检查该表的外键引用关系。如果有很多子表引用了这张表且没有很好的索引,删除一行可能要检查多张子表的索引,耗时成倍增加。
6.3 删除大量数据后查询变慢
这是一个很容易被忽视的"后遗症":DELETE 执行完了,删除的数据也不多,但后续 SELECT 查询反而变慢。
原因分析:DELETE 产生大量死元组后,InnoDB(这里换成 PostgreSQL)的 visibility map 中对于被删除页面尚未标记为"全部可见",后续的索引扫描还需要检查每一行的 xmax 与当前事务的快照版本,比较开销增大。同时,表和索引膨胀导致数据分布的物理连续性变差,顺序扫描变慢。
解决办法:执行 VACUUM ANALYZE,既清理死元组,又更新统计信息。如果你的表修改频繁,还应该调低 autovacuum 的触发阈值,避免死元组堆积过多。
6.4 高可用场景下 DELETE 引发的备机延迟
在 PostgreSQL 高可用部署中,主库执行大批量 DELETE 会生成大量 WAL,备机需要同步回放。如果备机性能弱于主机,或者回放速度跟不上,就会出现复制延迟(replication lag)。严重时,备机查询的数据还是几十分钟前的老数据。
解决方案:
- 分批删除控制 WAL 生成速率,别一口气全删完
- 检查备机的磁盘 IO 和 CPU 资源,必要时升级备机硬件
- 监控
pg_stat_replication视图,观察replay_lsn与主库的current_wal_lsn之间的差距
值得注意的是,在同步复制模式下,主库的 DELETE 提交会等待备机确认落盘,所以大批量 DELETE 的速度会被备机的回放能力拖慢。这是高可用架构下的一个特殊约束,设计时要提前评估。
6.5 误删后的心态与流程建议
最后分享一点实操经验。误删数据后,最忌讳的是慌乱中继续做操作。正确流程是:
- 立即停止所有写入操作,最好直接把应用切到只读模式
- 冻结相关表,防止 autovacuum 自动清理死元组——这个非常关键,很多自救方案的前提就是死元组还在
- 尽快评估备份情况,确定恢复策略
- 通知相关方,准备事故报告
在 PostgreSQL 里,如果误删的是大表且没有备份,尽快对表执行 ALTER TABLE table_name SET (autovacuum_enabled = false) 来阻止自动清理,这能为救援争取时间。
7. 一个 DELETE 的完整实战案例:从需求到落地
7.1 需求描述
假设你在负责一个电商系统,用户表 users 有 1200 万行,订单表 orders 有 8000 万行。产品经理提了一个需求:清理 2020 年之前注册、且从未下过单的用户数据。要求尽量不影响线上业务,数据清理后要能出报告,证明删了多少行。
7.2 方案设计
这个需求里有三个关键点:时间边界、是否存在订单、不影响线上。
时间边界上,我定义为 registered_at < '2020-01-01'。
是否存在订单,用 NOT EXISTS 来关联查询。注意不要用 NOT IN,因为 orders 表的 customer_id 如果存在 NULL,NOT IN 的结果会出现非预期行为——NULL 会使 NOT IN 永远为假。这是个经典坑。
不影响线上,就需要分批删除 + 控制锁粒度 + 避免高峰期操作。
7.3 SQL 实现
第一步,先确认目标数据量:
sql复制SELECT COUNT(*)
FROM users u
WHERE u.registered_at < '2020-01-01'
AND NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = u.id
);
第二步,分批删除。删除前先备份目标用户的 ID 到一个临时表,这样即使删除中途出问题,也能定位问题范围:
sql复制CREATE TEMP TABLE temp_users_to_delete AS
SELECT u.id
FROM users u
WHERE u.registered_at < '2020-01-01'
AND NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = u.id
);
第三步,用循环分批删除:
sql复制DO $$
DECLARE
v_batch_size INTEGER := 1000;
v_deleted INTEGER;
v_cursor CURSOR FOR
SELECT id FROM temp_users_to_delete;
v_user_id INTEGER;
BEGIN
OPEN v_cursor;
LOOP
FETCH FORWARD v_batch_size FROM v_cursor INTO v_user_id;
EXIT WHEN NOT FOUND;
DELETE FROM users WHERE id = v_user_id;
GET DIAGNOSTICS v_deleted = ROW_COUNT;
PERFORM pg_sleep(0.1);
RAISE NOTICE 'Deleted % users in current batch', v_deleted;
END LOOP;
CLOSE v_cursor;
END $$;
当然,每个批次删除后需要提交才能真的释放资源和控制锁的持有时间。上面的 DO 块默认在一个事务里,所有删除一次性提交。如果要每批独立提交,需要用脚本(如 Bash + psql)来调,或者用 dblink / fdw 的方式实现自治事务。这就是为什么很多自动化清理任务会使用外部脚本而不是纯数据库函数的原因。
7.4 复盘与验证
删除完成后,生成报告:
sql复制SELECT
COUNT(*) AS total_deleted,
COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) AS confirmed
FROM users_audit_log;
为了让报告更可靠,我在设计时给 users 表加了一个 deleted_at 字段(软删除标记),清理任务会把删除前的数据快照写入审计表,这样能追踪每一条被删除的记录。这不是必须的,但在合规敏感的业务中非常推荐。
8. 写在最后的经验之谈
我在实际项目里踩过不少 DELETE 的坑,有几次甚至到了"心跳骤停"的边缘。现在回头总结,核心就这几条:
第一,DELETE 之前先用 SELECT 确认一下 WHERE 条件的结果集,花不了几秒钟,但能规避大部分误删风险。我自己的习惯是,永远先跑 SELECT COUNT(*) 看看要删多少行,再跑 EXPLAIN 看看执行计划,最后才真正执行 DELETE。
第二,大批量删除永远分批做,不要一次性 DELETE 全表。哪怕你觉得"数据量也不大,就几万行",也不建议一股脑全删——考虑到并发业务的影响,分批提交永远更稳。
第三,备份和恢复能力要在平时就反复演练。等到出事了才发现备份不可用,那是整个项目最绝望的时刻。我在每个 PostgreSQL 集群都配置了连续的 WAL 归档,并且每季度做一次恢复演练,这个过程虽然花时间,但值。
最后再分享一个小技巧:如果你需要频繁执行 DELETE 来清理历史数据,不如给表设计一个分区——按时间分区的表,清理旧数据时直接 DROP 分区(或者 DETACH PARTITION),速度远超 DELETE,而且不会产生死元组和膨胀问题。很多人大批量删除慢,本质上是表结构设计没有考虑数据生命周期,用删除代替了归档。方案层面想明白,远比死磕 SQL 优化更高效。
