深入浅出PostgreSQL的Buffer Manager

前言

数据库设计中,Buffer Manager是最重要的话题之一,无论MySQL还是PostgreSQL,都有其良好的Buffer Manager设计。今天我们深入浅出PostgreSQL的Buffer Manager设计。

详细设计

Buffer Manager主要管理内存和存储之间的数据传输,直接影响了数据库的性能指标。下图展示了后台进程、Buffer Manager、存储之间的层次关系。
后台进程、buffer manager、存储之间的层次关系

Buffer Manager 结构

buffer manager包含三部分:buffer table,buffer descriptors,buffer pool。如下图
buffer manager三层结构

  • 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。
Buffer Table
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。
Buffer Manager初始状态
下图展示了第一个页面是如何载入的。

  • 从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分区锁,不用互相等待。
两个进程同时各自的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。具体例子为:

  1. pin住buffer descriptor
    1)获取buffer descriptor的spinklock
    2)增加refcount和usage_count
    3)释放spinlock
    LockBufHdr(bufferdesc);    /* Acquire a spinlock */
    bufferdesc->refcont++;
    bufferdesc->usage_count++;
    UnlockBufHdr(bufferdesc); /* Release the spinlock */
    
  2. 设置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中,下图展示了全过程

  1. 创建所需页面的buffer tag,计算hash值
  2. 获取对应shared BufMappingLock分区锁
  3. 从hash列表中找到buffer tag,获取buffer id
  4. 释放BufMappingLock分区锁
  5. 从buffer pool中获取buffer id对应的页面
    获取已经在buffer pool中的页面
    然后,在具体读取页面的行时,进程持有对应buffer descriptor的shared content_lock,多个进程可以并发读取。
    当修改该页面的行时,进程获取exclusive content_lock,dirty bit也要设为1。
    在获取页面后,对应的refcount要加1.

2. 从存储加载到空的槽位

当获取的页面不在buffer pool,需要进行以下步骤:

  1. 查找buffer table
    1)创建buffer tag,计算hash值
    2)获取shared BufMappingLock分区锁
    3)查找buffer table(没有找到)
    4)释放BufMappingLock
  2. 从freelist中获取第一个空的buffer descriptor,pin住
  3. 获取exclusive BufMappingLock分区锁
  4. 创建包含buffer tag和buffer id的hash entry,插入buffer table
  5. 从存储中加载指定页到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
  6. 释放BufMappingLock
  7. 从buffer pool获取页
    从存储加载到空的槽位

3. 从存储加载到buffer pool的牺牲槽位

当buffer pool数组被占满,但所需页面不在里面时,需要执行以下操作:

  1. 创建buffer tag,查找buffer table
  2. 使用clock-sweep算法选中牺牲的buffer pool槽位,在buffer table中获取包含牺牲槽位buffer id的旧表项,在buffer descriptor中pin住牺牲槽位
  3. 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
  4. 获取旧页表的exclusive BufMappingLock分区锁
  5. 获取新页表的exclusive BufMappingLock分区锁,添加到buffer table
    1)创建包含新buffer tag和buffer id的hash entry
    2)获取新的exclusive BufMappingLock分区锁
    3)插入到buffer table
  6. 删除buffer table中旧的hash entry,释放旧的BufMappingLock分区锁
  7. 从存储中加载到牺牲slot,更新buffer descriptor的flag:dirty位置0,初始化其他bit
  8. 释放新的BufMappingLock分区锁
  9. 从buffer pool中获取指定buffer id的页
    从存储加载到buffer pool的牺牲槽位

页面置换算法

当buffer pool已满时,读取新页必须将旧页置换。一般来说,这被称为页面置换算法,被置换的页称为牺牲页。
PostgreSQL采用NFU+clock sweep,而不用LRU。
buffer descriptor是循环链表,数组标识usage_count,nextVictimBuffer是32位usigned int,总是指向某个buffer descriptor并按顺时针顺序旋转。
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
  1. 获取nextVictimBuffer指向的候选buffer descriptor
  2. 如果候选buffer descriptor未pinned,进入3,否则进入4
  3. 如果候选buffer descriptor的usage_count为0,选择该buffer descriptor对应slot作为牺牲,进入5,否则usage_count-1,继续执行4
  4. 将nextVictimBuffer顺时针指向下一个buffer descriptor,并返回1,重复直至找出牺牲slot
  5. 返回牺牲slot的buffer id

Ring Buffer

当读写大表时,PostgreSQL使用Ring Buffer而不是buffer pool。Ring buffer是一个很小的临时缓冲区域。当满足下列任一条件时,PostgreSQL在共享内存中分配ring buffer。

  1. 批量读取。当扫描一个对象的大小超过buffer pool的1/4时,ring buffer为256KB
  2. 批量写入。当执行下列SQL时,ring buffer为16MB
    1)copy from …
    2)create table as …
    3)create materialized view 或 refresh materialized view
    4)alter table …
  3. 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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值