在多版本存储里,删除只是记录变化、而不立刻释放空间。回收这些空位需要维护工作,一旦被跳过,代价就会悄悄累积。
删除不等于已经回收
UPDATE 或 DELETE 会留下旧行版本,直到相关快照都不再需要它们。长事务可以在其他会话持续修改数据时推迟这个时刻,但长事务本身不会凭空让一张没有写入的表膨胀。提交删除、旧版本不再可见、空间得到回收,是不同边界。不能只看删除语句成功,就认定这些页面已经可以重新分配。
普通 VACUUM 通常只是让表文件内部的空间可供新行复用,不会缩小文件。末尾完全空闲的页面有时能够截断;一般压缩则需要 VACUUM FULL 一类重写操作,付出额外空间和更强锁的代价。清理成功但文件大小不变,并不意味着失败。要把活数据、内部可复用空间以及真正归还操作系统的空间分别观察,才不会对正常状态反复执行昂贵重写。
清理与统计是不同任务
autovacuum 由启动进程和多个工作进程组成,并非一名常驻 worker 按固定时刻清完所有表。它根据活动估计和年龄相关规则决定是否执行 VACUUM 或 ANALYZE。ANALYZE 独立收集规划器分布统计,普通 VACUUM 不能替代它。插入驱动的工作与防回卷工作也说明,死元组数量并不是维护的唯一触发条件,不能因为暂时没有大量删除就忽略事务年龄。
基于代价的延迟,会在估计 I/O 工作累计到一定程度后暂停一段时间。代价上限不是“一次调用最多删掉多少行,达到后就退出”。阈值影响资格,worker 是否有空影响调度,存储能力和限流则影响推进速度。排查需要先判断卡在哪个阶段,不能把各种迟缓都归结为某个比例系数太大,也不能把模拟程序里的清理额度当成真实数据库参数。
坏掉的样子
第一类问题是产生旧版本的速度超过回收速度。连续几个观察窗口中,死元组估计与关系体积不断上涨。应结合已经完成的运行、取消情况、worker 可用性、I/O 与写入速率检查。资源确实成为瓶颈时,额外硬件可能有帮助;但重启数据库本身不会让仍需保留的版本变得可回收。在相同 I/O 上限下,降低阈值或增加 worker 数量,也不保证最终吞吐更高。
第二类问题是清理在跑,却因为旧快照或保留的 xmin 而无法回收特定版本。干预之前要检查事务及复制状态。事务开始得很早只是线索,不能证明它持有这次受影响的可见性边界。结束应用事务或删除复制槽可能中断工作,甚至让消费者需要重建,因此应先找到负责人、确认保留目的与恢复要求,而不是为了让监控数字下降就直接清除对象。
第三类问题是仅凭一个 n_dead_tup 值或一条慢计划断定膨胀。统计数量是估计,页面布局、可见性、选择率和统计过时都可能改变读取成本。应比较趋势、vacuum 报告和真实执行计划。文件较大但内部空间可复用,可能是健康稳态;死元组较少,也不排除版本保留或冻结问题。不能让单一计数替代完整因果判断。
快速验证
把程序保存为 vacuum_model.py,用 python3 vacuum_model.py 执行。它只用标准库对比触发条件、有限清理吞吐和完全无法回收的积压。工作单位与检查间隔都是人为设定。基础值加比例因子的触发方式,只借用了 autovacuum 的一种思路,省略 PostgreSQL 18 的最大阈值、插入触发、冻结与真实调度。RECLAIM_CAPACITY 是玩具模型的吞吐额度,不是 vacuum_cost_limit,也不能拿它的数值去配置生产数据库。
"""Illustrative cleanup-backlog model, not a PostgreSQL vacuum simulator."""
BASE_THRESHOLD = 50
SCALE_FACTOR = 0.2
CHECK_INTERVAL = 2
RECLAIM_CAPACITY = 1600 # arbitrary work units per model cleanup opportunity
LIVE_TUPLES = 10_000
THRESHOLD = BASE_THRESHOLD + SCALE_FACTOR * LIVE_TUPLES
def needs_vacuum(dead):
return dead > THRESHOLD
def run_workload(*, churn_per_tick, ticks, pinned=False):
dead = 0
runs = 0
max_dead = 0
for tick in range(1, ticks + 1):
dead += churn_per_tick # each update leaves one dead version
max_dead = max(max_dead, dead)
if tick % CHECK_INTERVAL == 0 and needs_vacuum(dead):
runs += 1
if not pinned:
dead = max(0, dead - RECLAIM_CAPACITY)
return {"dead": dead, "runs": runs, "max_dead": max_dead}
def main():
healthy = run_workload(churn_per_tick=400, ticks=20)
assert healthy["runs"] >= 1, "healthy workload should trigger vacuum"
assert healthy["dead"] < THRESHOLD, healthy
assert healthy["max_dead"] < 3000, healthy
overloaded = run_workload(churn_per_tick=1500, ticks=20)
assert overloaded["runs"] >= 1, "overloaded workload still triggers vacuum"
assert overloaded["dead"] > THRESHOLD, overloaded
pinned = run_workload(churn_per_tick=400, ticks=20, pinned=True)
assert pinned["runs"] >= healthy["runs"], (pinned, healthy)
assert pinned["dead"] == 400 * 20, pinned
assert pinned["dead"] > healthy["dead"], (pinned, healthy)
print(f"healthy: runs={healthy['runs']} dead={healthy['dead']} max={healthy['max_dead']}")
print(f"overloaded: runs={overloaded['runs']} dead={overloaded['dead']} max={overloaded['max_dead']}")
print(f"pinned: runs={pinned['runs']} dead={pinned['dead']}")
print("PASS: model threshold, limited throughput, and pinned backlog")
if __name__ == "__main__":
main()
两种语言的完整代码均在 Python 3.12.14 通过。健康模型最终剩一千六百单位,过载剩一万四千,完全受阻剩八千。健康负载触发几次清理后回到阈值以下;过载仍然触发,却越积越多;完全受阻的总量等于全部新产生工作。这三个不同结果说明,应区分“处理能力不足”和“暂时不能回收”,而不是看到清理运行过就认为问题已经消失。
这些数值是模型输出,不是 PostgreSQL 测量。真实 vacuum 不会因为达到这个人为额度就停止;真实旧快照也未必阻挡每一个死版本。程序没有连接数据库,没有重现 worker 调度、锁冲突或磁盘延迟,也不预测一次生产清理会耗时多久。理解这个对照之后,仍需从数据库取得证据,再决定问题属于吞吐、保留边界还是其他维护条件。
延伸阅读
来源
PostgreSQL:常规 vacuum,为什么、何时 vacuum,已核验 2026-10-15。
PostgreSQL:维护,保持表健康,已核验 2026-10-15。
PostgreSQL:监控统计,读活动指标,已核验 2026-10-15。
PostgreSQL 18 vacuum 代价延迟,依据估计 I/O 工作暂停,已核验 2026-10-15。