PostgreSQL 更新一行时会产生新版本,删除则让旧版本对后来的快照不可见。但这不意味着所有较早开始读取的事务都已经用完旧版本。普通清理,包括自动回收工作者发起的清理,都必须遵守这个可见性边界,之后才能复用空间。把清理任务安排得更频繁,不能越过仍然有效的读取快照。

坏掉的样子

一个持续很久的快照,可能在删除事务提交以后仍然需要旧行。因此,清理命令可以正常执行,却没有回收预期数量的版本。单纯降低自动回收触发门槛,也不会让这些版本突然变成可删除。应先检查长事务和其他持有回收边界的对象,例如复制槽保留的 xmin,再判断是不是工作者吞吐不足。

回收元组空间,通常意味着同一张表以后可以复用这些位置,并不必然让文件长度下降。普通清理可以截断文件末尾连续的空页,却不会把中间所有存活行搬到一起、整理掉每个孔洞。因此,清理之后磁盘文件大小不变,可能是正常的内部空间复用,不能仅凭这个现象宣布任务没有发挥作用。

快速验证

本轮没有重跑原样保留的双会话快照与清理实验。早先的 PostgreSQL 观察属于历史记录,单用户后端无法复现保留读者的边界。

准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验只在自己的测试表上暂时关闭自动回收,并手动执行普通清理以控制时序。它验证的是自动与手动清理共同遵守的可见性规则,不把手动命令当作自动调度已经运行的证据。乙先创建五千行数据:

CREATE SCHEMA vacuum_demo;
CREATE TABLE vacuum_demo.items (id integer PRIMARY KEY,payload text)
  WITH (autovacuum_enabled=false);
INSERT INTO vacuum_demo.items
SELECT g,repeat(md5(g::text),10) FROM generate_series(1,5000) AS g;
SELECT pg_stat_force_next_flush();
VACUUM (ANALYZE) vacuum_demo.items;
CREATE TABLE vacuum_demo.baseline AS
SELECT pg_relation_size('vacuum_demo.items') AS heap_bytes;

统计刷新辅助函数让待发布计数在下一个机会写出,这里用于减少短实验中的观察滞后。基线记录的是堆文件字节数,不包括索引和其他分支文件。甲开启可重复读事务,读取五千行后保持事务打开:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM vacuum_demo.items;

乙删除前四千行,发布写入统计,再执行清理:

DELETE FROM vacuum_demo.items WHERE id<=4000;
SELECT pg_stat_force_next_flush();
VACUUM (VERBOSE) vacuum_demo.items;
SELECT pg_stat_force_next_flush();
SELECT count(*) FROM vacuum_demo.items;
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relid='vacuum_demo.items'::regclass;

乙此时只能看到一千行;本次隔离实验中的死元组估计仍为四千。统计视图提供的是估计值,不能把这个数字直接当成精确物理字节数。现在回到甲,在结束事务之前再次读取:

SELECT count(*) FROM vacuum_demo.items;
COMMIT;

甲仍然看到五千行。这就是不能直接擦掉旧版本的原因:已有快照仍然有权读取它们。甲提交之后,乙再执行一次清理:

VACUUM (VERBOSE) vacuum_demo.items;
SELECT pg_stat_force_next_flush();
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relid='vacuum_demo.items'::regclass;
SELECT pg_relation_size('vacuum_demo.items')=heap_bytes AS same_heap_size
FROM vacuum_demo.baseline;

死元组估计降到零,但堆文件大小与基线相等。因为存活行还位于原文件靠后的位置,清空前部空间并不能截短这个文件。旧快照已经释放,删除版本不再需要保留;文件大小没有下降,也不是删除数据库文件或强制重写的理由。需要继续观察的是这些空间能否正常承接后续写入。

乙插入四千条新记录,并核对回收出来的位置是否得到复用:

INSERT INTO vacuum_demo.items
SELECT g,repeat(md5(g::text),10) FROM generate_series(5001,9000) AS g;
SELECT count(*),min(id),max(id) FROM vacuum_demo.items;
SELECT pg_relation_size('vacuum_demo.items')=heap_bytes AS reused_existing_heap
FROM vacuum_demo.baseline;
DROP SCHEMA vacuum_demo CASCADE;

预期总数恢复到五千,编号范围是四千零一到九千,文件大小比较仍返回真。本实验中新旧行宽接近,新增记录能够放进已有堆空间,不必继续扩展文件。实际负载中的行宽、并发活动和空闲位置分布不同,大小结果也可能不同,不能把这里的布尔值当成所有工作负载的永久保证。最后删除实验模式,也就一起移除了表级自动回收覆盖设置。

分别观察移除、复用与调度资格

死元组估计描述旧版本数量,自动清理时间记录后台任务曾经执行过;两者都不能单独证明操作系统收回了多少磁盘容量。应结合版本保留情况、关系文件增长、清理耗时和业务延迟判断。手动清理时间与自动清理时间也要分开看,尤其在短实验中,需要考虑累计统计尚未发布和统计快照缓存带来的延迟。

自动回收还负责维护可见性信息,并防止事务编号回绕。即使表主要只有插入,没有更新或删除积压,也可能需要清理。因此,长期关闭自动回收并不是解决维护负载的可靠办法。反过来,一张安静的表很久没有自动清理,也不能直接判定它坏了。

正确排查顺序是检查是否满足触发条件、是否存在表级覆盖、工作者是否有容量,以及锁和可见性边界是否阻碍推进。确认是哪一环限制清理后,再调整触发门槛或工作能力。旧快照不释放,与工作者处理速度赶不上写入,是两种不同问题;把它们都归为“后台任务不够勤快”,会让调参没有针对性,还可能增加额外扫描成本。

来源