前言
数据库设计中,Buffer Manager是最重要的话题之一,无论MySQL还是PostgreSQL,都有其良好的Buffer Manager设计。今天我们深入浅出PostgreSQL的Buffer Manager设计。
详细设计
Buffer Manager主要管理内存和存储之间的数据传输,直接影响了数据库的性能指标。下图展示了后台进程、Buffer Manager、存储之间的层次关系。
Buffer Manager 结构
buffer manager包含三部分:buffer table,buffer descriptors,buffer pool。如下图
- buffer table是哈希表结构,存储buffer tag->buffer id,buffer tag是页唯一标识,buffer id是页的数组下标。
- buffer descriptors也是数组结构,和buffer pool是一对一关系,所以数组下标也是buffer id。buffer descriptors存储对应页的元数据。
- buffer pool是数组结构,数组下标是buffer id,数组内容是数据页。
Buffer Tag
buffer tag是数据页的唯一标识,由三部分组成:RelFileNode{table space、db、relation}、数据页所属进程的fork number、数据页所属数据文件的block number。
其中fork number是指:table(数据页)、freespace maps 和 visibility maps的fork number分别为0,1,2。
例如,buffer tag '{(16821, 16384, 37721), 0, 7}'
表示数据表文件(0代表table)的第7个block。(16821, 16384, 37721)
分别代表tablespace、database、relation的oid。
Buffer Table
buffer table包含三部分:hash函数、hash bucket、data entries。
hash函数通过buffer tag计算hash bucket,通过单链表解决同一个hash bucket上的hash冲突。
data entry包含两部分:buffer tag,buffer id。
Buffer Descriptor
buffer descriptor存储数据页元信息。
typedef struct BufferDesc
{
BufferTag tag; /* 即buffer tag */
int buf_id; /* 即buffer id */
/* pg10以后版本将refcount、usagecount、flags三个变量都由state表示,
refcount 页的引用计数,进程访问此页会+1,结束访问会-1
usagecount 页被加载到buffer pool的次数,时钟扫描会用到
flags 页的几种状态,dirty bit表示是否脏页,valid bit表示是否有效(页面置换时被置为无效),io_in_progress bit表示此页正在从存储中读/写
*/
pg_atomic_uint32 state;
int wait_backend_pid; /* backend PID of pin-count waiter */
int freeNext; /* 指向freelist中下一个descriptor,后面会详细说明 */
LWLock io_in_progress_lock; /* 等待IO完成的锁 */
LWLock content_lock; /* 访问bufer内容的锁 */
} BufferDesc;
为了简化后续章节的描述,这里定义三种描述符状态。
- Empty:当buffer pool的某个slot不存储页面时,即refcount与usage_count都是0,该描述符的状态为空。
- Pinned:当buffer pool的某个slot存储着页面,且有PostgreSQL进程正在访问的相应页面时,即refcount和usage_count都大于等于1,该缓冲区描述符的状态为钉住。
- Unpinned:当buffer pool的某个slot存储页面,但没有PostgreSQL进程正在访问相应页面时,即usage_count大于或等于1,但refcount为0。
当PostgreSQL进程启动时,所有buffer descriptor的状态都为空,构成了一个freelist链表。注:PostgreSQL的freelist和Oracle不一样,PostgreSQL里freelist只是空的buffer descriptor的链表,PostgreSQL中与Oracle freelist对应的是FSM。
下图展示了第一个页面是如何载入的。
- 从freelist头部获取一个空的buffer descriptor,pin住(refcount和usagecount加1)
- 在buffer table中插入新项,该项保存了buffer tag和buffer id。
- 将新页面从存储加载到对应的buffer pool slot。
- 将新页面的元信息保存到buffer descriptor。
从freelist中取出的buffer descriptor保存页面的元信息,只有下列任一情况出现,buffer descriptor的状态变为empty,可以重新回到freelist中。 - 相关表或索引已被删除
- 相关数据库已被删除
- 相关表或索引已经被vacuum full命令清理
Buffer Pool
buffer pool是存储数据文件的数组,例如表和索引。buffer pool的数组下标就是buffer id。buffer pool的每个槽位都是8KB,与页面大小相同。
进程是如何读取数据页的?
- 在进程读取数据页或索引页时,会先将带着buffer tag的请求发送到buffer manager;
- 假如buffer pool中已经存在此page,buffer manager直接返回buffer pool数组的下标buffer id。假如不存在,buffer manager从存储中读取数据页放在buffer pool中,并返回下标buffer id。
- 进程读取buffer id对应的页。
进程也可以修改buffer pool中的数据页,数据页在没有被flush到存储上时被认为是dirty page。
Buffer Manager 锁
Buffer Manager利用很多锁机制来保证同步,与SQL语句和SQL操作中的锁没有任何关系。
Buffer Table 锁
BufMappingLock保护整个buffer table,可以用作shared或者exclusive模式。当在buffer table中查找空项时,进程持有shared锁;当插入或删除项时,进程持有exclusive锁。
BufMappingLock使用分区锁来降低锁粒度,默认128个分区。每个分区锁保护对应的hash表槽位。下图展示了BufMappingLock分区锁的使用,两个进程可以同时获取不同的exclusive BufMappingLock分区锁,不用互相等待。
buffer table还有其他锁,包括spin lock来删除项,这里暂不讨论。
Buffer Descriptor 锁
每个Buffer Descriptor有两个轻量锁content_lock和io_in_progress_lock。
content_lock
content_lock能控制访问权限,具有shared和exclusive两种模式。当读取一页时,进程会获取页面的buffer descriptor的shared content_lock。当进行下列操作时会获取exclusive content_lock:
- 页面中插入行,或者更改页面中元组的t_xmin/t_xmax,也就是相关行更新或删除
- 物理上删除元组,或者compact已有的空间(vacuum和HOT)
- 冻结页面中的元组
io_in_process_lock
io_in_process_lock用于等待IO完成。当PostgreSQL进程读取、写入页面时,该进程在访问页面期间,持有对应buffer descriptor的exclusive io_in_process_lock。
spinlock
当检查或更改标记字段时,例如refcount和usage_count,会用到spinlock。具体例子为:
- pin住buffer descriptor
1)获取buffer descriptor的spinklock
2)增加refcount和usage_count
3)释放spinlockLockBufHdr(bufferdesc); /* Acquire a spinlock */ bufferdesc->refcont++; bufferdesc->usage_count++; UnlockBufHdr(bufferdesc); /* Release the spinlock */
- 设置dirty bit
1)获取buffer descriptor的spinklock
2)设置dirty bit
3) 释放spinlock#define BM_DIRTY (1 << 0) /* data needs writing */ #define BM_VALID (1 << 1) /* data is valid */ #define BM_TAG_VALID (1 << 2) /* tag is assigned */ #define BM_IO_IN_PROGRESS (1 << 3) /* read or write in progress */ #define BM_JUST_DIRTIED (1 << 5) /* dirtied since write started */ LockBufHdr(bufferdesc); bufferdesc->flags |= BM_DIRTY; UnlockBufHdr(bufferdesc);
Buffer Manager工作原理
当进程想要获取页面,会调用函数ReadBufferExtended。ReadBufferExtended的行为从逻辑上分为以下三种情况。
1. 获取已经在Buffer Pool中的页面
最简单的情况就是页面已存在于buffer pool中,下图展示了全过程
- 创建所需页面的buffer tag,计算hash值
- 获取对应shared BufMappingLock分区锁
- 从hash列表中找到buffer tag,获取buffer id
- 释放BufMappingLock分区锁
- 从buffer pool中获取buffer id对应的页面
然后,在具体读取页面的行时,进程持有对应buffer descriptor的shared content_lock,多个进程可以并发读取。
当修改该页面的行时,进程获取exclusive content_lock,dirty bit也要设为1。
在获取页面后,对应的refcount要加1.
2. 从存储加载到空的槽位
当获取的页面不在buffer pool,需要进行以下步骤:
- 查找buffer table
1)创建buffer tag,计算hash值
2)获取shared BufMappingLock分区锁
3)查找buffer table(没有找到)
4)释放BufMappingLock - 从freelist中获取第一个空的buffer descriptor,pin住
- 获取exclusive BufMappingLock分区锁
- 创建包含buffer tag和buffer id的hash entry,插入buffer table
- 从存储中加载指定页到buffer pool,使用buffer id为数组下标
1)获取exclusive io_in_progress_lock
2)io_in_progress bit置1,防止其他进程获取buffer descriptor
3)从存储中加载指定页到buffer pool
4)修改buffer descriptor的状态,io_in_progress bit置0,valid bit置1
5)释放io_in_progress_lock - 释放BufMappingLock
- 从buffer pool获取页
3. 从存储加载到buffer pool的牺牲槽位
当buffer pool数组被占满,但所需页面不在里面时,需要执行以下操作:
- 创建buffer tag,查找buffer table
- 使用clock-sweep算法选中牺牲的buffer pool槽位,在buffer table中获取包含牺牲槽位buffer id的旧表项,在buffer descriptor中pin住牺牲槽位
- flush(write和fsync)牺牲槽位的页表数据,否则直接到4
脏页必须先落盘,所占的slot才能被牺牲。刷脏包括以下步骤:
1)获取buffer descriptor的shared content_lock和exclusive io_in_progress_lock
2)修改buffer descriptor状态,io_in_progress bit置1,drity bit置0
3)根据具体情况,使用XLogFlush将WAL缓冲区的WAL写入WAL段文件
4)flush牺牲页
5)修改buffer descriptor状态,io_in_progress big置0,valid bit置1
6)释放io_in_progress_lock和content_lock - 获取旧页表的exclusive BufMappingLock分区锁
- 获取新页表的exclusive BufMappingLock分区锁,添加到buffer table
1)创建包含新buffer tag和buffer id的hash entry
2)获取新的exclusive BufMappingLock分区锁
3)插入到buffer table - 删除buffer table中旧的hash entry,释放旧的BufMappingLock分区锁
- 从存储中加载到牺牲slot,更新buffer descriptor的flag:dirty位置0,初始化其他bit
- 释放新的BufMappingLock分区锁
- 从buffer pool中获取指定buffer id的页
页面置换算法
当buffer pool已满时,读取新页必须将旧页置换。一般来说,这被称为页面置换算法,被置换的页称为牺牲页。
PostgreSQL采用NFU+clock sweep,而不用LRU。
buffer descriptor是循环链表,数组标识usage_count,nextVictimBuffer是32位usigned int,总是指向某个buffer descriptor并按顺时针顺序旋转。
伪代码:时钟扫描
WHILE true
(1) Obtain the candidate buffer descriptor pointed by the nextVictimBuffer
(2) IF the candidate descriptor is unpinned THEN
(3) IF the candidate descriptor's usage_count == 0 THEN
BREAK WHILE LOOP /* the corresponding slot of this descriptor is victim slot. */
ELSE
Decrease the candidate descriptpor's usage_count by 1
END IF
END IF
(4) Advance nextVictimBuffer to the next one
END WHILE
(5) RETURN buffer_id of the victim
- 获取nextVictimBuffer指向的候选buffer descriptor
- 如果候选buffer descriptor未pinned,进入3,否则进入4
- 如果候选buffer descriptor的usage_count为0,选择该buffer descriptor对应slot作为牺牲,进入5,否则usage_count-1,继续执行4
- 将nextVictimBuffer顺时针指向下一个buffer descriptor,并返回1,重复直至找出牺牲slot
- 返回牺牲slot的buffer id
Ring Buffer
当读写大表时,PostgreSQL使用Ring Buffer而不是buffer pool。Ring buffer是一个很小的临时缓冲区域。当满足下列任一条件时,PostgreSQL在共享内存中分配ring buffer。
- 批量读取。当扫描一个对象的大小超过buffer pool的1/4时,ring buffer为256KB
- 批量写入。当执行下列SQL时,ring buffer为16MB
1)copy from …
2)create table as …
3)create materialized view 或 refresh materialized view
4)alter table … - vacuum,当auto vacuum进程运行时,ring buffer为256KB。
ring buffer使用后立刻释放。使用ring buffer的好处显而易见,如果后端进程不适用ring buffer读写大表,buffer pool的命中率会很低,ring buffer可以避免此类问题。
批量读取和vacuum时的ring buffer大小是256KB,是因为其足够小可以放入L2缓冲区,在OS层面高效缓存。
刷脏页
除了置换牺牲页面,PostgreSQL中有两个进程会进行刷脏操作:checkpoint和background writer。
checkpoint进程将checkpoint记录写入WAL文件,并在checkpoint开始时开始刷脏。
background writer通过少量多次的刷脏,减少checkpoint带来的密集写入影响。background writer会一点点刷脏,尽可能减少对主进程的影响。默认情况,background writer每隔200ms被唤醒一次(bgwriter_delay控制),最多刷写100个页面(bgwriter_lru_maxpages控制)。
checkpoint进程和background writer进程之所分开,是因为二者不解耦会带来负面影响,我们没法在不停止后台写入的情况下执行checkpoint。