跳转到主要内容

6 清理与冻结

PostgreSQL 18 · 基于俄文 PG18 原版的简体中文译本

6.1 清理

页内剪枝执行得很快,但只能释放一部分空间。它只在一个表页面内工作,不会触碰索引;反过来,索引的页内剪枝也只在一个索引页面内工作,不会触碰表。

主要的常规清理1由 VACUUM 命令执行2。它会完整处理表,不仅清除无用的行版本,还会从所有索引中清除指向这些行版本的引用。

清理与系统中的其他活动并行进行。执行清理时,表和索引仍可正常读写(但不能同时执行 CREATE INDEX、ALTER TABLE 等命令)。(第 261 页)

为了避免扫描多余的页面,清理会使用可见性映射。映射中已标记的页面会被跳过,因为它们保证只包含有效行版本;其他页面则可能包含可以清除的版本。如果处理后页面中只剩下位于数据库视界之外的行版本,清理就会更新可见性映射。(第 32 页)

清理还会更新空闲空间映射,反映页面中新增的空闲空间。

创建一个表和一个索引:

=> CREATE TABLE vac(
  id integer,
  s char(100)
) WITH (autovacuum_enabled = off);
=> CREATE INDEX vac_s ON vac(s);

这里通过存储参数 autovacuum_enabled 禁用自动清理,以便只为实验目的精确控制清理时机。

插入一行,并执行两次更新:

=> INSERT INTO vac(id,s) VALUES (1,'A');
=> UPDATE vac SET s = 'B';
=> UPDATE vac SET s = 'C';

此时表中有三个行版本:

=> SELECT * FROM heap_page('vac',0);
 ctid  | state  | xmin  | xmax  | hhu | hot | t_ctid
-------+--------+-------+-------+-----+-----+--------
 (0,1) | normal | 845 c | 846 c |     |     | (0,2)
 (0,2) | normal | 846 c | 847   |     |     | (0,3)
 (0,3) | normal | 847   | 0 a   |     |     | (0,3)
(3 rows)

索引中有指向每个行版本的引用:

=> SELECT * FROM index_page('vac_s',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,1) | f
          2 | (0,2) | f
          3 | (0,3) | f
(3 rows)

清理后,死行版本消失,只剩下一个有效版本:

=> VACUUM vac;
=> SELECT * FROM heap_page('vac',0);
 ctid  | state  | xmin  | xmax | hhu | hot | t_ctid
-------+--------+-------+------+-----+-----+--------
 (0,1) | unused |       |      |     |     |
 (0,2) | unused |       |      |     |     |
 (0,3) | normal | 847 c | 0 a  |     |     | (0,3)
(3 rows)

此时,指向被清除版本的前两个项指针状态变为 unused,而不是页内剪枝时的 dead,因为索引中已经没有指向它们的条目:

=> SELECT * FROM index_page('vac_s',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,3) | f
(1 row)

状态为 unused 的项指针被视为空闲,可以用于新的行版本。

现在,该页面已在可见性映射中标记出来。我们可以通过 pg_visibility 扩展查看该映射:

=> CREATE EXTENSION pg_visibility;
=> SELECT all_visible
FROM pg_visibility_map('vac',0);
 all_visible
-------------
 t
(1 row)

页面上所有行版本在所有数据快照中都可见,这一点也记录在表页面的页头中:

=> SELECT flags & 4 > 0 AS all_visible
FROM page_header(get_raw_page('vac',0));
 all_visible
-------------
 t
(1 row)

6.2 再谈数据库视界

清理根据数据库视界判断哪些行版本应视为死版本。这个概念非常重要,值得再回顾一次。

从头重复前面的实验:

=> TRUNCATE vac;
=> INSERT INTO vac(id,s) VALUES (1,'A');
=> UPDATE vac SET s = 'B';

不过,在再次更新这一行之前,我们先启动第二个事务,让它持有数据库视界(几乎任何事务都可以,只有以 Read Committed 隔离级别执行的虚拟事务例外)。例如,让这个事务修改另一个表中的某些内容。(第 106 页)

=> BEGIN;
=> INSERT INTO accounts(id, client, amount)
  VALUES (1, 'alice', 1000.00);
=> UPDATE vac SET s = 'C';

现在表中有三个行版本,索引中有三个引用。清理后会发生什么?

=> VACUUM vac;
=> SELECT * FROM heap_page('vac',0);
 ctid  | state  | xmin  | xmax  | hhu | hot | t_ctid
-------+--------+-------+-------+-----+-----+--------
 (0,1) | unused |       |       |     |     |
 (0,2) | normal | 851 c | 853 c |     |     | (0,3)
 (0,3) | normal | 853 c | 0 a   |     |     | (0,3)
(3 rows)
=> SELECT * FROM index_page('vac_s',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,2) | f
          2 | (0,3) | f
(2 rows)

上一次清理后表中只剩一个行版本,而这一次却剩下两个:清理判断版本 (0,2) 还不能删除。原因当然是数据库视界;在这个例子中,视界由尚未结束的事务决定:

=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
--------------
          852
(1 row)

可以指定 VERBOSE 子句,让清理报告发生了什么:

=> VACUUM VERBOSE vac;
INFO:  vacuuming "internals.public.vac"
INFO:  finished vacuuming "internals.public.vac": index scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly
scanned
tuples: 0 removed, 2 remain, 1 are dead but not yet removable
removable cutoff: 852, which was 2 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were
all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead
item identifiers removed
avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s
buffer usage: 14 hits, 0 reads, 0 dirtied
WAL usage: 0 records, 0 full page images, 0 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

这里可以看到:

  • 表中没有发现可以删除的行版本(tuples: 0 removed);

  • 找到两个不能删除的行版本(2 remain);

  • 两个不能删除的版本中有一个是死版本(1 are dead but not yet removable),另一个是有效版本;

  • 当前清理视界(removable cutoff)与打开事务的视界相同。

结束打开的事务后,数据库视界向前推进,清理也就可以继续:

=> COMMIT;
=> VACUUM VERBOSE vac;
INFO:  vacuuming "internals.public.vac"
INFO:  finished vacuuming "internals.public.vac": index scans: 1
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly
scanned
tuples: 1 removed, 1 remain, 0 are dead but not yet removable
removable cutoff: 854, which was 0 XIDs old when operation ended
new relfrozenxid: 853, which is 2 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 1 pages set all-visible, 0 pages set all-frozen (0 were
all-visible)
index scan needed: 1 pages from table (100.00% of total) had 1 dead
item identifiers removed
index "vac_s": pages: 2 in total, 0 newly deleted, 0 currently deleted,
0 reusable
avg read rate: 0.000 MB/s, avg write rate: 95.274 MB/s
buffer usage: 27 hits, 0 reads, 1 dirtied
WAL usage: 6 records, 1 full page images, 8981 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

清理发现一个死行版本已经越过更新后的视界,于是将其删除;页面中只剩下最后一个有效版本:

=> SELECT * FROM heap_page('vac',0);
 ctid  | state  | xmin  | xmax | hhu | hot | t_ctid
-------+--------+-------+------+-----+-----+--------
 (0,1) | unused |       |      |     |     |
 (0,2) | unused |       |      |     |     |
 (0,3) | normal | 853 c | 0 a  |     |     | (0,3)
(3 rows)

索引中也只剩一个条目:

=> SELECT * FROM index_page('vac_s',1);
 itemoffset | htid  | dead
------------+-------+------
          1 | (0,3) | f
(1 row)

6.3 清理的执行阶段

清理的工作看起来并不复杂,但这种简单只是表象。表和索引必须同时处理,同时还不能阻塞其他进程。为此,每个表的清理都分为多个阶段3。

首先扫描表以寻找死行版本;找到的版本先从索引中清除,再从表中清除。如果死版本太多,这个过程可能重复进行。最后,表还会在可能的情况下被截断。

下面详细介绍这些阶段。

表扫描

一切从表扫描开始4,扫描时会考虑可见性映射:映射中标记的页面会跳过,因为它们保证只包含有效行版本。对于从已读取页面中发现的、已经越过视界且不再需要的行版本,会把它们的标识符(tid)写入内存中的专用存储。不能立即删除这些行版本,因为索引可能仍然引用它们。(v. 17)

这个存储采用前缀树5。页面编号是树的键,节点中存放行版本的位图。结构大小受 maintenance_work_mem 参数限制,内存按需分配。(第 605 页)(第 427 页)(默认值: 64MB)

说明

与 PostgreSQL 17 之前使用的普通数组相比,前缀树存储标识符时节省了一个数量级的空间,而且其总大小可以超过 1 GB——一次分配的内存片段的最大大小。

索引清理

接下来有两种可能:要么扫描到达表的末尾,要么用于保存标识符的内存耗尽。无论哪种情况,都会开始索引清理阶段6。为了从索引页面中清除指向已记录行版本的条目,表上创建的每一个索引都必须被完整扫描。

说明

如果知道索引键,索引可以快速找到行版本;但没有办法根据表行版本的标识符快速找到索引条目。针对 B-树的这项工作目前还没有成功7。

对于大小超过 min_parallel_index_scan_size 的每个索引,都可能启动一个独立的工作进程;这样,多个索引就可以并行清理。可以在 VACUUM (parallel N) 命令中指定所需的清理进程数。实际启动的进程数不会超过空闲后台进程数8和符合条件的索引数(因为一个索引不能由多个进程同时处理)。(v. 13)(默认值: 512kB)

索引清理阶段还会维护空闲空间映射,并计算清理统计信息。但如果表中只有新增行(既没有删除也没有更新),就会跳过这一阶段,因为表中不会有死行版本。此时,索引清理会在最后强制执行一次,这称为索引规整9。

索引清理阶段结束后,索引中已经没有指向无用行版本的引用,但这些版本仍然存在于表中。这是正常情况:索引访问已经无法到达它们,而顺序扫描表时,可见性检查会将死版本排除。

表清理

随后开始表清理阶段10。表会再次扫描,以便从页面中清除先前记录的行版本并释放项指针。现在索引已经不再引用这些版本,因此可以安全地这样做。

清理释放的空间会记录到空闲空间映射中;只剩下在所有快照中都可见的有效版本的页面,则会在可见性映射中标记出来。

如果表扫描阶段没有读完整张表,就会清空 tid 存储,并从上次扫描停止的位置继续重复整个过程。

表截断

清理后,表页面中会出现空闲空间;有时还可能恰好释放出完整页面。如果文件末尾形成了一些空页面,清理就可以在最后的表截断阶段11“切掉”文件尾部,并把空间归还给操作系统。

截断需要短暂地获取表的排他锁。获取锁的尝试不会持续超过五秒,以免长时间停止其他进程。(第 261 页)

由于需要锁住表,只有在空页面足够多时才会执行截断:至少占文件的 116\frac{1}{16},或者对于大表至少 1000 个页面(这些阈值不能配置)。

如果锁仍然造成问题,可以通过几种方式彻底禁用截断:

  • 执行 VACUUM 命令,并指定 truncate off;(v. 12)

  • 关闭存储参数 vacuum_truncate 和 toast.vacuum_truncate;(v. 12)

  • 关闭配置参数 vacuum_truncate。(v. 18)

6.4 冻结

事务计数器回卷

PostgreSQL 为事务编号分配了 32 位。40 亿看起来是一个很大的数字,但在高负载下可能很快耗尽。例如,在每秒 1000 个事务(不包括虚拟事务)的负载下,连续运行一个半月就会耗尽。

位数耗尽后,计数器必须归零并重新开始下一轮计数;英语称之为 wraparound。但只有在编号持续递增时,才能认为编号较小的事务早于编号较大的事务。因此,不能简单地把计数器归零并重新开始编号。

为什么不为事务编号分配 64 位,这样不就能彻底排除这个问题了吗?原因在于,每个行版本的头部都保存两个事务编号:xmin 和 xmax。头部已经相当大(考虑对齐后至少 24 字节),增加位数还会额外占用 8 字节。(第 75 页)

说明

64 位事务编号也会使用12,它们在普通 32 位编号的基础上增加一个 32 位“纪元”,但只用于内部目的,从不会出现在数据页面中。

要正确处理计数器回卷,不能比较事务编号,而要比较事务年龄(即从该事务出现以来启动的事务数量)。因此,不再使用“小于”和“大于”,而使用“先于”(“更老”)和“后于”(“更年轻”)。

在代码中,这自然通过 32 位算术实现:求两个 32 位事务编号的差,再将结果与零比较13。

可以把事务编号想象成一个环。对于任意事务,逆时针方向的一半编号更老(在过去),顺时针方向的一半编号更年轻(在未来)。

事务计数器回卷:T1、T2、T3;过去与未来
图 6.1 事务计数器回卷

但这种环形方案会产生一个令人不快的情况。对于新的事务,旧事务 T1 位于很久以前;可是过一段时间后,对某个新事务来说,T1 会落入属于未来的那一半圆。如果真的发生这种情况,后果将是灾难性的:很久以前由 T1 做出的更改会对新事务不可见。

行版本冻结与可见性规则

为了避免这种“时间旅行”,清理进程除了释放页面空间外,还要执行另一项任务14:寻找位于数据库视界之外的行版本(因此它们在所有快照中都可见),并以特殊方式标记它们,即冻结这些行版本。(第 274 页)

冻结的行版本不考虑事务编号 xmin,在所有数据快照中都可见,因此这个编号可以安全地重新使用。

可以认为,冻结行版本中的 xmin 被替换成一个约定的“负无穷”(图中用雪花表示),这表明创建该版本的事务早已结束,具体编号已经不重要。实际上,xmin 并不会改变;系统会同时设置提交提示位和中止提示位,作为冻结标志。

行版本冻结与可见性规则:T1、T2、T3、T4 及冻结标记
图 6.2 行版本冻结与可见性规则
说明

许多资料(包括文档)都会提到特殊的事务编号 FrozenTransactionId = 2。这就是 9.4 版本以前用来替换 xmin 的那个“负无穷”。现在使用的提示位可以在行版本中保留原始事务编号,便于维护和调试。不过,在升级到最新版本的旧系统数据页面中,仍然可能遇到事务编号 2。

事务编号 xmax 完全不参与冻结。它只出现在无效行版本中;当这些版本不再对任何快照可见(也就是编号越过数据库视界)时,就会由清理删除。

手动冻结通过 VACUUM FREEZE 命令执行。在这个例子中,它冻结唯一的有效行版本:

=> VACUUM FREEZE vac;
=> SELECT * FROM heap_page('vac',0);
 ctid  | state  | xmin  | xmax | hhu | hot | t_ctid
-------+--------+-------+------+-----+-----+--------
 (0,1) | unused |       |      |     |     |
 (0,2) | unused |       |      |     |     |
 (0,3) | normal | 853 f | 0 a  |     |     | (0,3)
(3 rows)

现在,这个页面不仅在可见性映射中被标记,也在冻结映射中被标记:

=> SELECT all_visible, all_frozen
FROM pg_visibility_map('vac',0);
 all_visible | all_frozen
-------------+------------
 t           | t
(1 row)

6.5 分析

除了清理之外,还有一项与清理没有正式关系的任务:分析15,也就是收集统计信息。大部分统计信息供查询规划器使用,包括关系中的行数(pg_class.reltuples)和页面数(pg_class.relpages)、表列中的数据分布以及其他信息。可见性映射(pg_class.relallvisible)和冻结映射(pg_class.relallfrozen)中标记的页面数不会被规划器使用,但清理和分析本身需要用它们来管理工作。(第 346 页)

可以单独执行 ANALYZE 命令来手动分析16,也可以把它和清理合并为 VACUUM ANALYZE。不过,这时清理和分析仍然依次执行,并不会节省时间。

说明

从历史上看,VACUUM ANALYZE 这一形式最早出现于 6.1 版,而独立的 ANALYZE 命令直到 7.2 版才出现。更早的版本通过 TCL 脚本更新统计信息。