上篇介绍了 PG 流复制架构,今天聊聊 PG 最热门甚至没有之一的话题—— vacuum 。熟悉或者不熟悉的PG 多少都听说过 vacuum 大名,甚至有的同学谈 vacuum 色变,从而拒绝接受使用 PG,就像伟大的鲁迅曾说过:爱一个人,不是喜欢它的优点,而是拥抱它的特点。
那么PG 中的 vacuum 到底是什么,为什么会出现,解决了什么问题,又带来了哪些问题等等。这些问题小编将分为两篇介绍。本文主要分享 vacuum的来由、功能、相关参数设置,下一篇将介绍运维措施,包括vacuum的可观测性建设、最佳实践,以及业内探讨的几大解决方案发展情况等。
本文大约4000字,阅读完预计需要15分钟。
vacuum 来由
数据库为了解决读写冲突大多采用 MVCC,即修更新或删除数据时先存储其前镜像,形成数据的多个版本,再结合可见性视图,使不同事务仅可见各自应见的版本。其中把不再被任何活跃事务访问的数据版本,称为死元组。
PG MVCC 是在原数据文件中插入新版本,旧版本保留在原处,再通过行物理地址和事务ID形成版本链。查询时,根据事务隔离级别和快照可见性规则,沿版本链查询到对应版本。而MySQL InnoDB、Oracle 等数据库,旧版本数据存储在独立undo 表空间,通过回滚段管理。两种不一样的设计带来了不同的收益和成本。
在原表写入的方式,逻辑简洁不依赖额外的组件,读事务也不需要跨空间查询,可直接扫描堆表并通过事务ID过滤出可见版本,也减少 IO 开销(因为写 undo 的操作记录也会需要写入redo log 中)。但坏处是原表容易膨胀,尤其是存在大量更新、删除、长事务时,会导致表体积快速增大。为了解决这些问题,PG 引入了vacuum 机制。
vacuum 清理空间
死元组导致表膨胀、查询时版本链遍历效率下降及事务可见性判断异常等问题,需要定期通过vacuum 或者vacuum full操作解决。
当 vacuum 启动时,首先会对目标表加 Share Update Exclusive 锁,这个锁和DML、DQL 语句兼容,但和 DDL 互斥。接着按顺序扫描表的所有数据页、索引页,对于页内的每个元组,检查元组的 xmin(插入事务 ID)和 xmax(删除或更新事务 ID,如果该行没有更新或者删除,xmax为0),结合 pg_clog 日志中记录的事务提交状态,判断元组是否为死元组。如果是,则释放其占用的空间,并标记为可用,可用的空间并不会回收,但可供后续写入使用。当然,vacuum 在清理数据页的死元组时,也会同步清理索引页中指向这些死元组的无效索引项,避免索引膨胀。
而 vacuum full 是一种更彻底的清理策略,它先获取最高级别的 Access Exclusive 锁,这个锁阻塞表的所有读写操作,接着创建原表的副本,仅复制存活元组并紧凑存储,重建所有索引,最后用新表替换旧表,彻底回收磁盘空间。
无论是 vacuum 还是 vacuum full ,都是为了抵消 MVCC 机制带来的存储冗余代价,在避免存储空间无效占用的同时,保障并发场景下读写操作的数据一致性与性能稳定性。
从vacuum 运行机制来看,这是一个重IO操作。如果每个表有一个页发生变化,就要扫描所有表中的所有页,显然这是一个效率比较低下的方法。在8.4版本后,PG引入了可见性映射(Visibility Map,VM)机制,用于提升vacuum性能。
VM 通过一种机制来监控表和索引中的页面,确定哪些页面仅包含所有正在进行的事务都可见的元组,对于全可见页,无需重复解析元组的事务 ID 和事务日志。在对大表的全表扫描或索引扫描,能显著降低 I/O 和 CPU 消耗。
vacuum 事务冻结
事务冻结(Transaction Freezing)也是PG 数据库经典的话题之一,是为保障 MVCC 而设计的“阑尾”。原因是使用 32 位无符号整数 作为事务 ID标识,使用完32位事务号后,导致不能通过简单的比较事务号大小进行可见性判断 。
通常数据库中的事务ID 通常是单调递增,通过比较事务数字标识大小判断事务之间的可见性。比如A元组的事务ID 100,B 元组的ID 是101,则表示A 先与B 提交。32 位整数大约可记录 42 亿个事务。假设系统每秒产生 1000 个事务,只需约 49 天耗尽32为正数的事务号。此时,PG 使用了事务回卷技术解决事务号不够使用的问题。
事务回卷后新增事务的 ID 会重新开始技术,即从 3 开始重新计数(0、1、2 为系统预留事务号),这称为事务回卷。然而,回卷后的事务无法单纯的通过比较事务号大小判断数据的可见性。比如事务 ID 回绕后,系统会认为事务ID 3 早于事务 ID 42 亿,实际上3是最新的事务ID,进而引发元组可见性判断混乱,可能导致数据一致性破坏甚至数据丢失。
为避免此类问题,PG 引入冻结机制,将那些足够古老事务标记为特殊的 “冻结状态”(FrozenTransactionId)。这个状态在事务比较中被视为 “早于所有普通事务 ID”,因此无论事务 ID 如何回绕,被冻结的行始终对所有新事务可见,从而解决 ID 回绕导致的逻辑混乱。
实际上,32位事务号带来的问题,除了需要事务冻结机制,还需要通过环形空间切割一起服用解决。PG 将事务空间逻辑切割为两个对称区间:
过去可见区:当前事务 ID 的前 2³¹ 个事务(约21亿)被视为“过去事务”,其修改的数据对当前事务可见。
未来不可见区:当前事务 ID 的后 2³¹ 个事务被视为“未来事务”,其修改的数据对当前事务不可见。
环形事务示意图
如上图中,对于73号事务可见区为72 到 2³¹ +74 号的事务,而 74 到 2³¹ +73 号的事务不可见。因此,不考虑事务冻结情况下,实际上只有21亿个事务是可见的,并不是42亿个。这让原本就不富裕的事务号数量雪上加霜。
/*
* TransactionIdPrecedes --- is id1 logically < id2?
*/
bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
/*
* If either ID is a permanent XID then we can just do unsigned
* comparison. If both are normal, do a modulo-2^32 comparison.
*/
int32diff;
if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
return (id1 < id2);
diff = (int32) (id1 - id2);
return (diff < 0);
}
PG 事务号可见性判断代码在目录https://github.com/postgres/postgres/blob/master/src/backend/access/transam/transam.c
正常情况:若事务 id1=10, 事务id2=20 ,diff=-10 < 0 , id1 更旧。
回卷情况:若事务 id1=42亿,事务id2=3 ,差值为 42亿-3 为正,但转换为 int32 后溢出为负数 , 判定 id1 更旧。
21亿差值的约束比较函数依赖 int32 的溢出特性,要求事务差绝对值必须 ≤ 2³¹。若差值超过此限,转换结果会错误翻转,导致数据“消失”。不得不说,PG 针对32位事务号的阑尾而设计的解决办法的确实巧妙,既是数学约束的必然结果,也是工程实现的精巧取舍。
本着能干就多干点的原则,vacuum 除清理死元组、维护可见性映射及执行事务冻结外,还负责收集统计信息。统计信息为查询优化器估算不同执行路径的成本,避免因误判数据分布而导致低效操作。
vacuum 相关参数
实际上在 PG 运维中,主要依赖 utovacuum 而不是手动执行 vacuum,两者主要区别也主要是触发条件不同,毕竟auto 机制的总是更智慧、更高效。因此,我们重点围绕 autovacuum 的相关参数展开说明。
| 参数类型 | 参数名称 | 含义 | 默认值 | 补充 |
|---|---|---|---|---|
| 启动参数 | autovacuum | 控制是否启用 | on | 需同时启用 track_counts 参数才能生效 |
| 日志与监控参数 | log_autovacuum_min_duration | 记录执行时间超过指定阈值的 autovacuum 操作。 | -1 | |
| 资源控制 | autovacuum_max_workers | 设置同时运行的最大 autovacuum 工作进程数 | 3 | 仅服务器启动时设置。 |
| 资源控制 | autovacuum_naptime | 设定数据库间 autovacuum 轮询的最小间隔时间 | 1分钟 | |
| 清理触发阈值 | autovacuum_vacuum_threshold | 单表触发 VACUUM 所需的最少更新/删除元组数 | 50 | |
| 清理触发阈值 | autovacuum_analyze_threshold | 单表触发 ANALYZE 所需的最少增/删/改元组数 | 50 | |
| 清理触发阈值 | autovacuum_vacuum_insert_threshold | 单表触发 VACUUM 所需的最少插入元组数 | 1000 | |
| 清理触发阈值 | autovacuum_vacuum_scale_factor | 计算 VACUUM 触发阈值时,添加到 autovacuum_vacuum_threshold 的表大小比例 |
0.2 | |
| 清理触发阈值 | autovacuum_vacuum_insert_scale_factor | 计算插入触发 VACUUM 时,添加到 autovacuum_vacuum_insert_threshold 的表大小比例。 |
0.2 | |
| 清理触发阈值 | autovacuum_analyze_scale_factor | 计算 ANALYZE 触发阈值时,添加到 autovacuum_analyze_threshold 的表大小比例。 |
0.1 |
|
| 事务回卷防护 | autovacuum_freeze_max_age | 设定表的事务 ID 年龄上限,超过此值强制触发 VACUUM | 2亿 | autovacuum 关闭,此触发仍有效 |
| 事务回卷防护 | autovacuum_vacuum_cost_delay | 设置 autovacuum 操作的代价延迟 | 2毫秒 | |
| 事务回卷防护 | autovacuum_vacuum_cost_limit | 设置 autovacuum 的代价限制 | -1 | -1表示使用 vacuum_cost_limit 值,其默认值为200 |
清理空间参数
autovacuum_vacuum_threshold = 50
autovacuum_vacuum_scale_factor = 0.2
表触发 autovacuum 的公式代入默认值: 50 + 表总行数的20% 。
假设一个表是 100行,则表中的行如果更新或者删除的数量超过:50 + 10*0.2 = 70 行, autovacuum 就开始工作了。但如果表是 1000万行,那么工作的数量就变为超过 200万 才能触发autovacuum。因此,表越大死元组的规模也越大,数据库的性能就越低。所以根据默认值,触发 autovacuum 的死元组规模就越大,对数据库就越大,而且通常表越大,那么这个的表重要性、访问频率也越高。似乎有一次陷入了悖论,那如何解决呢?
从autovacuum 触发公式看,影响触发的两个参数一个是比例,一个是绝对值,同一个比例值对不同表的死元组数量触发不一致。那么自然可以想到,如果把比例设为0,死元组的绝对值数量设置为一个合理的值,那么不管是大表还是小表,只要死元组超过该阈值即触发 autovacuum。相同的,对于触发统计信息 autovacuum_analyze_threshold 可以是相同的思路。
事务冻结参数
什么时候触发事务冻结是autovacuum_freeze_max_age 参数控制,默认值是2亿,表示历史事务号ID与当前事务号ID差2亿时,即把历史事务冻结。
此外,还有两个和 autovacuum 成本相关的参数:autovacuum_vacuum_cost_delay、autovacuum_vacuum_cost_limit。 其中后者表示 autovacuum 操作在触发延迟之前所能累积的成本上限,默认值-1表示参考vacuum_cost_limit 值,默认值为200;后者表示 autovacuum 累积成本达到 autovacuum_vacuum_cost_limit 时,暂停的时间,单位是毫秒,默认值为2毫秒。
PG 把vacuum成本分为缓存区页修改的成本为1,从磁盘读取到缓存区的页成本为2,脏页的成本为20,这些成本相加到到达vacuum_cost_limit 时,就进行vacuum的一次delay,delay时间默认是2毫秒。这些成本值由参数vacuum_cost_page_hit 、vacuum_cost_page_miss、vacuum_cost_page_dirty 分别控制。
参数设置建议
前面已多次提起,vacuum 是 PG 数据库的核心功能,是解决表膨胀、查询优化器精准选择、 MVCC 实现等核心功能密切相关,也对数据库的性能与连续性影响显著。因此,autovacuum 相关参数的设显得格外重要。而了解这些参数的含义仅是设置的开始,具体参数设置还需结合运行状态、业务系统要求及数据特性。小编认为,应综合考虑以下情况后再确定参数值:
每个表中的行数
每个表中的死元组数
大事务的规模
每个表最后一次vacuum的时间
每个表中数据插入/更新/删除的速率
autovacuum 为每张表花费的时间
表未被清理的警告
大多数关键查询及其访问的表的当前性能
手动 vacuum 后相同查询的性能
总结
vacuum 看似是PG的一个“阑尾”设计,冗余却不可或缺,实际上是以存储空间与计算资源换取并发性能与数据一致性的平衡艺术,但32 位事务ID号引发的高并发系统事务号数量不够表示,需要通过事务冻结、环形事务ID设计解决,还是值得商榷。
以上就是我对 PG vacuum 的介绍以及相关资料的整理,希望能帮助需要的同学,感谢阅读。
参考
https://habr.com/en/companies/postgrespro/articles/869128/
https://www.enterprisedb.com/blog/well-known-databases-use-different-approaches-mvcc
https://www.interdb.jp/pg/pgsql05/01.html
https://www.anbob.com/archives/8476.html
https://postgreshelp.com/postgresql-autovacuum/
作者介绍
司马辽太杰,10余年数据库架构和运维管理经验,擅长常见关系型、NoSQL、MPP 等类型数据库。业余热爱历史、足球,读点闲书。欢迎关注个人公众号“程序猿读历史”。如需联系,可从关注公众号,在公众号对话窗口中扫码添加好友。感谢您的支持!

