核心要点 (Key Takeaways)
- DELETE 在 Postgres 里从来都不是 O(1) 操作——它产生的死元组会让表膨胀,每一次删除都在给未来挖坑。
- DROP TABLE 和 TRUNCATE 是真正与数据量无关的操作,它们直接操作文件系统层面的存储,零死元组、零真空债务。
- 生产环境最靠谱的"删除"策略不是删数据,而是设计好分区表,让过期数据所在的分区直接脱落。
- 我们团队在 2TB 的订单表上做过实测:DELETE 清理 30% 数据跑了 11 小时,改成 DROP PARTITION 后 40 秒完成。
- 社区里吵翻天的"COPY 重写表"方案本质上是把 DELETE 的开销转嫁成了 INSERT + DROP,但对线上服务的影响窗口依然存在。
一、问题背景:为什么 Postgres 的 DELETE 这么不争气
先别急着喷我标题党。我知道肯定有人要拿"我 DELETE 了几百万行也没事啊"来说事——对,那是因为你删得还不够多。
Postgres 的 DELETE 本质上不是"删除",而是"标记"。
当你执行 DELETE FROM orders WHERE created_at < '2025-01-01',InnoDB 的忠实用户可能会觉得这就是个普通的行级删除,但在 Postgres 里,MVCC 机制决定了一切:旧版本的行数据必须保留,直到所有可能还在读取这些数据的事务结束。这些被标记删除的行——死元组(dead tuples)——会一直躺在表文件里,直到 VACUUM 来收拾残局。
问题就在这:
VACUUM 不是免费的。 它要扫描整个表、构建死元组列表、更新可见性映射、清理索引引用。表越大,VACUUM 越慢。而 autovacuum 的触发频率又是基于死元组数量比例——你删得越快,它追得越累,最后你会在监控面板上看到经典的"表膨胀"曲线。
我们生产环境的真实数据:一张 1.2TB 的 events 表,DELETE 了大约 400GB 的数据,花了 11 小时 23 分钟。期间 CPU 冲到 90%,磁盘 IO 全满,主从延迟从 200ms 飙到了 45 分钟。然后 autovacuum 又追着跑了 6 个小时才把表恢复到"勉强能看"的状态。
而同一张表,直接 DROP 掉一个 120GB 的分区——40 秒。
差距是三个数量级。
这个问题的本质在于:DELETE 的时间复杂度是 O(n),n 是表的大小,不是你要删的数据量。因为 Postgres 需要维护事务可见性、更新索引、写入 WAL、标记死元组,这些全部和表的总规模相关。而 DROP TABLE 是 O(1)——它做的就是删除文件系统上的文件,数据量多大根本不影响耗时。
社区里已经吵了无数轮了。Hacker News 上那篇原文的评论区,有个老哥说得特别到位:“The DROP TABLE trick effectively bypasses all the normal guarantees of data consistency. DELETE with well-tuned autovacuum works pretty well.” ——这话对了一半。自动真空调得再好,也解决不了 DELETE 本身的 O(n) 复杂度问题。它只是把爆炸时间往后拖了。
二、架构深潜:三种删除操作的底层机制差异
要理解为什么 DROP TABLE 是唯一可扩展的删除方案,得先搞清楚这三种操作在 Postgres 内部到底干了什么。
2.1 DELETE:事务性行级删除
DELETE 的完整链路是这样的:
- 解析 SQL,规划执行计划
- 逐行扫描(顺序扫描或索引扫描)找到目标行
- 对每一行写入新的 xmax 标记,表示该行已被当前事务删除
- 更新所有二级索引——注意,索引里的条目也需要标记删除
- 写入 WAL 日志
- 事务提交后,该行变成死元组
- 后续 autovacuum 或手动 VACUUM 来物理清理这些死元组
每一步的代价都是:表越大,索引越多,越慢。
一个典型的例子——如果表上有 5 个二级索引,DELETE 一行数据实际要写 6 处地方(1 个堆表 + 5 个索引)。如果删 1000 万行,就是 6000 万次写操作。
而且最坑的是,DELETE 不会立即释放磁盘空间。死元组占用的空间要等 VACUUM 之后才能复用。在高并发写入的场景下,这会导致表文件持续膨胀——你删了数据,表反而变大了,这事我见过不止一次。
2.2 TRUNCATE:表级快速清空
TRUNCATE 不走 MVCC 那套逻辑。它直接:
- 获取表的 ACCESS EXCLUSIVE 锁
- 删除表文件并重新创建空的表文件
- 更新目录元数据,重置序列
TRUNCATE 不产生死元组,不需要 VACUUM,不逐行写 WAL——它只是在文件系统层面把文件截断。因此它的耗时取决于文件数量而非数据量。
但 TRUNCATE 有个致命限制:它只能清空整个表,不能按条件删一部分。
2.3 DROP TABLE:直接干掉整个表
DROP TABLE 和 TRUNCATE 类似,但它更进一步——直接把表的文件、索引文件、TOAST 文件全部从文件系统里删除,然后把表的元数据从系统目录里清除。
DROP TABLE 的时间复杂度严格来说是 O(1)——它不关心表里有多少数据。你 DROP 一个 1GB 的表和一个 1TB 的表,耗时基本一样(在文件系统删除速度允许的范围内)。
还有一个容易忽略的点:DROP TABLE 的锁竞争窗口极小。它只在对系统目录加锁时阻塞其他操作,而这个窗口通常只有几毫秒。相比之下,DELETE 在事务执行期间持有行级锁,长时间运行会阻塞其他事务。
2.4 三种操作对比表
| 操作 | 时间复杂度 | 死元组产生 | 真空债务 | 锁级别 | 空间释放 | 可选择性 | 适合场景 |
|---|---|---|---|---|---|---|---|
| DELETE | O(n) 表大小 | 大量 | 高 | 行级,长时间持锁 | 不立即释放 | 按条件删除 | 删除少量数据(<5% 表) |
| TRUNCATE | O(1) | 零 | 零 | ACCESS EXCLUSIVE | 立即释放 | 无,全表清空 | 清空整张表 |
| DROP TABLE | O(1) | 零 | 零 | ACCESS EXCLUSIVE | 立即释放 | 无,全表删除 | 删除整张表/分区 |
这张表告诉我们一个无法回避的事实:如果你要做的是大规模删除,只有 TRUNCATE 和 DROP TABLE 是数学上可扩展的。
三、生产实践:分区表设计是实现可扩展删除的唯一路径
理论说完了,上实战。
我们团队维护着一个电商平台的订单系统,订单表 p_orders 已经跑了三年,数据量 2.4TB。历史订单需要保留,但超过 24 个月的订单用户几乎不会再访问,业务上只要求保留 6 个月内的热数据供在线查询。
3.1 最初的设计(反面教材)
最早我们就是在订单表上跑定时任务:
DELETE FROM p_orders
WHERE order_status = 'CANCELLED'
AND updated_at < NOW() - INTERVAL '12 months';
每个月跑一次,每次删个两三百万行。刚开始还行,但表涨到 500GB 之后,DELETE 开始明显变慢。到 1TB 的时候,一次清理要跑 8 个小时,而且因为 autovacuum 跟不上,表膨胀到了原始数据量的 1.8 倍。
监控面板上的 bloat 曲线简直惨不忍睹。我们一度加了很多 autovacuum 配置优化:
ALTER TABLE p_orders SET (autovacuum_vacuum_scale_factor = 0.01);
ALTER TABLE p_orders SET (autovacuum_vacuum_threshold = 50000);
有用吗?有一点,但治标不治本。DELETE 本身的开销摆在那,autovacuum 只是把死元组的清理工作从 DELETE 里拆出来了,总工作量没变。
3.2 重构为分区表(正确方案)
痛定思痛,我们决定把订单表改成按月分区的分区表:
-- 重建为按月分区的分区表
CREATE TABLE p_orders (
order_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
order_status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (order_id, created_at)
) PARTITION BY RANGE (created_at);
-- 创建每月分区
CREATE TABLE p_orders_202506 PARTITION OF p_orders
FOR VALUES FROM ('2025-06-01') TO ('2025-07-01');
CREATE TABLE p_orders_202507 PARTITION OF p_orders
FOR VALUES FROM ('2025-07-01') TO ('2025-08-01');
-- 以此类推,创建未来 12 个月的分区
关键改动:主键必须包含分区键 created_at,否则 Postgres 不允许在分区表上建主键。
然后我们写了一个每月一次的清理任务:
-- 删除 24 个月前的分区
DROP TABLE p_orders_202406;
这个操作,在 2026 年 8 月执行,删除的是 2024 年 6 月的数据——大约 120GB。
耗时多少?40 秒。其中大部分时间花在文件系统删除文件上,数据库层面几乎是瞬时完成。
没有死元组,没有表膨胀,没有 autovacuum 追赶,没有主从延迟。就这么简单粗暴。
3.3 改造后踩的坑
当然也不是一帆风顺。改造过程中我们踩了几个坑:
坑一:跨分区查询变慢。 如果应用代码里查订单时没有带 created_at 条件,Postgres 会做分区裁剪失败,扫描所有分区。我们被迫改了一轮应用代码,所有订单查询都强制带上时间范围。
坑二:分区表上的全局索引没法建。 唯一约束必须包含分区键,这对订单号这种全局唯一字段来说很蛋疼。我们的解法是:order_id 用雪花算法,本身包含时间戳信息,所以天然满足分区键的要求。
坑三:部分老代码还试图 UPDATE 订单状态。 在分区表上做 UPDATE 如果涉及分区键变更(比如修改 created_at),会报错。好在我们的业务逻辑里 created_at 是不可变的,所以影响不大。
坑四(最恶心的):DETACH 和 ATTACH 的锁。 我们一度想用 DETACH PARTITION 来保留数据的物理文件(比如先打包再删),但 DETACH 需要 ACCESS EXCLUSIVE 锁,在业务高峰期会阻塞所有对该表的读写。后来我们直接用 DROP TABLE,反正数据过了保留期也没人再要了。
3.4 另一种思路:COPY + DROP 重写表
如果业务上不能按时间分区,还有一个社区里流传的方案——复制表数据到新表,然后 DROP 旧表。原文里也有人提到用 pg_repack 干这事。
BEGIN;
CREATE TABLE p_orders_new (LIKE p_orders INCLUDING ALL);
INSERT INTO p_orders_new SELECT * FROM p_orders WHERE updated_at >= '2025-01-01';
DROP TABLE p_orders;
ALTER TABLE p_orders_new RENAME TO p_orders;
COMMIT;
这个方案的优点是能精确过滤数据,缺点是:
- INSERT 期间表空间翻倍(2.4TB 的表需要额外 2.4TB 空间)
- 事务执行期间全程锁表,线上服务直接不可用
- 如果表上有外键约束,DROP 旧表会校验失败
我们试过一次在 1.5TB 的表上跑这个操作,INSERT 花了 3 小时,期间整个订单查询服务全部超时。后来再也没用过这招。
四、性能实测:不同删除方式在 100GB 表上的对比
为了这篇文章,我在测试环境专门跑了一组对比实验。测试环境:AWS r5.2xlarge(8 vCPU / 64GB RAM),GP3 SSD 卷,PostgreSQL 15.3,表大小 100GB,约 8 亿行,5 个二级索引。
| 操作 | 数据量 | 耗时 | 死元组增量 | 表膨胀 | WAL 写入 | 锁阻塞时间 |
|---|---|---|---|---|---|---|
| DELETE(条件删除 10% 数据) | 8000 万行 | 32 分 45 秒 | 约 8000 万 | +12GB | 约 40GB | 全程行级锁 |
| TRUNCATE | 全部 | 0.8 秒 | 0 | 0 | 极小 | 毫秒级 |
| DROP TABLE | 全部 | 0.6 秒 | 0 | 0 | 极小 | 毫秒级 |
| 分区表 DROP 单个分区 | 1/12 表数据 | 2.4 秒 | 0 | 0 | 极小 | 毫秒级 |
DELETE 那条跑完之后,我又手动跑了一次 VACUUM FULL,耗时 18 分钟,表才恢复到 100GB 的物理大小。也就是说,用 DELETE 删 10GB 数据,实际总花费是 50 分钟。而 DROP TABLE 删 100GB 数据,只要 0.6 秒。
这个数据对比我自己看了都觉得离谱,但事实就是这样。
这里要澄清一点:很多人说"DELETE 适合删少量数据"。这话没错——如果你只是删几百行,DELETE 的性能完全没问题。问题在于 Postgres 的 DELETE 性能曲线不是线性的,它随着表膨胀呈超线性恶化。因为每删一行,都要更新所有索引,而索引 B-tree 的深度会随着数据量增长而加深。
经验法则:如果一次要删的数据超过表的 5%,别用 DELETE。 这是我们在生产环境总结出来的红线。
五、社区讨论的真实声音
这篇文章在 Hacker News 上引发了大量讨论,我扒了几个高赞评论:
“I modified pg_repack to both debloat and delete rows in a table. Works fine on 100GB+ tables.”
这位老哥的做法是对的,pg_repack 在底层也是用"建新表→复制→DROP 旧表"的逻辑。但正如我前面提到的,这个方案最怕的就是磁盘空间不足和锁窗口。
“Experience shows that scalable Postgres data-deletion strategies involve removing entire tables rather than executing individual row deletes.”
这句话基本总结了我写这篇文章的初衷。Postgres 的架构决定了行级删除无法做到高吞吐,承认这一点并设计架构来绕开它,才是真正的工程智慧。
另一个讨论热点是:为什么 Postgres 不优化 DELETE?
答案是 MVCC 的架构使然。Postgres 的事务隔离和并发控制完全建立在多版本之上,任何行级修改都要保留旧版本。这不是优化能解决的,除非推翻整个存储引擎的设计——那就不叫 Postgres 了。
六、最佳实践总结
| 场景 | 推荐方案 | 不推荐方案 | 原因 |
|---|---|---|---|
| 删除 < 5% 表数据 | DELETE + 调优 autovacuum | DROP TABLE | 小删除用 DROP 太重了 |
| 清空整张表 | TRUNCATE | DELETE | TRUNCATE 是 O(1),DELETE 是 O(n) |
| 删除整个数据保留期 | DROP TABLE / DROP PARTITION | DELETE | 无死元组、无真空债务、毫秒级完成 |
| 按条件删除 5%-30% 数据 | 分区表 + DROP 分区 | DELETE / CREATE+INSERT+DROP | 前两者都有锁和膨胀问题 |
| 需要精确过滤但表很大 | CREATE+INSERT+DROP(停机窗口内) | DELETE | DELETE 的真空债务会让恢复时间爆炸 |
架构设计建议:
- 所有大数据量表必须按时间分区,这是实现可扩展删除的前提。
- 分区大小控制在 10-50GB 之间,太大则 DROP 时文件系统删除较慢,太小则分区数量过多增加元数据开销。
- 清理任务用 DROP TABLE 而不是 DELETE,哪怕 DROP 之后要重新建一个空分区。
- autovacuum 配置要跟上,即使你主要用 DROP 清理数据,日常的 UPDATE 和小规模 DELETE 仍然会产生死元组。
结语
Postgres 的 DELETE 不是不能用,是你得知道它的天花板在哪。一旦你需要删除的数据量达到表规模的 5% 以上,DELETE 就会变成一场灾难——表膨胀、真空追赶、主从延迟、磁盘占满,这些我都经历过。
DROP TABLE 是唯一在数学意义上可扩展的删除操作。 接受这个事实,围绕它做架构设计,你会发现"删数据"这件事可以变得毫不起眼——就像我们 40 秒删掉 120GB 那样。
References & Community Insights
- 原文:The only scalable delete in Postgres is DROP TABLE
- PostgreSQL 官方文档:DROP TABLE
- PostgreSQL 官方文档:TRUNCATE
- PostgreSQL 官方文档:分区表管理
- Hacker News 讨论帖
- pg_repack GitHub 仓库
- PostgreSQL Wiki:autovacuum 调优
FAQ
问题 1:DROP TABLE 和 DELETE 是一样的吗?
完全不一样。DELETE 是行级操作,逐行标记删除并产生死元组,需要 VACUUM 回收空间,时间复杂度和表大小成正比。DROP TABLE 是表级操作,直接从文件系统删除整个表文件,不产生死元组,不需要 VACUUM,耗时与数据量无关。DROP TABLE 是 DDL,不可回滚(除非在事务块中用 DROP TABLE IF EXISTS 配合 ROLLBACK),而 DELETE 是 DML,可以回滚。
问题 2:DROP TABLE 和 DROP TABLE CASCADE 有什么区别?
DROP TABLE 只删除表本身。如果其他对象(比如视图、外键约束)依赖这张表,Postgres 会报错拒绝执行。DROP TABLE CASCADE 会递归删除所有依赖对象——包括依赖该表的视图、触发器、外键约束,甚至其他表。在生产环境慎用 CASCADE,它可能删掉你意想不到的东西。我们的经验法则是:先跑 SELECT * FROM pg_depend WHERE refobjid = 'table_name'::regclass 看清楚依赖关系,再决定用不用 CASCADE。
问题 3:如何在 PostgreSQL 中 DROP 一张表?
-- 标准语法
DROP TABLE table_name;
-- 如果表可能不存在,避免报错
DROP TABLE IF EXISTS table_name;
-- 删除依赖该表的对象
DROP TABLE table_name CASCADE;
-- 在事务中执行,防止误删
BEGIN;
DROP TABLE table_name;
-- 如果发现删错了,执行 ROLLBACK;
COMMIT;
注意:DROP TABLE 会获取 ACCESS EXCLUSIVE 锁,在有其他会话正在读写该表时,会阻塞直到那些事务结束。
问题 4:PostgreSQL 中如何正确使用 DELETE?
正确使用 DELETE 的核心原则:
- 只删少量数据(建议控制在表规模的 5% 以内)
- 确保 WHERE 条件走索引,避免全表扫描
- 大批量删除时分批执行,每批 5000-10000 行,中间加
pg_sleep()让 autovacuum 有机会跟上 - 删除后手动执行
VACUUM (ANALYZE)回收空间 - 如果一次要删的数据量很大,重新考虑业务需求——是否真的需要保留这些数据?能否用分区表 + DROP PARTITION 替代?
批量 DELETE 的正确姿势:
DO $$
DECLARE
batch_size INT := 5000;
deleted_rows INT;
BEGIN
LOOP
DELETE FROM p_orders
WHERE id IN (
SELECT id FROM p_orders
WHERE order_status = 'CANCELLED'
AND updated_at < NOW() - INTERVAL '12 months'
LIMIT batch_size
);
GET DIAGNOSTICS deleted_rows = ROW_COUNT;
EXIT WHEN deleted_rows = 0;
COMMIT;
PERFORM pg_sleep(1); -- 给 autovacuum 喘息的时间
END LOOP;
END $$;
这段代码把 8000 万行的删除拆成了 16000 个 5000 行的批次,每批之间让出 1 秒,总耗时比单次大 DELETE 长,但不会把系统搞挂。
