ITPub博客

首页 > 数据库 > 数据库开发技术 > PostgreSQL Internals之路 Part-I 第7,8章 冻结、重建表和索引

PostgreSQL Internals之路 Part-I 第7,8章 冻结、重建表和索引

原创 数据库开发技术 作者:数据库杂记 时间:2024-09-17 05:00:00 0 删除 编辑

7. 冻结(Freezing)

7.1 事务ID回卷(Wraparound)

在PG中,事务ID占用32个二进制位。40亿看起来是很大的一个数,但如果系统被有效使用的话,它很快就会被用完。例如,平均每秒1000笔事务的话(包括虚拟事务),6周连续的操作,就会把它耗光。

一旦所有的数都用完,计数器不得不从下一轮重新开始(这种场景就叫做回卷)。但是拥有更小ID的事务将被认为比其它事务更老,如果被赋预的数总是在增长的话。因此计数器在重置以后不能简单的重新使用相同的数。

分配64位的事务ID,将能有效的减少这个问题的发生,为何PG不利用这一点呢?原因是每个元组头必须存储两个事务ID:xmin和xmax,头部已经很大了(至少24个字节,如果将数据对齐也考虑进去的话),添加更多的二进制位,将会引入另一个8字节。

PG也确实实现了64位事务ID,用于扩展32位的epock常规ID,但是它们只是在内部使用,从来没有用到数据页当中。

为正确的处理回卷,PG不得不比较事务的年龄(由自该事务开始时后续的事务的序列号定义)而不是事务的ID。这样一来,我们不是使用术语小于和大于,用的是更老(早于)和更年轻(晚于)的概念。

在代码中,这个比较是使用简单的32位算术运算来实现:首先将32位的事务ID的差求出,然后将其与0进行比较。(backend/access/transam/transam.c, TransactionIdPrecedes function[1])

为了更好的将这个方法可视化,你可以将事务ID想像成一个钟面。每个事务,顺时针方向的半圆表示将来,另一半则表示过去。

但是,这个可视化的场景也有不好的地方。一个老的事务(T1)在遥远的过去,要与最近的事务进行比较。迟早一个新事务会看到它,在将来的那个半圆里头。如果真是这样的话,它将会有灾难性的影响:从现在开始,所有新事务都看不到T1的变化。

7.2 元组冻结和可见性规则

要阻止这种时间旅行,清理操作会执行加一个任务(除了进行页清理外):它会搜索超出数据库地平线的元组(在所有的快照里头都可见),并将它们打一个标签,来冻结它们。

对于冻结的元组,可见性规则不必将xmin考虑进去,因为这些元组已经 在各个快照里头都可见, 因而这些事务ID是可以安全的重用。

你可以想像xmin事务ID在冻结的元组里头被替换成假定的负无穷(下图中的雪花形);这是一个标志,意味着该元组是由某事务在很远的过去创建而成,它的真实ID已经没有意义了。然而在现实中,xmin依然不变,冻结属性是由另两个二进制位共同决定:committed和aborted。

很多资料(包括文档)都提到FrozenTranactionId = 2。这就是我前边意指的负无穷---该值在9.4以前的版本用于替换xmin的值,但是现在使用的是hint的二进制位。这样的话,原始的事务ID仍然会保留在元组当中,这也便于进行调试和支持。旧的系统也仍然能保留过去的FrozenTransactionId,即使升级到更高版本。

事务ID xmax不以任何方式参与到冻结操作。它只出现在过期的元组里,一旦这样的元组在所有的快照里不可见(意味着xmax ID超出了数据库的地平线),它们将会被清理出去。

我们创建一张新表用于下边的实验。fillfactor参数必须设成最小值,这样每页只能容纳两个元组---这样也便于追踪实际的进度。同时也禁用autovacuum,以确保新表是按照实际需要进行清理。

CREATE TABLE tfreeze(
id integer,
s char(300)
)
WITH (fillfactor = 10, autovacuum_enabled = off);

我们还要创建另一个函数,用以借助于pageinspect显示堆页上的信息。处理一个范围以内的页,显示冻结属性(f)和每个元组xmin事务的年龄(它必须调用与年龄相关的系统函数---年龄值显然是不会存储到堆页里头):

现在我们往表里插入若干行,运行VACUUM命令,它会立即创建VM。

INSERT INTO tfreeze(id, s) SELECT id, 'FOO'||id FROM generate_series(1,100) id;
INSERT 0 100

VACUUM tfreeze;
VACUUM

我们打算观察头两个堆页,使用pg_visibility插件。当清理完成时,两个页面都会在vm(all_visible)上打上标记,但是在冻结映射(all_frozen),因为它们仍然还有一些未冻结的元组:

CREATE EXTENSION pg_visibility;
SELECT * FROM generate_series(0,1) g(blkno),
mydb-#     pg_visibility_map('tfreeze',g.blkno)
mydb-# 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
-------+--------+-------+----------+------
 (0,1) | normal | 858 c |        1 | 0 a
 (0,2) | normal | 858 c |        1 | 0 a
 (1,1) | normal | 858 c |        1 | 0 a
 (1,2) | normal | 858 c |        1 | 0 a
(4 rows)

7.3 管理冻结

有4个主要参数来控制冻结。它们都代表各种事务年龄,并定义下述事件何时发生:

  • 冻结开始(vacuum_freeze_min_age)
  • 执行强力冻结 (vacuum_freeze_table_age)
  • 冻结强制执行 (autovacuum_freeze_max_age)
  • 冻结接收优先级(vacuum_failsafe_age)

最小冻结年龄

参数vacuum_freeze_min_age定义了xmin事务的最小的冻结年龄。此值越小,开销越大:如果某行是“热”行,并且不断地改变,那么冻结它那些新建的版本将是一种浪费。将该值设成相对高一点,将允许你多等一段时间。

要观察冻结过程,我们可以将此参数值减少到1:

ALTER SYSTEM SET vacuum_freeze_min_age = 1;
SELECT pg_reload_conf();

现在我们更新第0页的一行。新行版本将位于同一页,因为fillfactor值相当小:

UPDATE tfreeze SET s = 'BAR' WHERE id = 1;
UPDATE 1

所有事务的年龄会加1, 堆页现在看起来如下所示:

ELECT * FROM heap_page('tfreeze',0,1);
 ctid  | state  | xmin  | xmin_age | xmax
-------+--------+-------+----------+------
 (0,1) | normal | 858 c |        2 | 859
 (0,2) | normal | 858 c |        2 | 0 a
 (0,3) | normal | 859   |        1 | 0 a
 (1,1) | normal | 858 c |        2 | 0 a
 (1,2) | normal | 858 c |        2 | 0 a
(5 rows)

在这个时间点上,比vacuum_freeze_min_age = 1还老的元组将被冻结。但是清理vacuum进程并没有处理VM中的任何页:

SELECT * FROM generate_series(0,1) g(blkno),
pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
 blkno | all_visible | all_frozen
-------+-------------+------------
     0 | f           | f
     1 | t           | f
(2 rows)

前一次的UPDATE命令已经将第0页的可见2进制位给移除了,因此该页中符合条件的xmin年龄的元组将会被冻结。但是第1页会被跳过:

VACUUM tfreeze;
SELECT * FROM heap_page('tfreeze',0,1);
 ctid | state          | xmin  | xmin_age | xmax
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−+−−−−−−−−−−+−−−−−−
(0,1) | redirect to 3  |       |          |
(0,2) | normal         | 856 f | 2        | 0 a
(0,3) | normal         | 857 c | 1        | 0 a
(1,1) | normal         | 856 c | 2        | 0 a
(1,2) | normal         | 856 c | 2        | 0 a
(5 rows)

现在第0页又出现vm里头了,如果它没有发生变化,vacuum将再也不会返回到此页:

强力冻结(aggressive freezing)年龄

像我们前边看到,如果该页只包含所有快照中都可见的元组,清理将不会冻结它们。要克服这个限制,PG提供了参数: vacuum_freeze_table_age (默认值是:  150M)。它定义了事务的年龄,允许清理进程忽略VM,这样任意堆页面都可以被冻结。

对每张表,系统catalog保留一个事务ID,所有更老的事务将被冻结。称之为:relfrozenid。

SELECT relfrozenxid, age(relfrozenxid)
FROM pg_class
WHERE relname = 'tfreeze';
 relfrozenxid | age
--------------+-----
          856 |   4
(1 row)

这个事务的年龄,与vacuum_freeze_table_age进行比较,以确定是否要进行强力冻结。

感谢冻结映射(freeze map) (v9.6),在清理过程中,不需要执行一次全表扫描。只需要检查那些没出现在这个映射当中的页。除了这个重要的优化,冻结映射也带来了容错功能:如果清理被中断,下一次运行不会回到已经被处理的并打了标记的那些页。

每当系统中事务的数量达到e vacuum_freeze_table_age − vacuum_freeze_min_age 这个限制时,PG对表中的所有页执行强力冻结。使用默认值,将是每当100M个事务的时候。这样看来,如果vacuum_freeze_min_age 值太大,它将会导致强力冻结,也会增加开销。

要冻结整张表,我们可以将vacuum_freeze_table_age减至4,这样强力冻结的条件将会满足:

ALTER SYSTEM SET vacuum_freeze_table_age = 4;
SELECT pg_reload_conf();

运行VACUUM命令:

VACUUM VERBOSE tfreeze;
INFO:  aggressively vacuuming "public.tfreeze"
INFO:  table "tfreeze": found 0 removable, 100 nonremovable row versions in 50 out of 50 pages
DETAIL:  0 dead row versions cannot be removed yet, oldest xmin: 860
Skipped 0 pages due to buffer pins, 0 frozen pages.
CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s.
VACUUM

由于整个表都被分析了,relfrozenid的值可以往前移---堆页可以确保没有更老的未被冻结的xmin事务:

SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid | age
--------------+-----
          859 |   1
(1 row)

第1页,现在只包含冻结的元组了:

SELECT * FROM heap_page('tfreeze',0,1);
 ctid  |     state     | xmin  | xmin_age | xmax
-------+---------------+-------+----------+------
 (0,1) | redirect to 3 |       |          |
 (0,2) | normal        | 858 f |        2 | 0 a
 (0,3) | normal        | 859 c |        1 | 0 a
 (1,1) | normal        | 858 f |        2 | 0 a
 (1,2) | normal        | 858 f |        2 | 0 a
(5 rows)

另外,该页也在冻结映射里头有相关定义:

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           | t
(2 rows)

强制自动清理的年龄

要及时处理冻结元组,有时候光配置前边讨论的两个参数是不够的。自动清理可能需要关闭,而常规的清理又根本没被调用(这是个非常糟糕的主意,但技术上是可能的)。另外,有些不活动的数据库(像template0)可能不会被清理。PG基于强力模式通过强制自动清理来处理这种情况。

当遇到风险:数据库当中某些未冻结的事务ID将超过autovacuum_freeze_max_age时,自动清理将会强制执行(即使被关掉)。这个决定是基于所有表中的最老的pg_class.relfrozenxid事务,所有更老的事务确保要被冻结。这个事务的ID存储在系统catalog:

SELECT datname, datfrozenxid, age(datfrozenxid) FROM pg_database;
  datname  | datfrozenxid | age
-----------+--------------+-----
 postgres  |          726 | 134
 mydb      |          726 | 134
 template1 |          726 | 134
 template0 |          726 | 134
(4 rows)

autovacuum_freeze_max_age值设置到20亿个事务(比半个圆稍小),而缺省值要小10倍。这样做也是有原因的:大点的值会增加事务ID回卷的风险,因为PG可能在冻结所有适合的元组时失败。这种情况下,服务器必须立即停掉,以避免可能的问题,还必须经由管理员进行重启。

autovacuum_freeze_max_age的值同样会影响CLOG的大小。没有必要保留冻结事务的状态,所有在最老datfrozenxid之前的事务必须被冻结。这些CLOG文件也没有必要保留,将会被自动清理进程清理掉。

改变autovacuum_freeze_max_age参数值,需要重启服务器。但是,前边讨论的所有的冻结参数设置,同时可以在表一级进行调整,通过设置相应的存储参数即可。注意那些参数的名字是以“auto"打头的:

  • autovacuum_freeze_min_age and toast.autovacuum_freeze_min_age
  • autovacuum_freeze_table_age and toast.autovacuum_freeze_table_age
  • autovacuum_freeze_max_age and toast.autovacuum_freeze_max_age

安全保障冻结的年龄 (age for failsafe freezing)

如果自动清理已经在努力防止事务ID回卷,它也明确是时间上的一个比赛,一个安全切换也被引入:自动清理autovacuum将忽略参数autovacuum_vacuum_cost_delay (vacuum_cost_delay)的值,也会停止清理索引,来尽快进行冻结堆元组。

有安全保障的冻结模式将被开启,如果遇到如下风险:数据库中未冻结的事务的年龄将超过:vacuum_failsafe_age的值 (默认值是1.6M)。假定这个值比autovacuum_freeze_max_age要大。

7.4 手动冻结

有时候,手动管理冻结要比自动清理会更方便些。

通过清理来冻结

你可以使用带有参数FREEZE选项的命令vacuum启动冻结。它将冻结所有的堆元组,不管它们的事务的年龄,就像vacuum_freeze_min_age=0一样。

如果这样的调用的目的是为了尽快冻结堆元组,禁用索引的清理就是有意义的,就你在安全保障模式下所做的一样。你可以通过命令VACUUM (freeze, index_cleanup false)命令显式的使用,或者通过存储参数:vacuum_index_cleanup。更明显的是,它不应该在常规情况下去执行,因为这种情况下,VACUUM与页清理的主任务并不能很好的搭配。

初始装载下的数据冻结

在装载进数据库的时候,不期望改变的数据可以立即被冻结。这个可以通过运行带FREEZE选项的COPY命令来运行。

元组可以在初始装载时被冻结,仅当结果表在同一事务当中已被创建或者截断,因为这两个操作都是获取该表的排它锁。这个限制是有必要的,因为冻结的元组期望在所有的快照里是可见的,不管它是什么隔离级。否则,事务会突然看起来是新的---冻结的元组被上传。但是如果能获得锁,其它事务就不会访问到该表。

然而,在技术上仍然可能打破隔离性。我们在一个新的会话里开启一个可重复读的事务:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT 1; -- the shapshot is built

对表tfreeze进行截断,同一事务中插入新行。(如果只读事务已经访问了tfreeze表,TRUNCATE命令将被阻塞。)

BEGIN;
TRUNCATE tfreeze;
COPY tfreeze FROM stdin WITH FREEZE;
1 FOO
2 BAR
3 BAZ
\.
COMMIT;

现在读事务也能看到新数据:

SELECT count(*) FROM tfreeze;
count
−−−−−−−
3
(1 row)
=> COMMIT;

这确实打破了隔离性,但是数据加载不太可能按常规处理,在绝大多数情况下不会造成什么问题。

如果你使用冻结来加载数据,VM会立即创建,页头部也会接收可见性属性值:

SELECT * FROM pg_visibility_map('tfreeze',0);
 all_visible | all_frozen
-------------+------------
 t           | t
(1 row)

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

这样,如果数据以冻结方式加载,表将不会进行VACUUM处理(只要数据未发生改变,v14)。不幸的是,这个功能在TOAST表中还不支持:如果过量的数据被加载,VACUUM不得不重写整个TOAST表来设置所有页头的可见性属性。

8. 重建表和索引

8.1 完全清理(full vacuuming)

为何vacuuming例程还不够?

vacuuming例程可以比页裁剪释放更多的空间,但有时候,它仍然做的不够。

如果表或索引文件不断变大,VACUUM能够清理页内的某些空间,但是很少能减少真正的页数。如果有些空页刚好出现在文件末尾,回收的空间只能返给操作系统。但是这种情形非常少见。

过大的文件会导致不太好的结果:

  • 全表扫描(或索引扫描)会花更长的时间
  • 需要更大的buffer cache(页要做为一个整体进行缓存,数据的密度也会降低)。
  • B-树会产生附加的层级,也让索引访问变慢
  • 文件会占据更多的磁盘空间,备份也一样

文件中有用数据的比例会在某一个合理的水平下开始下降,管理员可以通过执行VACUUM FULL命令执行一次完全清理。在这种情况下,表和它所有的索引都会从头开始重建,数据也会被压缩的尽量稠密些(同时考虑fillfactor参数)。

当完全清理执行的时候,PG首先重建表,接着会重建它的各个索引。当一个对象被重建时,旧文件和新文件都要存到磁盘上,因此这个进程需要大量的空闲空间。

你必须也要记住这个操作完全阻塞对表的访问,既包括读也包括写操作。

数据稠密度的预估

出于演示的目的,我们往表中插入一些记录行:

TRUNCATE vac;
INSERT INTO vac(id,s)
SELECT id, id::text FROM generate_series(1,500000) id;

存储的稠密度可以使用pgstattuple插件进行预估:

CREATE EXTENSION pgstattuple;
CREATE EXTENSION
mydb=# SELECT * FROM pgstattuple('vac') \gx
-[ RECORD 1 ]------+---------
table_len          | 70623232
tuple_count        | 500000
tuple_len          | 64500000
tuple_percent      | 91.33
dead_tuple_count   | 0
dead_tuple_len     | 0
dead_tuple_percent | 0
free_space         | 381844
free_percent       | 0.54

这个函数读取了整张表,显示文件里头空间分布的统计信息。tuple_percent字段显示了有用数据(堆元组)所占空间的百分比。这个值肯定小于100%,因为在页里头还有各种元数据,但在此例中它依然很高。

对于索引,显示信息有点不同。但是avg_leaf_density字段表示相同的含义:显示有用数据(B-树叶子页)的百分比。

SELECT * FROM pgstatindex('vac_s') \gx
-[ RECORD 1 ]------+----------
version            | 4
tree_level         | 3
index_size         | 114302976
root_block_no      | 2825
internal_pages     | 376
leaf_pages         | 13576
empty_pages        | 0
deleted_pages      | 0
avg_leaf_density   | 53.88
leaf_fragmentation | 10.59

前边用pgstattuple函数完全读表或索引以得到精确的统计数据。对于大对象,这样的访问代价昂贵,因此这个插件又提供了另一个函数:pgstattuple_approx,它会跳过VM中跟踪的页,只显示大概的情况。

更快但是不那么精确的方法是使用系统catalog粗略估计数据容量和文件大小之间的一个比例。

这里是表及其索引的当前大小:

SELECT pg_size_pretty(pg_table_size('vac')) AS table_size,
mydb-# pg_size_pretty(pg_indexes_size('vac')) AS index_size;
 table_size | index_size
------------+------------
 67 MB      | 109 MB
(1 row)

现在我们从所有行中删除90%的数据:

DELETE FROM vac WHERE id % 10 != 0;
DELETE 450000

例程vacuuming并不会影响文件大小,因为在文件末尾并没有什么空页。

VACUUM vac;
VACUUM
mydb=# SELECT pg_size_pretty(pg_table_size('vac')) AS table_size,
mydb-# pg_size_pretty(pg_indexes_size('vac')) AS index_size;
 table_size | index_size
------------+------------
 67 MB      | 109 MB
(1 row)

但是数据稠密度下降了差不多10倍:

SELECT vac.tuple_percent, vac_s.avg_leaf_density
 FROM pgstattuple('vac') vac, pgstatindex('vac_s') vac_s;
 tuple_percent | avg_leaf_density
---------------+------------------
          9.13 |             6.71
(1 row)

表和索引现在位于下述文件里:

SELECT pg_relation_filepath('vac') AS vac_filepath,
 pg_relation_filepath('vac_s') AS vac_s_filepath \gx
-[ RECORD 1 ]--+-----------------
vac_filepath   | base/16384/24824
vac_s_filepath | base/16384/24825

我们来检查下VACUUM FULL之后会看到什么。当命令执行的时候,它的进度可以通过视图pg_stat_progress_cluster查到(这个与VACUUM对应的pg_stat_progress_vacuum视图类似):

VACUUM FULL vac;
SELECT * FROM pg_stat_progress_cluster \gx
−[ RECORD 1 ]−−−−−−−+−−−−−−−−−−−−−−−−−
pid | 19492
datid | 16391
datname | internals
relid | 16479
command | VACUUM FULL
phase | rebuilding index
cluster_index_relid | 0
heap_tuples_scanned | 50000
heap_tuples_written | 50000
heap_blks_total | 8621
heap_blks_scanned | 8621
index_rebuild_count | 0

VACUUM FULL阶段与vacuuming例程是不太相同。完全清理会用新文件去代替旧文件:

SELECT pg_relation_filepath('vac') AS vac_filepath,
mydb-# pg_relation_filepath('vac_s') AS vac_s_filepath \gx
-[ RECORD 1 ]--+-----------------
vac_filepath   | base/16384/24836
vac_s_filepath | base/16384/24839

索引和表的大小变得非常小了:

SELECT pg_size_pretty(pg_table_size('vac')) AS table_size,
mydb-# pg_size_pretty(pg_indexes_size('vac')) AS index_size;
 table_size | index_size
------------+------------
 6904 kB    | 6504 kB
(1 row)

结果,数据稠密度增加了。索引的稠密度甚至比原来还高:在现存数据基础上从头开始建索引,它比在现存的索引上边一行行的插入数据生成要高效的多。

SELECT vac.tuple_percent,
mydb-# vac_s.avg_leaf_density
mydb-# FROM pgstattuple('vac') vac,
mydb-# pgstatindex('vac_s') vac_s;
 tuple_percent | avg_leaf_density
---------------+------------------
         91.23 |            91.08
(1 row)

冻结

当表被重建时,PG会冻结它的元组,因为这个操作几乎没什么成本,与其它部分的任务相比而言。参考:PostgreSQL: Documentation: 14: 28.4. Progress Reporting[2]

SELECT * FROM heap_page('vac',0,0) LIMIT 5;
 ctid  | state  | xmin  | xmin_age | xmax
-------+--------+-------+----------+------
 (0,1) | normal | 870 f |        6 | 0 a
 (0,2) | normal | 870 f |        6 | 0 a
 (0,3) | normal | 870 f |        6 | 0 a
 (0,4) | normal | 870 f |        6 | 0 a
 (0,5) | normal | 870 f |        6 | 0 a
(5 rows)

但是页既没在vm上,也没在fsm上注册,页头也不会接收可见性属性(就像COPY命令执行时带有FREEZE选项一样):

mydb=# SELECT * FROM pg_visibility_map('vac',0);
 all_visible | all_frozen
-------------+------------
 f           | f
(1 row)

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

这种情况只会在VACUUM调用的时候得到改善(或者AUTOVACUUM触发的时候):

mydb=# VACUUM vac;
VACUUM
mydb=# SELECT * FROM pg_visibility_map('vac',0);
 all_visible | all_frozen
-------------+------------
 t           | t
(1 row)

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

原文作者:数据库杂记;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2019-04-22

  • 博文量
    276
  • 访问量
    160266