聊一聊 vacuum 的页内收缩机制
1前言
昨天在群里分享了一篇 The Part of PostgreSQL We Hate the Most,文章内容抨击了一下 PostgreSQL 的弱鸡 MVCC 、膨胀以及 vacuum 的复杂管理,其实我已见怪不怪,毕竟萝卜青菜各有所爱,每隔一阵就会被拉出来鱼肉一下,不过这不是重点,本文不是点炮文,口水战我也不感兴趣,有这时间我还是研究一下 PostgreSQL。我感兴趣的是群里一位同志提的问题:
其实这个原理我是清楚的,页内收缩 (shrink),虽然后面有位老铁的回答是正确的,但是可惜没有正确 get 到这位群友的问题点:vacuum 会挪动元组吗?让我们分析一下(目前有三个交流群,可以后台留言哈)
2分析
参照上图,经过 vacuum 之后,Live Tuple 被全部"挪"到了一起,变得紧实,那么真会这么做吗?让我们分析一下
首先我们需要温顾一下页面的内部结构,引用一下 SUZUKI 大师经典的图片
我们关注的几点如下:
pd_flags:标识页面的数据存储情况,目前只用了 3 位,没有使用的二进制位都初始化为 0 以供后续可能的使用,比如 PD_PAGE_FULL 标识是否没有剩余空间以供新的 Tuple 插入
pd_lower:空闲空间的指针,指向行指针 pd_linp 的最后一个元素之后。
pd_upper:空闲空间的指针,指向偏移量最小的 Tuple 数据之前。
pd_linp:行指针数组,指向具体行 Tuple 的位置,有三个部分组成,其中里面包括了 lp_off,Tuple 的偏移量,总共 15 bit,这也是为什么最大只支持 32K 的块大小
实际数据是倒着放的
有了这些前置知识,让我们来复现一下
postgres=# create table test(id int,info text);
CREATE TABLE
postgres=# insert into test select n,'test'||n from generate_series(1,10) as n;
INSERT 0 10
postgres=# select lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('test',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | t_data
----+--------+----------+--------+---------+--------+--------+--------------------------
1 | 8152 | 1 | 34 | 1997914 | 0 | (0,1) | \x010000000d7465737431
2 | 8112 | 1 | 34 | 1997914 | 0 | (0,2) | \x020000000d7465737432
3 | 8072 | 1 | 34 | 1997914 | 0 | (0,3) | \x030000000d7465737433
4 | 8032 | 1 | 34 | 1997914 | 0 | (0,4) | \x040000000d7465737434
5 | 7992 | 1 | 34 | 1997914 | 0 | (0,5) | \x050000000d7465737435
6 | 7952 | 1 | 34 | 1997914 | 0 | (0,6) | \x060000000d7465737436
7 | 7912 | 1 | 34 | 1997914 | 0 | (0,7) | \x070000000d7465737437
8 | 7872 | 1 | 34 | 1997914 | 0 | (0,8) | \x080000000d7465737438
9 | 7832 | 1 | 34 | 1997914 | 0 | (0,9) | \x090000000d7465737439
10 | 7792 | 1 | 35 | 1997914 | 0 | (0,10) | \x0a0000000f746573743130
(10 rows)postgres=# select * from page_header(get_raw_page('test',0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-------------+----------+-------+-------+-------+---------+----------+---------+-----------
23/82AA1C30 | 0 | 0 | 64 | 7792 | 8192 | 8192 | 4 | 0
(1 row)
写过脚本解析一下
[postgres@xiongcc ~]$ ./encode.sh
TID: 1 data offset: 8152 length: 34 binary: 0000000001000100 1001111111011000
TID: 2 data offset: 8112 length: 34 binary: 0000000001000100 1001111110110000
TID: 3 data offset: 8072 length: 34 binary: 0000000001000100 1001111110001000
TID: 4 data offset: 8032 length: 34 binary: 0000000001000100 1001111101100000
TID: 5 data offset: 7992 length: 34 binary: 0000000001000100 1001111100111000
TID: 6 data offset: 7952 length: 34 binary: 0000000001000100 1001111100010000
TID: 7 data offset: 7912 length: 34 binary: 0000000001000100 1001111011101000
TID: 8 data offset: 7872 length: 34 binary: 0000000001000100 1001111011000000
TID: 9 data offset: 7832 length: 34 binary: 0000000001000100 1001111010011000
TID: 10 data offset: 7792 length: 35 binary: 0000000001000110 1001111001110000
lp_off 是偏移量,可以看到数据确实是倒着放的,第一条数据的偏移量最大 8152,位于数据块末尾
lp_flags 是1,表示正常使用,分为四种状态:
0 : LP_UNUSED , 未使用,对应的 lp_len 总是为 0 1 : LP_NORMAL , 正常使用,对应的 lp_len 总是大于 0 2 : LP_REDIRECT , HOT 特性中重定向的 Tuple,对应的 lp_len = 0 3 : LP_DEAD , dead 状态,对应的存储空间 lp_len 不确定,可能为 0,可能大于 0
比如我们在查找空闲 slot 的时候就会去判断 lp_flags = LP_UNUSED。现在让我们在中间删除几条数据
postgres=# delete from test where id in (2,4,6,8);
DELETE 4
postgres=# select * from page_header(get_raw_page('test',0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-------------+----------+-------+-------+-------+---------+----------+---------+-----------
23/82AA7EA0 | 0 | 0 | 64 | 7792 | 8192 | 8192 | 4 | 1997915
(1 row)postgres=# select lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('test',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | t_data
----+--------+----------+--------+---------+---------+--------+--------------------------
1 | 8152 | 1 | 34 | 1997914 | 0 | (0,1) | \x010000000d7465737431
2 | 8112 | 1 | 34 | 1997914 | 1997915 | (0,2) | \x020000000d7465737432
3 | 8072 | 1 | 34 | 1997914 | 0 | (0,3) | \x030000000d7465737433
4 | 8032 | 1 | 34 | 1997914 | 1997915 | (0,4) | \x040000000d7465737434
5 | 7992 | 1 | 34 | 1997914 | 0 | (0,5) | \x050000000d7465737435
6 | 7952 | 1 | 34 | 1997914 | 1997915 | (0,6) | \x060000000d7465737436
7 | 7912 | 1 | 34 | 1997914 | 0 | (0,7) | \x070000000d7465737437
8 | 7872 | 1 | 34 | 1997914 | 1997915 | (0,8) | \x080000000d7465737438
9 | 7832 | 1 | 34 | 1997914 | 0 | (0,9) | \x090000000d7465737439
10 | 7792 | 1 | 35 | 1997914 | 0 | (0,10) | \x0a0000000f746573743130
(10 rows)
做个 vacuum 再次观察一下
postgres=# select * from page_header(get_raw_page('test',0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-------------+----------+-------+-------+-------+---------+----------+---------+-----------
23/82AA9FD8 | 0 | 5 | 64 | 7952 | 8192 | 8192 | 4 | 0
(1 row)postgres=# select lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('test',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | t_data
----+--------+----------+--------+---------+--------+--------+--------------------------
1 | 8152 | 1 | 34 | 1997914 | 0 | (0,1) | \x010000000d7465737431
2 | 0 | 0 | 0 | | | |
3 | 8112 | 1 | 34 | 1997914 | 0 | (0,3) | \x030000000d7465737433
4 | 0 | 0 | 0 | | | |
5 | 8072 | 1 | 34 | 1997914 | 0 | (0,5) | \x050000000d7465737435
6 | 0 | 0 | 0 | | | |
7 | 8032 | 1 | 34 | 1997914 | 0 | (0,7) | \x070000000d7465737437
8 | 0 | 0 | 0 | | | |
9 | 7992 | 1 | 34 | 1997914 | 0 | (0,9) | \x090000000d7465737439
10 | 7952 | 1 | 35 | 1997914 | 0 | (0,10) | \x0a0000000f746573743130
(10 rows)
[postgres@xiongcc ~]$ ./encode.sh
TID: 1 data offset: 8152 length: 34 binary: 0000000001000100 1001111111011000
TID: 2 data offset: 0 length: 0 binary: 0000000000000000 0000000000000000
TID: 3 data offset: 8112 length: 34 binary: 0000000001000100 1001111110110000
TID: 4 data offset: 0 length: 0 binary: 0000000000000000 0000000000000000
TID: 5 data offset: 8072 length: 34 binary: 0000000001000100 1001111110001000
TID: 6 data offset: 0 length: 0 binary: 0000000000000000 0000000000000000
TID: 7 data offset: 8032 length: 34 binary: 0000000001000100 1001111101100000
TID: 8 data offset: 0 length: 0 binary: 0000000000000000 0000000000000000
TID: 9 data offset: 7992 length: 34 binary: 0000000001000100 1001111100111000
TID: 10 data offset: 7952 length: 35 binary: 0000000001000110 1001111100010000
各位可能发现了,原来第三条元组的偏移量从 8072 → 8112,第五条元组 7992 → 8072,说明都往后"挪"了,而且 lp 保持不变,没有被回收,只是设置了 lp_flags 为 0,下次可以重用(假如空间够)。
现在让我们重新插入 2 这条数据,应为和之前数据一样,所以可以复用,只不过按照刚刚的说法,会放到"后面"
postgres=# insert into test values(2,'test2');
INSERT 0 1
postgres=# select lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('test',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | t_data
----+--------+----------+--------+---------+--------+--------+--------------------------
1 | 8152 | 1 | 34 | 1997914 | 0 | (0,1) | \x010000000d7465737431
2 | 7912 | 1 | 34 | 1997916 | 0 | (0,2) | \x020000000d7465737432
3 | 8112 | 1 | 34 | 1997914 | 0 | (0,3) | \x030000000d7465737433
4 | 0 | 0 | 0 | | | |
5 | 8072 | 1 | 34 | 1997914 | 0 | (0,5) | \x050000000d7465737435
6 | 0 | 0 | 0 | | | |
7 | 8032 | 1 | 34 | 1997914 | 0 | (0,7) | \x070000000d7465737437
8 | 0 | 0 | 0 | | | |
9 | 7992 | 1 | 34 | 1997914 | 0 | (0,9) | \x090000000d7465737439
10 | 7952 | 1 | 35 | 1997914 | 0 | (0,10) | \x0a0000000f746573743130
(10 rows)postgres=# select *,ctid from test;
id | info | ctid
----+--------+--------
1 | test1 | (0,1)
2 | test2 | (0,2)
3 | test3 | (0,3)
5 | test5 | (0,5)
7 | test7 | (0,7)
9 | test9 | (0,9)
10 | test10 | (0,10)
(7 rows)
可以看到此时的偏移量是 7912。因此,用图来表示的话:
初始状态如下,lp 指向各条元组
删除部分数据之后,也就是这些红色的部分
经过 vacuum 清理之后,各位可能也发现了,这样会产生很多内部碎片,空洞
于是数据库会进行页内收缩,减少碎片,这样的好处就是页面内部可以避免膨胀,提升利用率
3小结
可以看到,其实 PostgreSQL 的 vacuum 里面有很多门路和细节,这些都需要我们去探索,就比如 vacuum truncate,仅当尾部空闲空间至少占表的 1/16 大小或已达到 1000 页的长度时才执行截断
找个时间我来好好聊一聊 vacuum 的全部原理和细节吧。
4参考
https://developer.aliyun.com/article/647442
https://ottertune.com/blog/the-part-of-postgresql-we-hate-the-most/