在多版本存储里,删除只是记录变化、而不立刻释放空间。回收这些空位是后台的工作,一旦被跳过,代价就会悄悄累积。
autovacuum 的工作 在 PostgreSQL 的视角里,删除并不等于立刻腾出空间。被标记为「已死」的行,连同其它事务仍在引用的旧版本,会一直占着位置,直到有人去回收并复用。于是表可以活跃行数几乎不变却持续变胖,一个运行很久的大事务更可能把旧版本死死钉住,让一句「最近没怎么写」的数据库照样悄悄膨胀。要记住的重点是:回收并不是删除带来的即时效果,而是一桩需要单独排队、周期性处理的常态工作。
回收是更要维护的定时技能 PostgreSQL 靠一个常驻后台 worker 来干这件事:autovacuum 按自己的节律醒来,扫描堆积超过阈值的表,把已死的元组回收,同时顺手刷新规划器的统计信息。它既然是周期性的而不是随叫随到的,表的尺寸就可能一直没能被合适的节奏追上,进而一点点偏离健康体积。对策不是关掉它,而是让它按既定周期照常跑、在大批量删除后调整它反应的快慢,并盯住监控里的真实信号,而不是指望手工执行一次 VACUUM 就能交代一切。
维护关注什么 好的维护盯的是信号,而不是症状。可以观察死元组与活元组的比例、最老事务的年龄,以及距上次自动 vacuum 实际运行过去了多久,再为它们各设阈值,让一次集中删除总能在合理的时间窗内被回收。能按周期把空间还出来的集群,体积始终停在预想附近、索引保持有用,也会把「表越来越肥」从一场随时救火,退化成每周照例的干净活。
坏掉的样子 最常见的失败是写放大超过清理能力。写密集的表每次更新都会留下一个死版本,集中写入可以在短时间内制造出远超单个回收周期能处理掉的死元组。基于代价的限流、过长的唤醒间隔、过少的工作进程都会拉大这个缺口:死元组计数持续攀升、堆表与索引同步膨胀、顺序扫描被迫读入大量死页面、规划器统计信息也随之过期。此时增加机器或者重启数据库都不能把欠账还上,只有提高清理吞吐或者降低写入制造死元组的速度才能收敛。
第二种失败在监控上看起来一模一样,原因却完全相反:清理一直在跑,但一个旧快照把可见性边界钉住了,所以什么都收不回来。一个被遗忘的重复读事务、一个长期空闲却不提交的会话、一个保持着旧事务编号的复制槽,都会让死版本依然可见。把阈值调得更激进,或者手工多执行几次清理,都释放不了这些版本,死元组数居高不下,表继续变大。正确动作是去事务活动视图和复制槽视图里找到那个占着边界的持有者并释放它,而不是继续给自动清理加压。
第三种失败是误诊:把膨胀当成慢查询或者缺索引。团队不断加索引、改写语句,但执行计划里的堆页面读取量始终偏高,实际行数也和系统统计对不上。普通清理只负责把空间标记为可复用并刷新可见性信息,一旦长期跳过,每个查询都要替膨胀买单。动手调优之前,先看死元组比例和上次自动清理的时间,再决定是不是查询本身的问题。
快速验证 把下面程序保存为临时目录里的 vacuum_model.py,然后用 python3 vacuum_model.py 运行。它只用标准库模拟三个行为:堆积超过阈值才触发清理、每次运行受代价预算限制只能回收一部分、可见性边界被钉住时跑了也收不回来。其中的阈值公式对照文档里的基础值加比例系数的规则做了简化,数值只是为了演示量级,不是生产调优建议。
"""Simplified autovacuum threshold and cost-limit model (stdlib only).""" BASE_THRESHOLD = 50 SCALE_FACTOR = 0.2 NAPTIME_TICKS = 2 CLEAN_BUDGET = 1600 # max dead tuples one run reclaims (simplified cost limit) 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 % NAPTIME_TICKS == 0 and needs_vacuum(dead): runs += 1 if not pinned: dead = max(0, dead - CLEAN_BUDGET) 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: threshold triggers, cost limit falls behind, pinned xmin reclaims nothing") if __name__ == "__main__": main() 预期全部断言通过并输出通过提示。健康负载会触发若干次清理,最终死元组回落到阈值以下,最大堆积也有上界。过载负载每次检查都触发清理,最终仍然远高于阈值,这就是跟不上的特征。被钉住的负载触发次数不比健康负载少,但回收量为零,最终堆积恰好等于每轮制造量乘以轮数。该脚本已在系统自带解释器下验证通过,它不连接数据库,也不预测真实清理耗时。
...