PostgreSQL学徒

聊一聊 vacuum 的页内收缩机制

1前言

昨天在群里分享了一篇 The Part of PostgreSQL We Hate the Most,文章内容抨击了一下 PostgreSQL 的弱鸡 MVCC 、膨胀以及 vacuum 的复杂管理,其实我已见怪不怪,毕竟萝卜青菜各有所爱,每隔一阵就会被拉出来鱼肉一下,不过这不是重点,本文不是点炮文,口水战我也不感兴趣,有这时间我还是研究一下 PostgreSQL。我感兴趣的是群里一位同志提的问题:

Image

其实这个原理我是清楚的,页内收缩 (shrink),虽然后面有位老铁的回答是正确的,但是可惜没有正确 get 到这位群友的问题点:vacuum 会挪动元组吗?让我们分析一下(目前有三个交流群,可以后台留言哈)

Image

2分析

参照上图,经过 vacuum 之后,Live Tuple 被全部"挪"到了一起,变得紧实,那么真会这么做吗?让我们分析一下

首先我们需要温顾一下页面的内部结构,引用一下 SUZUKI 大师经典的图片

Image
The Internals of PostgreSQL : Chapter 1 Database Cluster, Databases, and  Tables

我们关注的几点如下:

  1. pd_flags:标识页面的数据存储情况,目前只用了 3 位,没有使用的二进制位都初始化为 0 以供后续可能的使用,比如 PD_PAGE_FULL 标识是否没有剩余空间以供新的 Tuple 插入

  2. pd_lower:空闲空间的指针,指向行指针 pd_linp 的最后一个元素之后。

  3. pd_upper:空闲空间的指针,指向偏移量最小的 Tuple 数据之前。

  4. pd_linp:行指针数组,指向具体行 Tuple 的位置,有三个部分组成,其中里面包括了 lp_off,Tuple 的偏移量,总共 15 bit,这也是为什么最大只支持 32K 的块大小

    Image

  5. 实际数据是倒着放的

有了这些前置知识,让我们来复现一下

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 指向各条元组

Image

删除部分数据之后,也就是这些红色的部分

Image

经过 vacuum 清理之后,各位可能也发现了,这样会产生很多内部碎片,空洞

Image

于是数据库会进行页内收缩,减少碎片,这样的好处就是页面内部可以避免膨胀,提升利用率

Image

3小结

可以看到,其实 PostgreSQL 的 vacuum 里面有很多门路和细节,这些都需要我们去探索,就比如 vacuum truncate,仅当尾部空闲空间至少占表的 1/16 大小或已达到 1000 页的长度时才执行截断

Image

找个时间我来好好聊一聊 vacuum 的全部原理和细节吧。

4参考

https://developer.aliyun.com/article/647442

https://ottertune.com/blog/the-part-of-postgresql-we-hate-the-most/