PostgreSQL 教程: 理解冻结

十二月 11, 2024

摘要:在本教程中,您将了解 PostgreSQL 中的冻结。

目录

事务 ID 回卷

PostgreSQL 使用 32 位事务 ID。这是一个相当大的数字(大约 40 亿),但如果服务器的负载压力大,这个数字也是可能会耗尽的。例如:在每秒 1000 个事务的负载压力下,大约在连续运行一个半月的工作后就会耗尽。

但是,多版本并发控制依赖于顺序编号,这意味着在两个事务中,编号较小的事务可被认为是较早开始的。因此,很明显,不能只是重置计数器从头开始编号就行了。

img

那么,为什么不使用 64 位事务 ID?这样不是完全消除了这个问题吗?问题是每个元组的头部存储了两个事务 ID:xminxmax。头部本身就相当大,至少 23 字节,事务 ID 位数的增加会导致头部额外增加 8 个字节。这是完全没有道理的。

那么该怎么办呢?与其以数字形式按顺序对事务 ID 进行排序,不如像一个圆圈或时钟表盘一样。事务 ID 的比较方式与比较时钟读数的意义相同。也就是说,对于每个事务,事务 ID 的“逆时针”部分被视为与过去有关,而“顺时针”部分被视为与将来有关。

事务的年龄定义为自事务在系统中发生以来运行的事务数(无论事务 ID 是否回卷)。为了弄清楚一个事务是否比另一个事务更旧,我们比较它们的年龄而不是 ID 值。(顺便说一句,正是由于这个原因,没有为xid数据类型定义 “大于” 和 “小于” 操作符。

img

但是这种循环的布局很麻烦。一个发生在遥远过去的事务(图中的事务 1),在一段时间后将会进入到圆圈中与未来相关的那一半。这肯定会违反可见性规则,并发生问题:事务 1 所做的更改将会消失在视线以外。

img

元组冻结和可见性规则

为了防止这种从过去到未来的 “时间旅行”,VACUUM 还执行了一项任务(除了释放页面空间)。它会找到非常旧的 “冷” 元组,这些元组在所有快照中都可见并且不太可能更改,并以特殊方式标记它们,即 “冻结” 它们。冻结的元组会被认为比任何正常数据都旧,并且始终在所有快照中都可见。而且它不再需要查看xmin事务号,这个编号可以安全地重复使用。因此,冻结的元组始终保留在过去。

img

为了跟踪标记为冻结的xmin事务,会给元组设置两个提示位:committedaborted

请注意,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;

请注意,两个提示位committedaborted同时设置,表示元组冻结(我们用带括号的 “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)

了解更多

PostgreSQL 管理