
在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的变化。
要阻止这种时间旅行,清理操作会执行加一个任务(除了进行页清理外):它会搜索超出数据库地平线的元组(在所有的快照里头都可见),并将它们打一个标签,来冻结它们。
对于冻结的元组,可见性规则不必将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)
有4个主要参数来控制冻结。它们都代表各种事务年龄,并定义下述事件何时发生:
参数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将再也不会返回到此页:
像我们前边看到,如果该页只包含所有快照中都可见的元组,清理将不会冻结它们。要克服这个限制,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"打头的:
如果自动清理已经在努力防止事务ID回卷,它也明确是时间上的一个比赛,一个安全切换也被引入:自动清理autovacuum将忽略参数autovacuum_vacuum_cost_delay (vacuum_cost_delay)的值,也会停止清理索引,来尽快进行冻结堆元组。
有安全保障的冻结模式将被开启,如果遇到如下风险:数据库中未冻结的事务的年龄将超过:vacuum_failsafe_age的值 (默认值是1.6M)。假定这个值比autovacuum_freeze_max_age要大。
有时候,手动管理冻结要比自动清理会更方便些。
你可以使用带有参数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表来设置所有页头的可见性属性。
vacuuming例程可以比页裁剪释放更多的空间,但有时候,它仍然做的不够。
如果表或索引文件不断变大,VACUUM能够清理页内的某些空间,但是很少能减少真正的页数。如果有些空页刚好出现在文件末尾,回收的空间只能返给操作系统。但是这种情形非常少见。
过大的文件会导致不太好的结果:
文件中有用数据的比例会在某一个合理的水平下开始下降,管理员可以通过执行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)
原文作者:数据库杂记;如有侵权,请联系管理员删除。