大量删除之后,PostgreSQL 表可能只剩少量记录,却仍保留很大的文件。普通清理让旧空间可以在表内复用,完全清理则把存活内容写入新文件,同时重建索引。更彻底的空间回收,需要另一套锁与容量预算,应作为有计划的维护操作,而不是一看到文件大小偏高就立刻执行的日常反应。
坏掉的样子
重写需要的是 ACCESS EXCLUSIVE 表锁,普通读取和写入都与它冲突。某个查询虽然已经执行完,但事务仍然打开、继续持有表锁,也会挡住重写。把它叫成独占行锁,或者以为只有写请求受影响,会低估实际中断范围。排队等待重写的强锁请求,还可能影响随后进入的其他取锁请求,不能只看当前命令有没有开始复制数据。
替代文件成功建立之前,原文件仍然存在,所以操作需要额外磁盘空间。等文件系统已经写满才着手完全清理,可能反而没有容纳新文件的余量。应提前评估替代堆表与索引、磁盘读写和日志影响,以及能够承受的持锁时长。常规自动回收不会自动替你执行这类完整重写。
快速验证
在同一个 PostgreSQL 18 可丢弃数据库中,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。清理命令必须在显式事务块之外执行。乙先创建五千行、删除前四千五百行,然后进行普通清理:
CREATE SCHEMA compact_demo;
CREATE TABLE compact_demo.items (id integer PRIMARY KEY,payload text)
WITH (autovacuum_enabled=false);
INSERT INTO compact_demo.items
SELECT g,repeat(md5(g::text),10) FROM generate_series(1,5000) AS g;
CREATE TABLE compact_demo.baseline AS
SELECT pg_relation_size('compact_demo.items') AS heap_bytes,
pg_relation_filenode('compact_demo.items') AS file_id;
DELETE FROM compact_demo.items WHERE id<=4500;
VACUUM (ANALYZE) compact_demo.items;
SELECT pg_relation_size('compact_demo.items')=heap_bytes AS same_heap_size,
pg_relation_filenode('compact_demo.items')=file_id AS same_file
FROM compact_demo.baseline;
两个比较都返回真:堆文件大小与文件标识没有改变。存活行位于原文件尾部,末尾不存在可以截断的连续空页,所以普通清理不能通过截断来缩小这个特定文件。这不代表普通清理永远不会缩文件;如果末尾确实存在可释放的空页,就可能归还相应空间。这里要区分样本布局和命令的一般能力。
甲读取剩下的五百行,并保持事务打开:
BEGIN;
SELECT count(*) FROM compact_demo.items;
甲没有执行任何更新,但仍持有对应表锁。乙给重写设置很短的取锁预算,避免实验无期限等待:
SET lock_timeout='100ms';
\set ON_ERROR_STOP off
VACUUM (FULL) compact_demo.items;
\echo full_state :SQLSTATE
\set ON_ERROR_STOP on
RESET lock_timeout;
预期得到 55P03。已有读事务阻止了更强表锁的获取。临时允许这次预期错误继续显示,是为了核对结果和完成清理;随后恢复会话原来的超时设置。这个毫秒预算只是实验值,不是对实际维护窗口的参数建议,也不能据此推算大表重写需要多长时间。
甲现在结束读事务,释放它持有的锁:
COMMIT;
乙可以执行重写,再检查存储标识和所有保留字段:
VACUUM (FULL,ANALYZE) compact_demo.items;
SELECT pg_relation_size('compact_demo.items')<heap_bytes AS smaller_heap,
pg_relation_filenode('compact_demo.items')<>file_id AS different_file
FROM compact_demo.baseline;
SELECT count(*),min(id),max(id),sum(id),
bool_and(payload=repeat(md5(id::text),10)) AS payloads_preserved
FROM compact_demo.items;
DO $$ BEGIN
IF (SELECT sum(id) FROM compact_demo.items)<>2375250
OR (SELECT count(*) FROM compact_demo.items)<>500
OR EXISTS (SELECT FROM compact_demo.items
WHERE payload<>repeat(md5(id::text),10)) THEN
RAISE EXCEPTION 'compaction changed application data';
END IF;
END $$;
DROP SCHEMA compact_demo CASCADE;
两个存储比较都返回真:堆文件更小,文件标识也变了。数据检查应返回五百行,编号范围四千五百零一至五千,编号总和为二百三十七万五千二百五十,所有载荷匹配也为真。断言同时检查记录数量与内容,因为文件变小本身并不能证明压缩过程保留了正确的数据。最后删除整个实验模式。
判断重写是否值得
如果后续插入本来就会使用现有空闲空间,保留这些容量可能比先付出重写代价、之后再次扩展文件更合适。应在负载趋于稳定后,评估持续存在的额外空间和访问成本。还要区分主堆文件、索引和辅助存储,因为一个笼统的表大小数字,可能混合了几类增长原因不同的对象。选择维护动作之前,先确认真正占空间的是哪一部分。
安排维护时,应确认业务可以接受独占锁,并在请求强锁之前检查长时间打开的读事务。流量低并不保证没有旧事务留下。取锁等待应有明确预算,失败后先观察原因,而不是无限重试;完成后同时验证数据和存储变化。日常仍要保持普通回收健康,避免严重积压变成每次都需要独占重写的维护循环。
这个实验在小表上展示了锁冲突、文件替换和行内容保留,却不能预测生产对象的重写耗时与额外容量。大索引、外部存储值和复制日志流量都会改变代价,需要结合实际对象测量。只有把可承受的锁窗口、磁盘余量和验证方式都纳入维护安排,文件压缩才是完整的操作,而不是一条碰运气的命令。
来源
- PostgreSQL 18 VACUUM,重写、额外空间、尾部截断与事务限制,已核验 2026-09-10。
- PostgreSQL 18 显式锁,强排他表锁与读取、写入的冲突关系,已核验 2026-09-10。
- PostgreSQL 18 常规清理,日常空间复用与例外性的完整压缩,已核验 2026-09-10。