在多版本存储里,删除只是记录变化、而不立刻释放空间。回收这些空位需要维护工作,一旦被跳过,代价就会悄悄累积。

删除不等于已经回收

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 调度、锁冲突或磁盘延迟,也不预测一次生产清理会耗时多久。理解这个对照之后,仍需从数据库取得证据,再决定问题属于吞吐、保留边界还是其他维护条件。

延伸阅读

来源