8.缓冲区管理器
冲区管理器管理着共享内存和持久存储之间的数据传输
8.1概览
PostgreSQL缓冲区管理器由缓冲表、缓冲区描述符和缓冲池组成。缓冲池层存储着数据文件页面,诸如表页与索引页,以及其相应的自由空间映射和可见性映射的页面。缓冲池是一个数组,数据的每个槽中存储数据文件的一页。缓冲池数组的序号索引称为buffer_id。
PostgreSQL中的每个数据文件页面都可以分配到唯一的标签,即缓冲区标签。当缓冲区管理器收到请求时,PostgreSQL会用到目标页面的缓冲区标签。
缓冲区标签由三个值组成,分别是关系文件节点、关系分支编号和页面块号。第一个值分别代表了表空间、数据库和表的oid;第二个值代表关系表的分支号;最后一个值代表页面号。
#/pgdata/pgsql13/include/server/storage/buf_internals.h
/*
* Buffer tag identifies which disk block the buffer contains.
*
* Note: the BufferTag data must be sufficient to determine where to write the
* block, without reference to pg_class or pg_tablespace entries. It's
* possible that the backend flushing the buffer doesn't even believe the
* relation is visible yet (its xact may have started before the xact that
* created the rel). The storage manager must be able to cope anyway.
*
* Note: if there's any pad bytes in the struct, INIT_BUFFERTAG will have
* to be fixed to zero them, since this struct is used as a hash key.
*/
typedef struct buftag
{
RelFileNode rnode; /* physical relation identifier */
ForkNumber forkNum;
BlockNumber blockNum; /* blknum relative to begin of reln */
} BufferTag;
#/pgdata/pgsql13/include/server/storage/relfilenode.h
typedef struct RelFileNode
{
Oid spcNode; /* tablespace */
Oid dbNode; /* database */
Oid relNode; /* relation */
} RelFileNode;
后端进程如何读取数据页
当后端进程修改缓冲池中的页面时(例如向页面插入元组),这种尚未刷新到持久存储,但已被修改的页面被称为脏页。

页面置换算法
当所有缓冲池槽位都被占用,且其中未包含所请求的页面时,缓冲区管理器必须在缓冲池中选择一个页面逐出,用于放置被请求的页面。
脏页刷盘
脏页最终应该被刷入存储,但缓冲区管理器执行这个任务需要额外帮助。在PostgreSQL中,检查点进程和后台写入器这两个后台进程负责此任务。
8.2缓冲区管理器的结构
- 缓冲表层是一个散列表,它存储着页面的buffer_tag 与描述符的buffer_id 之间的映射关系。
- 冲区描述符层是一个由缓冲区描述符组成的数组。每个描述符与缓冲池槽一一对应,并保存着相应槽的元数据。
- 缓冲池层是一个数组。每个槽都存储一个数据文件页,数组槽的索引称为buffer_id。
8.2.1缓冲表
缓冲表可以在逻辑上分为三个部分,分别是散列函数、散列桶槽及数据项。

8.2.1缓冲区描述符
缓冲区描述符保存着页面的元数据,这些与缓冲区描述符相对应的页面保存在缓冲池槽中。缓冲区描述符的结构由BufferDesc结构定义。
# src/include/storage/buf_internals.h.
/*
* Flags for buffer descriptors
*
* Note: BM_TAG_VALID essentially means that there is a buffer hashtable
* entry associated with the buffer's tag.
*/
#define BM_LOCKED (1U << 22) /* buffer header is locked */
#define BM_DIRTY (1U << 23) /* data needs writing */
#define BM_VALID (1U << 24) /* data is valid */
#define BM_TAG_VALID (1U << 25) /* tag is assigned */
#define BM_IO_IN_PROGRESS (1U << 26) /* read or write in progress */
#define BM_IO_ERROR (1U << 27) /* previous I/O failed */
#define BM_JUST_DIRTIED (1U << 28) /* dirtied since write started */
#define BM_PIN_COUNT_WAITER (1U << 29) /* have waiter for sole pin */
#define BM_CHECKPOINT_NEEDED (1U << 30) /* must write for checkpoint */
#define BM_PERMANENT (1U << 31) /* permanent buffer (not unlogged,
* or init fork) */
/*
* BufferDesc -- shared descriptor/state data for a single shared buffer.
*
* Note: Buffer header lock (BM_LOCKED flag) must be held to examine or change
* the tag, state or wait_backend_pid fields. In general, buffer header lock
* is a spinlock which is combined with flags, refcount and usagecount into
* single atomic variable. This layout allow us to do some operations in a
* single atomic operation, without actually acquiring and releasing spinlock;
* for instance, increase or decrease refcount. buf_id field never changes
* after initialization, so does not need locking. freeNext is protected by
* the buffer_strategy_lock not buffer header lock. The LWLock can take care
* of itself. The buffer header lock is *not* used to control access to the
* data in the buffer!
*
* It's assumed that nobody changes the state field while buffer header lock
* is held. Thus buffer header lock holder can do complex updates of the
* state variable in single write, simultaneously with lock release (cleaning
* BM_LOCKED flag). On the other hand, updating of state without holding
* buffer header lock is restricted to CAS, which insure that BM_LOCKED flag
* is not set. Atomic increment/decrement, OR/AND etc. are not allowed.
*
* An exception is that if we have the buffer pinned, its tag can't change
* underneath us, so we can examine the tag without locking the buffer header.
* Also, in places we do one-time reads of the flags without bothering to
* lock the buffer header; this is generally for situations where we don't
* expect the flag bit being tested to be changing.
*
* We can't physically remove items from a disk page if another backend has
* the buffer pinned. Hence, a backend may need to wait for all other pins
* to go away. This is signaled by storing its own PID into
* wait_backend_pid and setting flag bit BM_PIN_COUNT_WAITER. At present,
* there can be only one such waiter per buffer.
*
* We use this same struct for local buffer headers, but the locks are not
* used and not all of the flag bits are useful either. To avoid unnecessary
* overhead, manipulations of the state field should be done without actual
* atomic operations (i.e. only pg_atomic_read_u32() and
* pg_atomic_unlocked_write_u32()).
*
* Be careful to avoid increasing the size of the struct when adding or
* reordering members. Keeping it below 64 bytes (the most common CPU
* cache line size) is fairly important for performance.
*/
typedef struct BufferDesc
{
BufferTag tag; /* ID of page contained in buffer */
int buf_id; /* buffer's index number (from 0) */
/* state of the tag, containing flags, refcount and usagecount */
pg_atomic_uint32 state;
int wait_backend_pid; /* backend PID of pin-count waiter */
int freeNext; /* link in freelist chain */
LWLock content_lock; /* to lock access to buffer contents */
} BufferDesc;
8.2.3缓冲区描述符层
缓冲区描述符的集合构成了一个数组,本书称该数组为缓冲区描述符层。
PostgreSQL中的freelist完全不同于Oracle中freelist的概念。PostgreSQL的freelist只是空缓冲区描述符的链表。PostgreSQL中与Oracle中的freelist相对应的对象是空闲空间映射(FSM)
8.2.3缓冲池
缓冲池只是一个用于存储关系数据文件(例如表或索引)页面的简单数组。缓冲池数组的序号索引也就是buffer_id。
缓冲池槽的大小为8KB,等于页面大小,因而每个槽都能存储整个页面。
8.3缓冲区管理器锁
缓冲区管理器会出于不同的目的使用各式各样的锁,注意,本节描述的锁,指的是缓冲区管理器同步机制的一部分。它们与SQL语句和SQL操作中的锁没有任何关系。
8.3.1缓冲表锁
BufMappingLock保护整个缓冲表的数据完整性。它是一种轻量级的锁,有共享模式与独占模式。在缓冲表中查询条目时,后端进程会持有共享的BufMappingLock。插入或删除条目时,后端进程会持有独占的BufMappingLock。
BufMappingLock会被分为多个分区,以减少缓冲表中的争用(默认为128个分区)。每个BufMappingLock分区都保护着一部分相应的散列桶槽。
缓冲表也需要许多其他锁。例如,在缓冲表内部会使用自旋锁(spin lock)来删除数据项。

两个后端进程可以同时持有各自分区的BufMappingLock独占锁,以插入新的数据项。如果BufMappingLock是系统级的锁,那么其中一个进程就需要等待另一个进程完成处理。
8.3.2缓冲区描述符相关的锁
每 个 缓 冲 区 描 述 符 都 会 用 到 内 容 锁(content_lock)与 IO 进 行 锁(io_in_progress_lock)这两个轻量级锁,以控制对相应缓冲池槽页面的访问。当检查或更改描述符本身字段的值时,就会用到自旋锁。
内容锁 典型的强制限制访问的锁
当读取页面时,后端进程以共享模式获取页面相应缓冲区描述符中的content_lock.
执行以下操作之一时,则会获取独占模式的内容锁
- 将行(即元组)插入页面,或更改页面中元组的 t_xmin/t_xmax 字段时
- 物理移除元组,或压紧页面上的空闲空间。
- 冻结页面中的元组
IO进行锁
IO 进行锁(io_in_progress_lock)用于等待缓冲区上的I/O完成。当PostgreSQL进程加载/写入页面数据时,该进程在访问页面期间,持有对应描述符上独占的io_in_progres_lock。
自旋锁
当检查或更改标记字段与其他字段时,例如refcount和usage_count,会用到自旋锁。
例子1:
钉住缓冲区描述符:
- 获取缓冲区描述符上的自旋锁
- 将其refcount和usage_count的值增加1
- 释放自旋锁
LockBufHdr(bufferdesc); /* Acquire a spinlock */
bufferdesc->refcont++;
bufferdesc->usage_count++;
UnlockBufHdr(bufferdesc); /* Release the spinlock */
例子2:
将脏位设置为”1”:
- 获取缓冲区描述符上的自旋锁
- 使用位操作将脏位置位为”1”。
- 释放自旋锁
#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);
缓冲区描述符的数据结构定义位置src/include/storage/buf_internals.h:BM_LOCKED
参考
8.4缓冲区管理器的工作原理
本节介绍缓冲区管理器的工作原理。当后端进程想要访问所需页面时,它会调用ReadBufferExtended函数。
函数 ReadBufferExtended的行为因场景而异,在逻辑上具体可以分为三种情况。
8.4.1访问存储在缓冲池中的页面
当从缓冲池槽中的页面里读取行时,PostgreSQL 进程获取相应缓冲区描述符的共享content_lock,因而缓冲池槽可以同时被多个进程读取。
当向页面插入(及更新、删除)行时,该 postgres后端进程获取相应缓冲区描述符的独占content_lock(注意,这里必须将相应页面的脏位置设为”1”)。访问完页面后,相应缓冲区描述符的引用计数值减1。
图8.8是访问存储在缓冲池中的页面示意图。

(1)创建所需页面的 buffer_tag(在本例中 buffer_tag 是’Tag_C’),并使用散列函数计算与描述符相对应的散列桶槽。
(2)获取相应散列桶槽分区上的BufMappingLock共享锁。
(3)查找标签为’Tag_C’的条目,并从条目中获取buffer_id。本例中buffer_id为2。
(4)将 buffer_id=2 的缓冲区描述符钉住,即将描述符的 refcount 和usage_count增加1。
(5)释放BufMappingLock。
(6)访问buffer_id=2的缓冲池槽。
8.4.2将页面从存储加载到空槽

在第二种情况下,假设所需页面不在缓冲池中,且freelist中有空闲元素(空描述符)。这时,缓冲区管理器将执行以下步骤:
(1)查找缓冲区表(本节假设页面不存在,找不到对应页面)。
- 1.创建所需页面的buffer_tag(本例中buffer_tag为’Tag_E’)并计算其散列桶槽。
- 2.以共享模式获取相应分区上的BufMappingLock。
- 3.查找缓冲区表(根据假设,这里没找到)。
- 4.释放BufMappingLock。
(2)从 freelist 中获取空缓冲区描述符,并将其钉住。在本例中所获的描述符:buffer_id=4。
(3)以独占模式获取相应分区的BufMappingLock(此锁将在步骤(6)中被释放)。
(4)创建一条新的缓冲表数据项:buffer_tag=’Tag_E’, buffer_id=4,并将其插入缓冲区表中。
(5)将页面数据从存储加载至buffer_id=4的缓冲池槽中,如下所示:
- 1.以排他模式获取相应描述符的io_in_progress_lock。
- 2.将相应描述符的IO_IN_PROGRESS标记位设置为1,以防其他进程访问。
- 3.将所需的页面数据从存储加载到缓冲池插槽中。
- 4.更改相应描述符的状态,将IO_IN_PROGRESS标记位设置为”0”,且VALID标记位设置为”1”。
- 5.释放io_in_progress_lock。
(6)释放相应分区的BufMappingLock。
(7)访问buffer_id=4的缓冲池槽。
8.4.3将页面从存储加载到受害者缓冲池槽
在这种情况下,假设所有缓冲池槽位都被页面占用,且未存储所需的页面。图8.10、图8.11是将页面从存储加载到受害者缓冲池槽的示意图。


缓冲区管理器将执行以下步骤:
(1)创建所需页面的buffer_tag并查找缓冲表。在本例中假设buffer_tag是’Tag_M’ (且相应的页面在缓冲区中找不到)。
(2)使用时钟扫描算法选择一个受害者缓冲池槽位,从缓冲表中获取包含着受害者槽位buffer_id的旧表项,并在缓冲区描述符层将受害者槽位的缓冲区描述符钉住。本例中受害者槽的buffer_id=5,旧表项为Tag_F, id = 5。时钟扫描将在下一节介绍。
(3)如果受害者页面是脏页,则将其刷盘(write & fsync),否则进入步骤(4)。在使用新数据覆盖脏页之前,必须将脏页写入存储中。脏页的刷盘步骤如下:
在使用新数据覆盖脏页之前,必须将脏页写入存储中。脏页的刷盘步骤如下:
- 1.获取 buffer_id=5 描述符上的共享 content_lock 和独占io_in_progress_lock。
- 2.更改相应描述符的状态:相应IO_IN_PROCESS 位设置为”1”,JUST_DIRTIED 位设置为”0”。
- 3.根据具体情况,调用XLogFlush()函数将WAL缓冲区上的WAL数据写入当前WAL段文件
- 4.将受害者页面的数据刷盘至存储中。
- 5.更改相应描述符的状态;将IO_IN_PROCESS位设置为”0”,将VALID位设置为”1”。
- 6.释放io_in_progress_lock和content_lock。
(4)以排他模式获取缓冲区表中旧表项所在分区上的BufMappingLock。
(5)获取新表项所在分区上的BufMappingLock,并将新表项插入缓冲表:
- 1.创建新表项:由buffer_tag=’Tag_M’与受害者的buffer_id组成的新表项。
- 2.以独占模式获取新表项所在分区上的BufMappingLock。
- 3.将新表项插入缓冲区表中。
(6)从缓冲表中删除旧表项,并释放旧表项所在分区的BufMappingLock。
(7)将目标页面数据从存储加载至受害者槽位,然后用 buffer_id=5 更新描述符的标识字段,将脏位设置为0,并按流程初始化其他标记位。
(8)释放新表项所在分区上的BufMappingLock。
(9)访问buffer_id=5对应的缓冲区槽位。
8.4.4 页面替换算法:时钟扫描
NFU(Not Frequently Used)算法的变体,开销较少,能高效地选出较少使用的页面。
缓冲区描述符为黑色或灰色的方框,框中的数字显示每个描述符的 usage_count。而 nextVictimBuffer是一个 32位的无符号整型变量,它总是指向某个缓冲区描述符并按顺时针顺序旋转。
--为代码,时钟扫描
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指向的候选缓冲区描述符。
#(2)如果候选描述符未被钉住,则进入步骤(3),否则进入步骤(4)。
#(3)如果候选描述符的usage_count为0,则选择该描述符对应的槽作为受害者,并进入步骤(5),否则将此描述符的usage_count减1,并继续执行步骤(4)。
#(4)将nextVictimBuffer迭代至下一个描述符(如果到末尾则回绕至头部)并返回步骤(1),重复直到找到受害者。
#(5)返回受害者的buffer_id。

如上图,该算法的伪代码与算法描述如下:
- 1.nextVictimBuffer 指向第一个描述符(buffer_id = 1),但因为该描述符被钉住了,所以跳过。
- 2.extVictimBuffer指向第二个描述符(buffer_id = 2),该描述符未被钉住,但其usage_count为2,因此该描述符的usage_count将减1,而nextVictimBuffer迭代至第三个候选描述符。
- 3.nextVictimBuffer 指向第三个描述符(buffer_id = 3),该描述符未被钉住,但其usage_count = 0,因而成为本轮的受害者。
当nextVictimBuffer扫过未钉住的描述符时,其usage_count会减1。因此只要缓冲池中存在未钉住的描述符,该算法总能在旋转若干次 nextVictimBuffer 后,找到一个usage_count为0的受害者。
8.5环形缓冲区
在读大表是,pg使用环形缓冲区,而非缓冲池,环形缓冲区是一个小的临时区域,当满足下面任意一个条件时,pg将在共享内存中分配一个环形缓冲区。
- 1.批量读取:当扫描关系读取数据的大小超过缓冲池的1/4时,环形缓冲区的大小为256KB
- 2.批量写入,当执行以下sql命令时,该缓冲区大小为16MB
. copy from
. create tables
. create meterialized view / refresh materialized view
. alter table - 3.清理过程,当字段清理守护进程执行清理过程时,该区大小256KB
分配的环形缓冲区将在使用后被立即释放。
环形缓冲区的好处显而易见,如果后端进程在不使用环形缓冲区的情况下读取大表,则所有存储在缓冲池中的页面都会被移除,这会导致缓存命中率降低。环形缓冲区可以避免此问题。
为什么批量读取和清理过程的默认环形缓冲区大小为256 KB
源代码中缓冲区管理器目录下的README中解释了这个问题
顺序扫描使用256KB的环形缓冲区,它足够小,因而能放入L2缓存中,从而使得操作系统缓存到共享缓冲区的页面传输变得高效。通常更小一点也可以,但环形缓冲区需要足够大到能同时容纳扫描中被钉住的所有页面。
8.6脏页刷盘
除了置换受害者页面之外,检查点进程和后台写入器进程也会将脏页刷盘至存储中。尽管两个进程都具有相同的功能(脏页刷盘),但是它们有着不同的角色和行为。
检查点进程将检查点记录写入WAL段文件,并在检查点开始时进行脏页刷盘
后台写入器的目的是通过少量多次的脏页刷盘,减少检查点带来的密集写入的影响。后台写入器会一点点地将脏页落盘,尽可能减少对数据库活动造成的影响。在默认情况下,后台写入器每 200ms 被唤醒一次(由参数 bgwriter_delay 定义),且最多刷写 bgwriter_lru_maxpages个页面(默认为100个页面)。
本节英文版