十二月 11, 2024
摘要:在本教程中,您将了解 PostgreSQL 中的冻结。
目录
事务 ID 回卷
PostgreSQL 使用 32 位事务 ID。这是一个相当大的数字(大约 40 亿),但如果服务器的负载压力大,这个数字也是可能会耗尽的。例如:在每秒 1000 个事务的负载压力下,大约在连续运行一个半月的工作后就会耗尽。
但是,多版本并发控制依赖于顺序编号,这意味着在两个事务中,编号较小的事务可被认为是较早开始的。因此,很明显,不能只是重置计数器从头开始编号就行了。
那么,为什么不使用 64 位事务 ID?这样不是完全消除了这个问题吗?问题是每个元组的头部存储了两个事务 ID:xmin
和xmax
。头部本身就相当大,至少 23 字节,事务 ID 位数的增加会导致头部额外增加 8 个字节。这是完全没有道理的。
那么该怎么办呢?与其以数字形式按顺序对事务 ID 进行排序,不如像一个圆圈或时钟表盘一样。事务 ID 的比较方式与比较时钟读数的意义相同。也就是说,对于每个事务,事务 ID 的“逆时针”部分被视为与过去有关,而“顺时针”部分被视为与将来有关。
事务的年龄定义为自事务在系统中发生以来运行的事务数(无论事务 ID 是否回卷)。为了弄清楚一个事务是否比另一个事务更旧,我们比较它们的年龄而不是 ID 值。(顺便说一句,正是由于这个原因,没有为xid
数据类型定义 “大于” 和 “小于” 操作符。
但是这种循环的布局很麻烦。一个发生在遥远过去的事务(图中的事务 1),在一段时间后将会进入到圆圈中与未来相关的那一半。这肯定会违反可见性规则,并发生问题:事务 1 所做的更改将会消失在视线以外。
元组冻结和可见性规则
为了防止这种从过去到未来的 “时间旅行”,VACUUM 还执行了一项任务(除了释放页面空间)。它会找到非常旧的 “冷” 元组,这些元组在所有快照中都可见并且不太可能更改,并以特殊方式标记它们,即 “冻结” 它们。冻结的元组会被认为比任何正常数据都旧,并且始终在所有快照中都可见。而且它不再需要查看xmin
事务号,这个编号可以安全地重复使用。因此,冻结的元组始终保留在过去。
为了跟踪标记为冻结的xmin
事务,会给元组设置两个提示位:committed
和aborted
。
请注意,xmax
事务不需要冻结。它的存在表明元组不再处于活动状态。当它在数据快照中不再可见时,该元组会被清除。
让我们来创建一个表进行测试。让我们为它指定最小填充因子,以便每页只容纳两行,这让我们可以更方便地观察发生了什么。我们还关闭 autovacuum,以自行控制 VACUUM 的时间。
CREATE TABLE tfreeze(
id integer,
s char(300)
) WITH (fillfactor = 10, autovacuum_enabled = off);
我们已经创建好了一些函数,这些函数使用 “pageinspect” 扩展来显示位于页面上的元组。我们现在将创建该函数的另一个变体:它将一次显示多个页面,并输出xmin
事务号的年龄(使用age
系统函数):
CREATE FUNCTION heap_page(relname text, pageno_from integer, pageno_to integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmin_age integer, xmax text, t_ctid tid)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
CASE lp_flags
WHEN 0 THEN 'unused'
WHEN 1 THEN 'normal'
WHEN 2 THEN 'redirect to '||lp_off
WHEN 3 THEN 'dead'
END AS state,
t_xmin || CASE
WHEN (t_infomask & 256+512) = 256+512 THEN ' (f)'
WHEN (t_infomask & 256) > 0 THEN ' (c)'
WHEN (t_infomask & 512) > 0 THEN ' (a)'
ELSE ''
END AS xmin,
age(t_xmin) xmin_age,
t_xmax || CASE
WHEN (t_infomask & 1024) > 0 THEN ' (c)'
WHEN (t_infomask & 2048) > 0 THEN ' (a)'
ELSE ''
END AS xmax,
t_ctid
FROM generate_series(pageno_from, pageno_to) p(pageno),
heap_page_items(get_raw_page(relname, pageno))
ORDER BY pageno, lp;
$$ LANGUAGE SQL;
请注意,两个提示位committed
和aborted
同时设置,表示元组冻结(我们用带括号的 “f” 表示)。有一个专用的 ID 来表示冻结的事务:FrozenTransactionId = 2。此方法在 9.4 以前的 PostgreSQL 版本中就存在了,现在它已被提示位取代。这允许将初始事务编号保留在元组中,便于维护和调试。但是,即使升级到最新版本,您仍然可能在旧系统中遇到 ID = 2 的事务。
我们还需要 “pg_visibility” 扩展,它可以让我们能够查看可见性映射表:
CREATE EXTENSION pg_visibility;
在 9.6 以前的 PostgreSQL 版本中,可见性映射表每页包含一位;该映射表仅跟踪具有 “相当旧的” 行版本的页面,这些页面肯定在所有数据快照中都可见。这背后的想法是,如果在可见性映射表中跟踪页面,则不需要检查其元组的可见性规则。
从版本 9.6 开始,每个页面有一个 “全都已冻结” 的标志位,被添加到了可见性映射表中。“全都已冻结” 的标志位跟踪所有元组都已冻结的页面。
让我们在表中插入几行,并立即执行 VACUUM 操作,以创建可见性映射表:
INSERT INTO tfreeze(id, s)
SELECT g.id, 'FOO' FROM generate_series(1,100) g(id);
VACUUM tfreeze;
我们可以看到,这两个页面都是 ”全部可见的“,但不是 “全部已冻结”:
SELECT * FROM generate_series(0,1) g(blkno),
pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
blkno | all_visible | all_frozen
-------+-------------+------------
0 | t | f
1 | t | f
(2 rows)
创建行的事务的年龄(xmin_age
)等于 1,也就是说,这是在系统中执行的最后一个事务:
SELECT * FROM heap_page('tfreeze',0,1);
ctid | state | xmin | xmin_age | xmax | t_ctid
-------+--------+---------+----------+-------+--------
(0,1) | normal | 697 (c) | 1 | 0 (a) | (0,1)
(0,2) | normal | 697 (c) | 1 | 0 (a) | (0,2)
(1,1) | normal | 697 (c) | 1 | 0 (a) | (1,1)
(1,2) | normal | 697 (c) | 1 | 0 (a) | (1,2)
(4 rows)