本文是《PostgreSQL指南--内幕探索》(铃木启修著 冯若航 刘阳明 张文升译)的读书笔记,仅供自己学习使用,请勿转载。这是一本好书,如有需要请直接购买书籍。
清理过程(通常简称为VACUUM)是一种维护过程,有助于 PostgreSQL 的持久运行。它的两个主要任务是删除死元组,以及冻结事务标识。
为了移除死元组,清理过程有两种模式,分别是并发清理与完整清理。并发清理过程会删除表文件每个页面中的死元组,而其他事务可以在其运行时继续读取该表。相反,完整清理不仅会移除整个文件中所有的死元组,还会对整个文件中所有的活元组进行碎片整理。其他事务在完整清理运行时无法访问该表。
尽管清理过程对PostgreSQL至关重要,但与其他功能相比,它的改进相对其他功能而言要慢一些。例如在8.0版本之前,清理过程必须手动执行(通过psql 实用程序或使用 cron 守护进程)。直到2005年实现了autovacuum 守护进程时,这一过程才实现了自动化。
由于清理过程涉及全表扫描,因此该过程代价高昂。在版本8.4(2009)中引入了可见性映射( Visibility Map,VM)来提高移除死元组的效率。在版本9.6(2016)中增强了 VM,从而改善了冻结过程的表现。
1. 并发清理概述
清理过程为指定的表或数据库中的所有表执行以下任务。
1.移除死元组和对活元组进行碎片整理。
- 移除每一页中的死元组,并对每一页内的活元组进行碎片整理。
- 移除指向死元组的索引元组。
2.冻结旧的事务标识。
- 如有必要,冻结旧元组的事务标识。
- 更新与冻结事务标识相关的系统视图( pg_database 与 pg_class)。
- 如果可能,移除不必要的提交日志文件。
3.其他。
- 更新已处理表的空闲空间映射(FSM)和可见性映射(VM)。
- 更新一些统计信息( pg_stat_all_tables 等)。
以下伪代码描述了清理的过程。
(1) FOR each table
(2) 在目标表上获取 ShareUpdateExclusiveLock 锁 /* 允许其他事务对该表进行读取 */
/* 第一部分 */
(3) 扫描所有页面,定位死元组,如有必要,冻结过早的元组
(4) 如果存在,移除指向死元组的索引元组
/* 第二部分 */
(5) FOR each page of the table
(6) 移除死元组,重排本页内的活元组
(7) 逐页更新目标表页对应的 FSM 与 VM
END FOR
/* 第三部分 */
(8) 如果最后一个页面没有任何元组,截断最后的页面
(9) 更新系统数据字典与统计信息
释放ShareUpdateExclusiveLock锁
END FOR
/* 后续处理 */
(10) 更新统计信息与系统数据字典
(11) 如果可能,移除非必要的文件及CLOG中的文件
该伪码分为两大块:一块是依次处理表的循环,一块是后处理逻辑。而循环块又分为三个部分,每一个部分都有各自的任务。接下来会描述这三个部分及后处理的逻辑。
1.1 第一部分
这一部分执行冻结处理,并删除指向死元组的索引元组。
首先,PostgreSQL 扫描目标表以构建死元组列表,如果可能的话,还会冻结旧元组。该列表存储在本地内存中的 maintenance_work_mem 里(维护用的工作内存)。冻结过程将在第 3 节中介绍。
扫描完成后,PostgreSQL 根据构建得到的死元组列表来删除索引元组。该过程在内部被称为“清除阶段”。不用说,该过程代价高昂。在 10.0 或更低版本中始终会执行清除阶段。在 11.0 或更高版本中,如果目标索引是B树,是否执行清除阶段由配置参数 vacuum_cleanup_index_scale_factor 决定。详细信息请参考此参数的说明。
当 maintenance_work_mem 已满,且未完成全部扫描时,PostgreSQL继续进行后续任务,即步骤(4)到(7),完成后再重新返回步骤(3)并继续扫描。
1.2 第二部分
这一部分会移除死元组,并逐页更新 FSM 和 VM。
图 1 删除死元组
假设该表包含三个页面,首先关注 0 号页面(即第一个页面),该页面包含三条元组, 其中 Tuple_2 是一条死元组,如图 1(1)所示。在这里PostgreSQL 移除了 Tuple_2,并重排剩余元组来整理碎片空间,然后更新该页面的 FSM 和 VM,如图 1(2)所示。PostgreSQL 不断重复该过程直至最后一页。注意,非必要的行指针是不会被移除的,它们会在将来被重用。因为如果移除了行指针,就必须同时更新所有相关索引中的索引元组。
1.3 第三部分
第三部分会针对每个表,更新与清理过程相关的统计信息和系统视图。此外,如果最后一页中没有元组,则该页会从表文件中被截断。
1.4 后续处理
当处理完成后,PostgreSQL 会更新与清理过程相关的几个统计数据,以及相关的系统视图;如果可能的话,它还会移除部分不必要的 CLOG 文件,见第 4 节。
2. 可见性映射
为了能加快VACUUM查找包含无效元组的文件块的过程,PG 为每个表文件设置了一个附属文件 ——— 可见性映射表。 可见性映射在 9.6 版中进行了加强,以提高冻结处理的效率。新的 VM 除了显示页面可见性之外,还包含了页面中元组是否全部冻结的信息。 VM 中为表的每一个文件块(Page)设置了一位,用来标记该文件块是否包含无效元组。对于包含无效元组的文件块,VACUUM有两种方式处理,即快速清理(Lazy VACUUM)和完全清理(Full VACUUM)。
注意,VM 文件仅在 Lazy VACUUM 操作中被用到,Full VACUUM 由于要跨块清理等复杂操作,需要对整个表文件进行扫描,所以 VM 文件此时作用不大。
2.1 结构分析
对于每个表文件,其对应的VM文件命名为:“关系表OID_vm”。对该文件的操作在 visibility_map.c 文件中进行了定义。
与其他文件一样, VM文件也被划分为若干个文件块(简称VM块)。VM块中除了必要的标记信息外,其他的每一位都对应于一个表块,当表块中所有元组都对当前事务可见时,表块对应的位才被设置为1。 其文件结构如下所示:
当标志位为1时,VACUUM会忽略扫描对应的表块,所以能大大提高VACUUM的效率。由于VM文件不跟踪索引,所以对索引的操作还是需要完全扫描。
3. 冻结过程
冻结过程有两种模式,依特定条件而择其一执行。为方便起见,我们将这两种模式分别称为惰性模式和迫切模式。
并发清理通常在内部被称为“惰性清理”。但是,本文中定义的惰性模式是冻结过程执行的模式。
冻结过程通常以惰性模式运行,但当满足特定条件时,也会以迫切模式运行。在惰性模式下,冻结过程仅使用目标表对应的VM扫描包含死元组的页面。迫切模式相则反,它会扫描所有的页面,无论其是否包含死元组,都会更新与冻结过程相关的系统视图,并在可能的情况下删除不必要的CLOG文件。
3.1 惰性模式
惰性模式当开始冻结处理时, PostgreSQL 计算 freezeLimit_txid ,并冻结 t_xmin 小于 freezeLimit_txid 的元组。freezeLimit_txid定义如下:
freezeLimit_txid = ( OldestXmin - vacuum_freeze_min_age )
OldestXmin 是当前正在运行的事务中最早的事务标识。举个例子,如果在执行VACUUM命令时,还有其他三个事务正在运行,且其txid分别为100、101和102,那么 OldestXmin 就是 100。如果不存在其他事务,OldestXmin 就是执行此 VACUUM 命令的事务标识。这里vacuum_freeze_min_age是一个配置参数(默认值为50 000 000)。
图 2 给出了一个具体的例子。Table_1 由三个页面组成,每个页面包含三条元组。执行VACUUM命令时,当前txid为50 002 500且没有其他事务。在这种情况下,OldestXmin就是50 002 500,因此freezeLimit_txid为2500。冻结过程按照如下步骤执行。
图2 冻结元组 -- 惰性模式
第0页:
三条元组被冻结,因为所有元组的 t_xmin 值都小于 freezeLimit_txid。此外,因为Tuple_1是一条死元组,所以在该清理过程中被移除。
第1页:
通过引用可见性映射(从VM中发现该页面所有元组都可见),清理过程跳过了对该页面的清理。
第2页:
Tuple_7和Tuple_8被冻结,且Tuple_7被移除。
在完成清理过程之前,与清理相关的统计数据会被更新,例如 pg_stat_all_tables视图中的n_live_tup、n_dead_tup、last_vacuum、vacuum_count等字段。
如上例所示,因为惰性模式可能会跳过页面,它可能无法冻结所有需要冻结的元组。
这里补充一个长事务的例子:
数据库运行一个长事务,很久没有提交导致current_oldest_xmin一直不会超过vacuum_freeze_min_age,vacuum不会冻结任何元组。这样最低的xmin就和当前最新的xmin的距离越来越远,差值慢慢接近20亿,这时候数据库为保证数据不丢失,会有告警甚至宕机。
告警
WARNING: database "mydb" must be vacuumed within 177009986 transactions
HINT: To avoid a database shutdown, execute a database-wide VACUUM in "mydb".
宕机
ERROR: database is not accepting commands to avoid wraparound data loss in database "mydb"
HINT: Stop the postmaster and vacuum that database in single-user mode.
3.2 迫切模式(9.5或更低版本)
迫切模式弥补了惰性模式的缺陷。它会扫描所有页面,检查表中的所有元组,更新相关的系统视图,并在可能时删除不必要的CLOG文件与页面。当满足以下条件时,会执行迫切模式。
pg_database.datfrozenxid < ( OldestXmin - vacuum_freeze_table_age)
在上面的条件中,pg_database.datfrozenxid 是系统视图 pg_database 中的列,并保存着每个数据库中最老的已冻结的事务标识,细节将在后面描述。这里我们假设所有 pg_database.datfrozenxid 的值都是1821(这是在9.5版本中安装新数据库集群之后的初始值)。vacuum_freeze_table_age 是配置参数(默认为150 000 000)。
图 3 给出了一个具体的例子。在表 1 中,Tuple_1 和 Tuple_7都已经被删除,Tuple_10和 Tuple_11 则已经插入第 2 页中。执行 VACUUM 命令时的事务标识为 150 002 000,且没有其他事务。因此,OldestXmin = 150 002 000,freezeLimit_txid = OldestXmin - vacuum_freeze_min_age =(150 002 000 - 50 000 000)=100 002 000。在这种情况下满足了上述条件:因为1821 < (150 002 000 - 150 000 000),所以冻结过程会以迫切模式执行,如下所示。注意,这里是 9.5 或更低版本的行为,最新版本的行为将在第 3.3 节中描述。
图3 冻结旧元组——迫切模式(9.5或更低版本)
第0页:即使所有元组都被冻结,也会检查 Tuple_2 和 Tuple_3。
第1页:此页面中的三条元组都会被冻结,因为所有元组的 t_xmin 值都小于 freezeLimit_txid。注意,在惰性模式下会跳过此页面。
第2页: 将 Tuple_10冻结,而 Tuple_11 没有冻结。
冻结完一张表的所有元组后,更新系统视图 pg_class 的 relfrozenxid 为 freezeLimit_txid:
冻结一张表后,目标表的 pg_class.relfrozenxid 将被更新。pg_class是一个系统视图,每个pg_class.relfrozenxid 列都保存着相应表的最近冻结的事务标识。本例中表1的 pg_class.relfrozenxid 会被更新为当前的 freezeLimit_txid(即100 002000),这意味着表 1 中 t_xmin 小于100 002 000 的所有元组都已被冻结。
如果当前数据库中的所有关系都以迫切模式冻结,则更新此数据库的pg_database. datfrozenxid :
在完成清理过程之前,必要时会更新 pg_database.datfrozenxid。每个 pg_database. datfrozenxid 列都包含相应数据库中的最小 pg_class.relfrozenxid。如果在迫切模式下仅仅对表 1 做冻结处理,则不会更新该数据库的 pg_database. datfrozenxid,因为其他关系的 pg_class.relfrozenxid(当前数据库可见的其他表和系统视图)还没有发生变化,如图4(1)所示。如果当前数据库中的所有关系都以迫切模式冻结,则数据库的 pg_database. datfrozenxid 就会被更新,因为此数据库的所有关系的 pg_class.relfrozenxid 都被更新为当前的 freezeLimit_txid,如图4(2)所示。
图4 pg_database.datfrozenxid 与 pg_class.relfrozenxid 之间的关系
如何查询 pg_class.relfrozenxid 与 pg_database.datfrozenxid?
testdb=# VACUUM table_1;
VACUUM
testdb=# SELECT n.nspname as "Schema", c.relname as "Name", c.relfrozenxid
FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','')
AND n.nspname <> 'information_schema'
AND n.nspname !~ '^pg_toast'
AND pg_catalog.pg_table_is_visible(c.oid)
ORDER BY c.relfrozenxid::text::bigint DESC;
Schema | Name | relfrozenxid
------------+-------------------------+--------------
public | table_1 | 100002000
public | table_2 | 1846
pg_catalog | pg_database | 1827
pg_catalog | pg_user_mapping | 1821
pg_catalog | pg_largeobject | 1821
...
pg_catalog | pg_transform | 1821
(57 rows)
testdb=# SELECT datname, datfrozenxid FROM pg_database
WHERE datname = 'testdb';
datname | datfrozenxid
---------+--------------
testdb | 1821
(1 row)
FREEZE选项:
带有 FREEZE 选项的 VACUUM 命令会强制冻结指定表中的所有事务标识。虽然这是在迫切模式下执行的,但是这里 freezeLimit 会被设置为 OldestXmin 而不是OldestXmin -vacuum_freeze_min_age。例如,当txid=5000的事务执行 VACUUM FULL 命令,且没有其他正在运行的事务时,OldesXmin 会被设置为 5000,而t_xmin 小于 5000的元组将会被冻结。
3.3 改进迫切模式中的冻结过程(9.6版本及更高版本)
9.5或更低版本中的迫切模式效率不高,因为它始终会扫描所有页面。比如在第 3.2 节的例子中,尽管第0页中所有元组都被冻结,但还是会被扫描。
为了解决这一问题,9.6版本改进了可见性映射VM与冻结过程。新VM包含着每个页面中所有元组是否都已被冻结的信息。在迫切模式下进行冻结处理时,可以跳过仅包含冻结元组的页面。
图 5 给出了一个例子。根据VM中的信息,冻结此表时会跳过第0页。在更新完1号页面后,相关的VM信息会被更新,因为该页中所有的元组都已经被冻结了。
图 5 冻结旧元组——迫切模式(9.6或更高版本)
4. 移除不必要的 CLOG 文件
CLOG 中存储着事务的状态。当更新 pg_database.datfrozenxid 时, PostgreSQL 会尝试删除不必要的CLOG 文件。注意,相应的 CLOG 页面也会被删除。图 6 给出了一个例子。如果 CLOG 文件 0002 中包含最小的pg_database.datfrozenxid,则可以删除旧文件(0000 和0001),因为存储在这些文件中的所有事务在整个数据库集簇中已经被视为冻结了。
图 6 删除不必要的CLOG文件和页面
5. 自动清理守护进程
自动清理守护进程已经将清理过程自动化,因此 PostgreSQL 运维起来非常简单。自动清理守护程序周期性地唤起几个 autovacuum_worker 进程,默认情况下每分钟唤醒一次(由参数 autovacuum_naptime 定义),每次唤起三个工作进程(由 autovacuum_max_works 定义)。
自动清理守护进程唤起的 autovacuum 工作进程会依次对各个表执行并发清理,从而将对数据库活动的影响降至最低。
6. 完整清理(FULL VACUUM)
死元组虽然都被移除了,但表的尺寸没有减小。这种情况既浪费了磁盘空间,又会对数据库性能产生负面影响.
图 7 完整清理模式概述
完整清理的伪代码如下所示
(1) FOR each table
(2) 获取表上的AccessExclusiveLock锁
(3) 创建一个新的表文件
(4) FOR 每条活元组in原表
(5) 将活元组复制到新表中
(6) 如果有必要,冻结该元组
END FOR
(7) 移除旧的表文件
(8) 重建所有索引
(9) 更新FSM与VM
(10) 更新统计信息
释放AccessExclusiveLock锁
END FOR
(11) 移除不必要的CLOG文件
1.当执行完整清理时,没有人可以访问(读/写)表。
2.最多会临时使用两倍于表的磁盘空间;因此在处理大表时,有必要检查剩余磁盘容量。