PostgreSQL空闲空间映射表
我们查看PostgreSQL数据库实例,表文件时,会发现以_fsm后缀命名的文件,这个文件是空闲空间映射表(Free Space Map,FSM),用于记录表的空闲空间。这里我们分析一下为什么会有FSM,以及它是如何设计的。
为什么需要空闲空间映射表?
随着表不断地执行插入和删除元组,元组块中必然产生空闲空间,当需要新插入一个元组时,会存在一个问题,这个元组插入那个页面呢?如果遍历整个表,找到第一个有足够空闲空间的页面,那么效率会非常低,因此PostgreSQL引入了空闲空间映射表,用于快速定位一个有足够空间存储新元组的页面,或者确定没有这样的页面,从而需要扩展一个新页面。PostgreSQL源代码中的README是这样说的The purpose of the free space map is to quickly locate a page with enough free space to hold a tuple to be stored; or to determine that no such page exists and the relation must be extended by one page.翻译过来就是为了快速找到一个有足够空间存储元组的页面,或者确定没有这样的页面,从而需要扩展一个页面。
FSM如何设计?
为了加快查找速度,FSM文件应该尽量的小,我们知道一个页是8KB大小,也就是最大空闲空间不会超过8192,表示8192需要2个字节,为了压缩FSM文件的大小,我们用一个字节去表示每个页的空闲空间大小,一个字节有8位,最大表示255,8192/256=32,所以,实际FSM文件中存储的值需要乘以32为其实际大小。0表示没有空闲空间。
那么具体的FSM文件是怎么组织的呢?最简单的方式是采用一个大数组的方式。
typedef uint32 BlockNumber;
#define InvalidBlockNumber ((BlockNumber) 0xFFFFFFFF)
#define MaxBlockNumber ((BlockNumber) 0xFFFFFFFE)
PostgreSQL中最多可以有2^32-1个数据页,每个数据页需要用1个字节表示空闲空间大小,则需要4G的空间,在如此大的空间查找到是否有足够空闲空间的块,采用数组的方式查找效率是非常低的,需要数组进行遍历,O(N),最差的情况需要遍历所有的数据,这是不可接受的。因此,PostgreSQL采用了一种树状结构来组织FSM文件,FSM文件由8KB的FSM块页组成,在FSM块之间使用3层树的结构,第0层和第1层位辅助层,第2层FSM中用于实际存放各表块的空闲空间值。每层构造最大堆,每个FSM块内构造一个局部的最大堆二叉树。这样
For example:
4
4 2
3 4 0 2 <- This level represents heap pages
为什么是三层结构? 每个FSM块默认大小为8KB,除去必要的文件块头部信息,FSM块中剩下的空间全部用来存储块内的最大堆二叉树,每个叶子节点用一个字节表示空闲空间大小,根据完全二叉树的性质可以计算出来,每个FSM块大概可以保存4000个叶子节点,两层结构的话,可以保存4000*4000 < 2^32个叶子节点。

三层的话400040004000 > 2^32,所以需要三层结构。
为什么通过8KB的页来存储FSM数据?与数据页Page大小一致,可以复用缓冲区管理逻辑,另一方面是硬件特性与操作系统综合权衡的结果。
表插入元组的过程
这里只分析大概的流程,实际向表中插入一条元组很复杂,涉及到很多细节,比如锁,WAL日志,事务等等。向表中插入元组,首先要确定插入到那个页中,这个页需要有大于新增的元组的大小的空间空间,可需要调用GetPageWithFreeSpace(Relation rel, Size spaceNeeded)返回含有指定空闲空间大小的块号。
ExecInsert
--> table_tuple_insert
--> heapam_tuple_insert
// 向heap表中插入元组
--> heap_insert
--> RelationGetBufferForTuple(relation, heaptup->t_len,...) // 获取一个可插入tuple的数据页,页的空闲空间需要大于heaptup->t_len
--> GetPageWithFreeSpace(relation, targetFreeSpace); // 获取一个有足够空间存储元组的页面
--> page = BufferGetPage(buffer); // 获取页面
--> pageFreeSpace = PageGetHeapFreeSpace(page); // 获取页面空闲空间大小
--> RelationSetTargetBlock(relation, targetBlock); // 设置目标块
--> RelationPutHeapTuple(relation, buffer, heaptup,(options & HEAP_INSERT_SPECULATIVE) != 0); // 插入元组
--> MarkBufferDirty(buffer); // 标记buffer为脏页
// ... 插入WAL日志
如何计算一个页的空闲空间大小?
根据堆表的页面布局,可用的空闲空间大小等于pd_upper - pd_lower - sizeof(ItemIdData)。
Size PageGetFreeSpace(Page page)
{
int space;
// 计算空闲空间大小
space = (int) ((PageHeader) page)->pd_upper -
(int) ((PageHeader) page)->pd_lower;
// 如果空间空间大小小于ItemIdData的大小,则返回0
if (space < (int) sizeof(ItemIdData))
return 0;
space -= sizeof(ItemIdData); // 减去ItemIdData的大小,因为实际插入数据时还会额外占用ItemIdData空间
return (Size) space;
}
什么时候更新FSM呢?
我们知道在PostgreSQL中,可通过auto-vacuum或者手动执行vacuum来进行空间回收,vacuum会对表中的页进行清理,清理后重新计算表空间大小,更新FSM文件。
vacuum_rel
--> vacuum_open_relation // 执行vacuum时,需要对表加锁,vacuum full则需要加AccessExclusiveLock,否则加ShareUpdateExclusiveLock
--> table_relation_vacuum(rel, params, vac_strategy);
--> heap_vacuum_rel(rel, params, bstrategy)
--> lazy_scan_heap(vacrel, params, aggressive); // 执行lazy vacuum 区别于full vacuum
--> for (blkno = 0; blkno < nblokcs; blkno++)
{
// 获取块号对应的buffer
buf = ReadBufferExtended(vacrel->rel, MAIN_FORKNUM, blkno, RBM_NORMAL, vacrel->bstrategy);
page = BufferGetPage(buf);
// 裁剪页面
lazy_scan_prune(vacrel, buf, blkno, page, vistest, &prunestate);
--> heap_page_prune
// 获取页面空闲空间大小
Size freespace = PageGetHeapFreeSpace(page);
// 更新FSM
RecordPageWithFreeSpace(vacrel->rel, blkno, freespace);
--> fsm_set_and_search(rel, addr, slot, new_cat, 0);
--> fsm_readbuf(rel, addr, true);
--> fsm_set_avail(page, slot, newValue)
}