10 清理不可省
迟早你会看到一张表不断变大,而 pg_stat_user_tables 却显示自动清理一小时前才运行过。你已经知道增长从哪里来:每次更新和删除都会留下一个死行版本(更新就是插入)。本章讲的是本该清理这些遗留物的进程,以及它为什么有时做不到。
清理跟得上时,没有人会注意。表不断变化,却保持原来的大小。清理还会防止事务 ID 耗尽,让仅索引扫描正常工作,并保持统计信息新鲜。清理落后时,表会膨胀,查询会读取不该读取的页;最糟糕的情况下,数据库会停止接受写入。
清理是 Postgres 中唯一一个所有人都会忽略、直到它成为所有人唯一谈论的事情的部分。趁还没到那一步,先弄懂它。
用 pgstattuple 测量死元组
先测量,从同一个 pgstattuple 开始;它曾观察到 清理释放表内空间、而不是磁盘空间中的 users 变成两倍大。这次的表是 vac_demo,它是前 500,000 行 events 的副本,并且关闭了自动清理。更新每两行中的一行,然后查看:
同样是 500,000 个活行,另外还有占表 31% 的 250,000 个死行。原本紧密排列的页没有空间容纳新版本,所以新版本被写到末尾的 4,807 个新页中。现在每次顺序扫描都会多读取 55% 的页。
pgstattuple 很精确,但会读取整张表。对于大型生产表,使用会跳过全可见页的 pgstattuple_approx(),或者使用 n_live_tup 和 n_dead_tup 这两个低成本估算值,它们位于 pg_stat_user_tables 中。
动手验证 · lab 09-vacuum
清理要做五件事
清除这 250,000 个死行只是清理工作的第一项。普通 VACUUM(不是 VACUUM FULL)会在原地工作,只取得一个 SHARE UPDATE EXCLUSIVE 锁,既不会阻塞读取,也不会阻塞写入。它沿着固定路线运行:遍历表,然后遍历每个索引,再次遍历表。
InnoDB 的后台清理线程会从撤销日志中清除旧行版本。Postgres 的旧版本存在表中,因此清理要在堆和每个索引上完成清除工作;还有一个 InnoDB 没有的风险:事务 ID 回卷(Postgres 不是 MySQL)。
1. 移除死行版本
当删除或替换该行的事务已经提交,并且没有仍在运行的事务可能还需要它时,一个行版本就是死亡的。清理会计算这个边界,也就是只有没有快照能看到版本时,版本才算死亡中介绍的 xmin 清理界限(xmin horizon);清理输出把它称为可移除截点(removable cutoff):任何当前快照可能还会关心的最老事务 ID。记住“已死亡但尚不可移除”这句话。这是清理输出中最重要的一行。
索引按位置指向堆元组(TID,即一个 (page, slot) 地址),所以清理会分阶段移除死版本。它先剪枝每个堆页,并把死 TID 收集到内存中;然后完整扫描每个索引,删除指向这些 TID 的条目;最后释放堆槽位。每个索引都会放大清理成本。(普通查询经过页时也会剪枝页:页内剪枝要等到页接近填满。)
深入阅读:清理何时会把每个索引扫描两遍
死 TID 存放在 maintenance_work_mem(或 autovacuum_work_mem)中。如果放不下,清理就执行一次索引遍历,清空这份列表后继续,因此内存太少意味着要对每个索引执行多次完整扫描。详细输出中的 index scans: 1 表示扫描一次。PostgreSQL 17 用紧凑得多的结构替换了 TID 数组,并取消了 1 GB 上限,所以在 17+ 上这种情况很少见。在更早的版本中,要给清理分配足够的内存,让 index scans: 保持为 1。
2. 记录空闲空间
释放的空间会进入空闲空间映射(FSM),这样后续写入会填充已有页,而不是扩展文件(插入会找到空间,而不用扫描表)。清理让空间变得可复用,而不会让文件变小。
清理之后,vac_demo 仍然有 13,601 页,但死元组变成了零,空闲空间从 0.6% 上升到 31.7%。随后我们插入了 100,000 行。一页都没有增加;空闲空间降到了 19.3%。
这就是你想要的稳态:表不断变化,却保持相同的大小。这个循环一旦断开,得到的就是膨胀。
动手验证 · lab 09-vacuum
3. 截断末尾的空页
只有在末尾的整页都为空时,普通清理才会缩小文件;它会短暂取得一个 ACCESS EXCLUSIVE 锁,把这些页截掉。删除按时间排序的表中最新的一半行,再运行清理,文件会缩小一半。删除最老的一半行时,空间会留在文件前部:可以复用,却永远不会归还给操作系统。
深入阅读:删除哪一半决定文件是否缩小
events 按时间排序,因此最新的一半位于文件末尾。截断锁持续时间很短,但副本会重放它,这可能造成可见的停顿。可以按表通过存储参数 vacuum_truncate 禁用,也可以在单次运行中使用 vacuum (truncate false) 禁用;PostgreSQL 18 还新增了服务器级别的 vacuum_truncate 设置。
动手验证 · lab 09-vacuum
4. 冻结旧元组
事务 ID(XID)存储在 32 位中,大约有 43 亿个值,计数器会像里程表一样回卷。因此 Postgres 会在一个圆环上比较 XID:对于任意一个 XID,大约 21 亿个在“过去”,21 亿个在“未来”。如果某个元组的插入 XID 比计数器落后超过 21 亿,它突然就会看起来像来自未来,并从所有查询中消失。这就是事务 ID 回卷。
冻结可以防止它。一旦元组的插入事务老到每个快照都能看到它已经提交,清理就会把元组标记为已冻结:对所有人可见,永久如此,不受计数器当前值影响。每张表的 relfrozenxid 表示其中所有更老的 XID 都已被冻结,而 age(relfrozenxid) 表示它落后了多少个 XID,这个值绝不能接近 20 亿。清理不会费心冻结比 vacuum_freeze_min_age 更年轻的行(5,000 万个事务);vacuum (freeze) 会立即冻结当前能冻结的一切。元组全部冻结的页会在可见性映射中标记为全冻结,因此后续清理再也不用读取它。
深入阅读:冻结是两个本来无法共存的提示位
这是 更新就是插入中那个三行的 account_usage 表,只是这次显式冻结了它:
HEAP_XMIN_FROZEN 是“已提交”和“无效”两个提示位同时设置的状态,这种组合在其他情况下没有意义。原始 t_xmin 仍然保留,便于调试,但之后不再查询它;relfrozenxid 跳到当前值,可见性映射把该页标记为全可见和全冻结。正常运行时永远不需要 vacuum (freeze);下面的设置会决定自动清理何时完成冻结。
5. 维护可见性映射
可见性映射(VM)为每个堆页保留两位:全可见和全冻结(每页两位,让扫描和清理跳过工作)。清理设置它们;对页的任何修改都会清除它们。仅索引扫描会跳过全可见页的堆,清理会跳过全可见页,冻结清理会跳过全冻结页。
所以只追加的表也需要清理。它们不会有行死亡,但没有清理,它们的页就永远不会变成全可见(无法进行仅索引扫描),也不会变成全冻结(于是某天一次巨大的防回卷清理会同时冻结整张表)。而且 vacuum (analyze) 会在这里顺便刷新规划器统计信息。
深入阅读:vacuum (verbose) 如何说明每项工作
tuples: 是工作 1;其中 dead but not yet removable 的数量是需要关注的那一行;index scan needed 是工作 1 的索引遍历;pages: 0 removed 表示工作 3 没有找到可截断的内容;frozen: 是工作 4;visibility map: 是工作 5。工作 2 的空闲空间只会出现在 pgstattuple 中。
自动清理会因死行、插入或年龄而触发
自动清理启动器每分钟唤醒一次(autovacuum_naptime),并在任何一张表超过以下三个阈值之一时启动一个工作进程;工作进程最多为 autovacuum_max_workers 个(默认 3 个):
- 死元组。
n_dead_tup超过autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples:50 加上活行的 20%。PostgreSQL 18 会把它限制在autovacuum_vacuum_max_threshold(1 亿)以内。 - 插入(PostgreSQL 13+)。
n_ins_since_vacuum超过 1,000 加上表的 20%。这会让只追加的表以小剂量获得可见性位和冻结。在 PostgreSQL 18 中,按比例计算的部分只统计尚未冻结的那部分。 - 年龄。
age(relfrozenxid)超过autovacuum_freeze_max_age(2 亿)。这是防回卷清理,即使关闭了自动清理也会运行。
当变更行数超过 50 加上表的 10% 时,ANALYZE 会触发。
对大表来说,默认值不对
0.2 的比例因子意味着“表中有五分之一的行死亡时才清理”:
| 活行数 | 自动清理前的死行数 | 含义 |
|---|---|---|
| 1,000 | 250 | 尚可 |
| 2,000,000 | 400,050 | 今天的 events:先积累 400k 个死行 |
| 100,000,000 | 20,000,050 | 20m 个死行造成的膨胀 |
| 1,000,000,000 | 100,000,000 (PostgreSQL 18 cap) | 没有上限时为 200m |
对大表来说,20% 的死行意味着每个查询都要扫描数 GB 的死行,最终启动的清理还要运行数小时。ANALYZE 也有同样的问题:比例为 10% 时,一张像 events 这样的年度时间序列表可能一个多月都不分析,因此统计信息会漏掉最新的行,而查询最常问到的正是这些行。修复方法是设置表级参数:
现在 events 大约积累 21,000 个死行就会清理,而不是等到 400,000 个。频繁的小清理在各方面都胜过罕见的大清理。我们的规则是:超过几百万行后,使用 0.01 到 0.05 的比例因子,或者使用 0 和固定阈值(自动清理的默认值适合小表和慢磁盘介绍全局设置)。
动手验证 · lab 09-vacuum
关闭自动清理从来不能修复任何问题:死元组会堆积,统计信息会腐烂,而防回卷清理仍然会在表膨胀到最糟时才到来。如果自动清理成本很高,就让它更频繁地运行,每次少做一些工作,并增加 I/O 预算,让它能够完成。
自动清理按预算运行,而默认预算很小
自动清理用点数预算限制自身:在共享缓冲区中找到的每页 1 点,从外部读取的每页 2 点,使其变脏的每页 20 点。当一个工作进程花完 vacuum_cost_limit(200)点时,就会休眠 autovacuum_vacuum_cost_delay(2 ms)。手动执行的 VACUUM 不受此限制。
这相当于每秒 100,000 点:读取 50,000 页(约 390 MB/s,绰绰有余),但只能弄脏 5,000 页(约 39 MB/s),而且这笔预算由三个工作进程共享。在真正有大量写入的 SSD 上,清理会落后,膨胀会增长,无论比例因子设得多低。把 autovacuum_vacuum_cost_limit 提高到 1,000~2,000,是最有效的改动之一。(清理还会通过一个小型缓冲区环读取,大小由 vacuum_buffer_usage_limit 决定:大操作会获得一个环。)
清理无法移除旧快照可能需要的内容
仍可能需要旧版本的最老对象决定了可移除截点。长事务会拖住一切已经说明了原因:一个从未接触这张表的空闲 repeatable-read 会话,在提交前一直让其中 60,000 个死行无法被移除。下面从清理的角度看看这是什么样,以及如何找出每种持有者。
症状是 vacuum (verbose) 的一行:0 removed,以及一个很大的已死亡但尚不可移除数量;除了持有截点的快照之外,所有人都看不到这些行。清理会运行、消耗 I/O,却什么也移除不了,这通常就是“自动清理在运行,但表仍然不断膨胀”的答案。持有截点的会话会在 pg_stat_activity 中以非空的 backend_xmin 出现,通常状态是 idle in transaction。
代价有多大,取决于它背后的写入量。在安静的表上,几秒钟没有代价;但在每秒接收数千次更新的表上,几秒钟就足以让更新失去 HOT,并永久扩大表(长事务会关闭 HOT)。繁忙的表被拖住三小时,就会产生数百万个死行,而膨胀会比事务活得更久。
深入阅读:打开的快照会让清理什么也移除不了
会拖住截点的对象包括:
- 长时间运行的事务和查询,包括只读事务和查询。
- 处于事务中空闲的会话:应用运行了
BEGIN,随后等待网络调用,或泄漏了连接。这是现实中最常见的原因;idle_in_transaction_session_timeout是解决办法(连接并非免费)。 - 复制槽。备库启用了
hot_standby_feedback的物理槽会为每张表保持截点,即使备库消失后仍会继续保持。逻辑槽只为系统目录保持截点。两者都会保留 WAL。 - 启用
hot_standby_feedback = on的副本,其中运行时间很长的副本查询会拖住主库。 - 被遗弃的预备事务(两阶段提交很少是答案)。
复制槽和预备事务永远不会出现在 pg_stat_activity 中,而备库反馈只会在那里显示为 walsender 的 backend_xmin(或者显示在它的槽上),所以还要检查 pg_replication_slots 和 pg_prepared_xacts(四个查询告诉你清理是否跟得上)。
一个会话整个周末都处于 idle in transaction,就可能拖住整个数据库的清理;一个被遗弃的物理复制槽,则可能拖住整个集簇。为应用角色设置 idle_in_transaction_session_timeout,对最老的 backend_xmin 和不活跃槽设置告警,并设置 max_slot_wal_keep_size,这样一个死亡的槽就不会顺便填满磁盘。
深入阅读:清理也会输掉锁竞争
清理的 SHARE UPDATE EXCLUSIVE 锁会与大多数 DDL 冲突,包括 ALTER TABLE、CREATE INDEX(甚至 CONCURRENTLY)以及另一次清理。普通自动清理如果阻塞了冲突的锁请求,会在 deadlock_timeout 之后自行取消,因此频繁的 DDL 或 LOCK TABLE 可能让一张表永远无法完成一次清理(log_autovacuum_min_duration 会记录这些取消)。防回卷清理不会让步;这也是一次迁移最终排在它后面的原因之一。
索引膨胀通常比表膨胀代价更大
查找会在索引中逐页进行,因此膨胀的索引会拖慢所有经过它的查询。膨胀是既不是活数据、近期也不会被复用的空间;一些空闲空间是健康的,因为下一次更新会落在那里。对于表,pgstattuple 会给出 dead_tuple_percent 和 free_percent。对于 B-树索引,pgstatindex 会报告叶子密度。
新的 B-树会把叶子打包到大约 90%。本章的更新每次都会为新行版本添加一个主键条目,因为没有任何一次更新能让新版本留在原来的页上;之后 vac_demo 的主键密度降到了 49%。REINDEX CONCURRENTLY(PostgreSQL 12+)会在线重建索引:创建一份新副本,再把它交换进来:
从 23.6 MB 降到 12.9 MB,小了 45%,而且没有阻塞写入。无需读取每一页的更便宜的膨胀估算见观察它运行。
动手验证 · lab 09-vacuum
大表执行 VACUUM FULL 会造成停机
VACUUM FULL 会把表重写到一份只包含活行的新文件中,然后重建所有索引。在 vac_demo 上,它从 13,601 页降到了 10,961 页。代价是:整个重写期间都要持有 ACCESS EXCLUSIVE 锁,阻塞所有读取和写入,还要额外占用一份副本大小的磁盘空间。对大表来说,这就是一次事故。(CLUSTER 是同一种操作,只是按索引对行排序。)把它留给小表和维护窗口;大表要在线重建(无锁重建膨胀的表)。
但首先要问,表为什么膨胀。如果清理被阻塞或太慢,重建只是把时钟拨回去。修复原因后,新写入会像插入测试所展示的那样复用已有的空闲空间。
深入阅读:VACUUM FULL 会写出一份全新的文件
回卷会在任何内容损坏前停止写入
数据库会拒绝新的写入,日志会说它这样做是为了防止数据丢失。没有任何东西损坏。你已经用掉了大部分 XID 空间,Postgres 在计数器回卷前停了下来。
每个写事务都会消耗一个 XID(只读事务不会)。每秒 1,000 个写事务就是每天 8,600 万个,因此繁忙系统每月会消耗数亿个。防线分成几层:
| XID 年龄 | 设置或限制 | 发生什么 |
|---|---|---|
| 150 million | vacuum_freeze_table_age | 清理变得激进:处理所有不是全冻结的页 |
| 200 million | autovacuum_freeze_max_age | 防回卷自动清理,即使自动清理已关闭 |
| 1.6 billion | vacuum_failsafe_age(PostgreSQL 14+) | 清理放弃节流和索引清理,以便完成 |
| 距离末尾 40 百万 | 在日志和客户端处发出警告 | |
| 距离末尾 3 百万 | 不再有新的 XID:完全不能写入 |
“末尾”就是回卷点,大约是 21 亿个 XID。
通常,防回卷清理每隔几亿个事务运行一次,没有人会注意。只有当某些东西阻止它完成时,才会出现紧急情况:有阻塞者拖住截点(清理不能冻结比它更新的元组)、节流太慢导致大表永远做不完,或者一次运行反复被阻塞或杀掉。
这发生在真实公司身上:Sentry 在 2015 年停机了大半个工作日,Mandrill 在 2019 年停机了大约 40 小时。两次事故的模式相同:大而繁忙的表早就让清理落后了,只是没人查看。
深入阅读:两次事故是如何结束的
两起事故都以相同方式结束。Sentry 的高写入数据库在自动清理跟不上时停止接受写入;经过数小时清理后,截断一张巨大且不重要的表恢复了写入。Mandrill 在一个分片上发生了回卷,因为哈希让它比其他分片更热;清理的最高估计时间是 40 天,而截断最大的表(以 TB 计,其中大多数表只有 MB)让它在大约一小时后完成。
要避免这种情况:
- 监控每个数据库的
age(datfrozenxid),以及最大表的age(relfrozenxid)。 要在防故障保护阈值之前很久就告警,比如到 5 亿至 10 亿时。 - 控制清理阻塞者。 几乎每起回卷事故背后都有它们。
- 让由插入驱动的自动清理以小剂量冻结只追加的表。
- 不要为了让迁移通过而杀掉防回卷清理。 只有一次运行完成时
relfrozenxid才会前进,因此杀掉它会丢失已经做的工作,而自动清理会立即再启动一次。
如果写入已经被拒绝:移除阻塞者(终止旧会话、处理预备事务、删除死亡的槽),然后在最老的表上不节流地运行普通 VACUUM(不是需要 XID 的 VACUUM FULL)。当前文档建议使用普通多用户模式,而不是旧的单用户步骤;关于你的版本的确切步骤,见 Routine Vacuuming。
深入阅读:多事务 ID 也会回卷,而 PostgreSQL 18 会更早冻结
多事务 ID用于多个事务锁住同一行的情况(例如,许多会话的外键检查取得 FOR KEY SHARE),它们有自己的计数器、年龄(mxid_age(relminmxid))和冻结设置,也可能以同样方式回卷(共享行锁会产生多事务 ID,多事务 ID 也会回卷)。
PostgreSQL 18 还会在普通清理期间提前冻结一小部分全可见页(详细输出中的 eagerly scanned 数量,由 vacuum_max_eager_freeze_failure_rate 调整),把原本会由激进清理一次性承担的成本摊开。
四个查询告诉你清理是否跟得上
调查问题时运行下面这些查询,并把重要的结果做成图表和告警(观察它运行会把它们和其他指标放在一起)。它们回答四个问题:
- 哪些表最接近触发自动清理,它最近运行过吗? 如果一张表的
n_dead_tup长期远高于阈值,它要么被阻塞,要么跟不上。 - 每张表距离强制冻结还有多远? 比较
age(relfrozenxid)和autovacuum_freeze_max_age。 - 是什么拖住了截点? 会话(
pg_stat_activity)、槽(pg_replication_slots)和预备事务(pg_prepared_xacts):三者都要检查。 - 清理现在正在做什么? 看
pg_stat_progress_vacuum。
还要让自动清理报告自身:log_autovacuum_min_duration(默认 10 分钟)会为每次更长的运行记录包含 VACUUM VERBOSE 细节的日志。设为 1s 后,你就会得到清理历史,知道清理了什么、用了多长时间,以及哪些内容已死亡但尚不可移除。
深入阅读:清理仪表盘的四个查询
哪些表最接近触发自动清理,它最近运行过吗? 在更新了 6,000 个事件和 300 个账户的新样本数据副本上:
300 个变更跨过了 accounts 的 250 行阈值,自动清理在一分钟内就把它们清掉了。events 的 6,000 个死行离 400,052 还远得很。这个查询使用全局设置,因此有表级覆盖设置的表会比 vacuum_at 显示的时间更早触发。
每张表距离强制冻结还有多远?
是什么拖住了截点?
清理现在正在做什么?
上面 index_vacuum_count 在大表上如果超过 1,意味着清理存不下所有死 TID,因而不止一次扫描索引。第一个查询中的 vacuum_at 使用的是 reltuples 这个采样估计值,因此会接近精确 2,000,000 行得到的 400,050,而不会正好等于它。
本章要点
- 更新和删除会留下死行版本;清理会移除它们,并记录空间以便复用。普通清理不会缩小文件,除非末尾存在空页。
- 用
pgstattuple(精确)或n_dead_tup(便宜)测量;用pgstatindex测量索引,并用REINDEX CONCURRENTLY修复索引膨胀。 - 清理还会冻结旧元组并设置可见性映射位。只追加的表也需要清理。
- 默认情况下,自动清理在 50 + 20% 死行时触发。对于超过几百万行的表,把表级比例因子设为 0.01 到 0.05。
- 在 SSD 上把
autovacuum_vacuum_cost_limit提高到远高于 200;默认预算由所有工作进程共享。 - “已死亡但尚不可移除”意味着有什么东西拖住了截点:长事务或处于事务中空闲的会话、复制槽、启用了反馈的副本,或预备事务。找出它并结束它。
VACUUM FULL会在整个重写期间阻塞表;在线重建大表,并先修复造成膨胀的原因。- 在 16 亿的防故障保护阈值之前很久就对
age(datfrozenxid)告警,永远不要关闭自动清理,也不要杀掉防回卷清理。
延伸阅读
- Routine vacuuming,PostgreSQL 文档。关于冻结、回卷,以及服务器停止接受写入时恢复步骤的参考资料。
- Vacuuming settings,PostgreSQL 文档。所有自动清理、成本和冻结设置,包括 PostgreSQL 18 的新增设置。
- pgstattuple,PostgreSQL 文档。精确和近似的膨胀测量,以及每个输出列的说明。
- src/backend/access/heap/vacuumlazy.c,PostgreSQL 源码。文件头注释介绍清理阶段以及 PostgreSQL 18 的提前冻结。
- VACUUM won’t remove dead rows: 4 reasons why,Laurenz Albe,CYBERTEC。简明介绍会拖住截点的所有因素。
- Transaction ID Wraparound in Postgres,Sentry;以及 What We Learned from the Recent Mandrill Outage,Mailchimp。两篇事务 ID 回卷事故复盘,最好在你需要它们之前读完。