PostgreSQL数据库缓冲区管理模块
1. 结构
图1. 缓冲区管理器的三层结构
表1. 缓冲区管理器的结构及其功能
结构 | 功能 |
缓冲表 (buffer table) | 是一个哈希表,存储着页面的buffer_tag与描述符的buffer_id之间的映射关系。 |
缓冲区描述符 (buffer descriptors) | 每个描述符与缓冲池槽一一对应,并保存着相应槽的元数据。 |
缓冲池 (buffer pool) | 存储着数据文件页面,及其相应的自由空间映射和可见性映射的页面。缓冲池是一个数组,数据的每个槽中存储数据文件的一页。缓冲池数组的序号索引称为buffer_id。 |
图2. 缓冲区管理器,存储和后端进程之间的关系
图3. 后端进程读取数据页
后端进程读取数据页的整体流程:
- 当读取表或索引页时,后端进程向缓冲区管理器发送请求,请求中带有目标页面的buffer_tag。
- 缓冲区管理器会根据buffer_tag返回一个buffer_id,即目标页面存储在数组中的槽位的序号。如果请求的页面没有存储在缓冲池中,那么缓冲区管理器会将页面从持久存储中加载到其中一个缓冲池槽位中,然后再返回该槽位的buffer_id。
- 后端进程访问buffer_id对应的槽位(以读取所需的页面)。
1.1 缓冲表
图4.缓冲表
表2. 表的逻辑组成及功能
组成 | 功能 |
散列函数 | 散列函数将buffer_tag映射到哈希桶槽 |
散列桶槽 | 缓冲表采用了使用链表的分离链接方法(separate chaining with linked lists)来解决冲突。当数据项被映射到至同一个桶槽时,该方法会将这些数据项保存在一个链表中。 |
数据项 | 包括页面的buffer_tag,以及包含页面元数据的描述符的buffer_id。 |
- buffer_tag
每个数据文件页面都可以分配到唯一的标签,即缓冲区标签(buffer tag)。
组成:关系文件节点(relfilenode),关系分支编号(fork number),页面块号(block number)。例如,缓冲区标签{(16821, 16384, 37721), 0, 7}表示,在oid=16821的表空间中的oid=16384的数据库中的oid=37721的表的0号分支(关系本体)的第七号页面。(关系本体main分支编号为0,空闲空间映射fsm分支编号为1,可见性映射vm分支编号为2)。
1.2 缓冲区描述符
保存着页面的元数据,这些与缓冲区描述符相对应的页面保存在缓冲池槽中。缓冲区描述符的结构由BufferDesc结构定义。
表3. BufferDesc结构定义
结构 | 定义 |
tag | 保存着目标页面的buffer_tag,该页面存储在相应的缓冲池槽中。 |
buffer_id | 标识了缓冲区描述符(亦相当于对应缓冲池槽的buffer_id)。 |
refcount | 保存当前访问相应页面的PostgreSQL进程数,也被称为钉数(pin count)。当PostgreSQL进程访问相应页面时,其引用计数必须自增1(refcount ++)。访问结束后其引用计数必须减1(refcount--)。当refcount为零,即页面当前并未被访问时,页面将取钉(unpinned),否则它会被钉住(pinned)。 |
usage_count | 保存着相应页面加载至相应缓冲池槽后的访问次数。 |
context_lock io_in_progress_lock | 轻量级锁,用于控制对相关页面的访问。 |
flags | 用于保存相应页面的状态:
|
freeNext | 一个指针,指向下一个描述符,并以此构成一个空闲列表(freelist) |
当PostgreSQL服务器启动时,所有缓冲区描述符的状态都为空。在PostgreSQL中,这些描述符构成了一个名为freelist的链表,如图5所示。
图5.缓冲区管理器初始状态
图6. 加载第一页
加载第一页的具体流程:
- 从freelist的头部取一个空描述符,并将其钉住(即,将其refcount和usage_count增加1)。
- 在缓冲表中插入新项,该缓冲表项保存了页面buffer_tag与所获描述符buffer_id之间的关系。
- 将新页面从存储器加载至相应的缓冲池槽中。
- 将新页面的元数据保存至所获取的描述符中。
1.3 缓冲池
缓冲池只是一个用于存储关系数据文件(例如表或索引)页面的简单数组。缓冲池数组的序号索引也就是buffer_id。缓冲池槽的大小为8KB,等于页面大小,因而每个槽都能存储整个页面。
2 缓冲区管理锁
2.1 缓冲表锁
BufMappingLock:保护整个缓冲表的数据完整性。它是一种轻量级的锁,有共享模式与独占模式。在缓冲表中查询条目时,后端进程会持有共享的BufMappingLock。插入或删除条目时,后端进程会持有独占的BufMappingLock。
BufMappingLock会被分为多个分区,以减少缓冲表中的争用(默认为128个分区)。每个BufMappingLock分区都保护着一部分相应的散列桶槽。
缓冲表也需要许多其他锁。例如,在缓冲表内部会使用自旋锁(spin lock)来删除数据项。
2.2 缓冲区描述符相关的锁
每个缓冲区描述符都会用到两个轻量级锁——content_lock与io_in_progress_lock,来控制对相应缓冲池槽页面的访问。当检查或更改描述符本身字段的值时,则会用到自旋锁。
表4.缓冲区描述符相关的锁及功能
锁 | 功能 |
内容锁 (content_lock) | 共享(shared)
独占(exclusive)
|
io_in_progress_lock | 用于等待缓冲区上的I/O完成。当PostgreSQL进程加载/写入页面数据时,该进程在访问页面期间,持有对应描述符上独占的io_in_progres_lock。 |
自旋锁 (spinlock) | 当检查或更改标记字段与其他字段时(例如refcount和usage_count),会用到自旋锁。 |
3. 缓冲区管理器的工作原理
当后端进程想要访问所需页面时,它会调用ReadBufferExtended函数。
图7. 将页面从存储加载至受害者缓冲池槽中
假设所有缓冲池槽位都被页面占用,且未存储所需的页面。缓冲区管理器将执行以下步骤:
(1)创建所需页面的buffer_tag并查找缓冲表。在本例中假设buffer_tag ’Tag_M’(且相应的页面在缓冲区中找不到)。
(2)使用时钟扫描算法选择一个受害者缓冲池槽位,从缓冲表中获取包含着受害者槽位buffer_id的旧表项,并在缓冲区描述符层将受害者槽位的缓冲区描述符钉住。本例中受害者槽的buffer_id=5,旧表项为Tag_F,id = 5。
(3)如果受害者页面是脏页,将其刷盘(write & fsync),否则进入步骤(4)。在使用新数据覆盖脏页之前,必须将脏页写入存储中。脏页的刷盘步骤如下:
- 获取buffer_id=5描述符上的共享content_lock和独占io_in_progress_lock(在步骤6中释放)。
- 更改相应描述符的状态:相应IO_IN_PROCESS位被设置为"1",JUST_DIRTIED位设置为"0"。
- 根据具体情况,调用XLogFlush()函数将WAL缓冲区上的WAL数据写入当前WAL段文件。
- 将受害者页面的数据刷盘至存储中。
- 更改相应描述符的状态;将IO_IN_PROCESS位设置为"0",将VALID位设置为"1"。
- 释放io_in_progress_lock和content_lock。
(4)以排他模式获取缓冲区表中旧表项所在分区上的BufMappingLock。
(5)获取新表项所在分区上的BufMappingLock,并将新表项插入缓冲表:
- 创建由新表项:由buffer_tag='Tag_M'与受害者的buffer_id组成的新表项。
- 以独占模式获取新表项所在分区上的BufMappingLock。
- 将新表项插入缓冲区表中。
(6)从缓冲表中删除旧表项,并释放旧表项所在分区的BufMappingLock。
将目标页面数据从存储加载至受害者槽位。然后用buffer_id=5更新描述符的标识字段;将脏位设置为0,并按流程初始化其他标记位。
(8)释放新表项所在分区上的BufMappingLock。
(9)访问buffer_id=5对应的缓冲区槽位。
4. 页面替换算法:时钟扫描
将缓冲区描述符想象为一个循环列表。而nextVictimBuffer是一个32位的无符号整型变量,它总是指向某个缓冲区描述符并按顺时针顺序旋转。
具体原理:
(1)获取nextVictimBuffer指向的候选缓冲区描述符(candidate buffer descriptor)。
(2)如果候选描述符未被钉住(unpinned),则进入步骤(3),否则进入步骤(4)。
(3)如果候选描述符的usage_count为0,则选择该描述符对应的槽作为受害者,并进入步骤(5);否则将此描述符的usage_count减1,并继续执行步骤(4)。
(4) 将nextVictimBuffer迭代至下一个描述符(如果到末尾则回绕至头部)并返回步骤(1)。重复至找到受害者。
(5)返回受害者的buffer_id。
当nextVictimBuffer扫过未固定的描述符时,其usage_count会减1。因此只要缓冲池中存在未固定的描述符,该算法总能在旋转若干次nextVictimBuffer后,找到一个usage_count为0的受害者。
- 环形缓冲区(ring buffer)
在读写大表时,PostgreSQL会使用环形缓冲区(ring buffer)而不是缓冲池。环形缓冲器是一个很小的临时缓冲区域。分配的环形缓冲区将在使用后被立即释放。
环形缓冲区的好处显而易见,如果后端进程在不使用环形缓冲区的情况下读取大表,则所有存储在缓冲池中的页面都会被移除(踢出),因而会导致缓存命中率降低。环形缓冲区可以避免此问题。
- 脏页刷盘
除了置换受害者页面之外,检查点进程(Checkpointer)进程和后台写入器进程也会将脏页刷写至存储中。尽管两个进程都具有相同的功能(刷写脏页),但它们有着不同的角色和行为。
检查点进程将检查点记录(checkpoint record)写入WAL段文件,并在检查点开始时进行脏页刷写。
后台写入器的目的是通过少量多次的脏页刷盘,减少检查点带来的密集写入的影响。后台写入器会一点点地将脏页落盘,尽可能减小对数据库活动造成的影响。默认情况下,后台写入器每200毫秒被唤醒一次(由参数bgwriter_delay定义),且最多刷写bgwriter_lru_maxpages个页面(默认为100个页面)。