------------Author by xiangxiao at 20250402
1BufferManager前置准备
1.1postgres文件系统结构
这里首先由postgres数据库的文件结构入手,了解前置知识有助于我们更清楚的了解pg的缓冲区管理器。

重点在DATA目录,DATA目录下主要看base目录,base目录下几个文件夹,就是分别代表着pg数据库的几个数据库实例。在这里需要注意的是:
- base/ 目录下包含以数据库 OID 命名的子目录

-
上述的 OID = 1,4 是系统的数据库模板。当我们调用 CREATE DATABASE 语句时,系统会用它们当模板,创建新的数据库。
-
OID = 5 (postgres) 是系统在 initdb 的时候通过模板创建出来的默认数据库。
-
每个子目录下包含了数据库所有的系统表 (catalog table) ,用户表和索引 (也是以 OID 为命名)(在代码里,每个文件也被叫做 relfilenode)。
了解到文件系统的结构之后,我们再来看具体的一个文件的结构情况。
进入到数据库的目录下之后,ls命令可以看到里面有很多文件:

所有数据库,系统表,用户表,索引都是以 OID 为文件名的形式储存,然后你又会发现这些文件有些OID相同,但是具有不同的文件后缀,这些分别代表不同文件分支类型,这些以_vm
和_fsm
结尾文件又分别叫做可见性映射文件和空间映射文件,目的是为了减少查询时需要扫描的数据库的数量,从而提高查询性能。用于优化长期存储和查询,如果表的数据不是很大的情况下,可能没有这两个文件。除此之外,一个文件的大小是有限制的,当文件的大小超过1GB之后,postgres会创建一个relfilenode.1
的文件接着存储,但是关于这两个文件这里不是本文重点,姑且放在别的文章进行讨论。
需要注意的是当我们需要将一个表的文件加载到操作系统的内存的时候,就需要选择这个文件的分支类型,然后如果一个数据文件太大了我们也不可能将其完全放入内存中去。所以就引入到我们这样一个文件是这么存储数据的,了解一个文件内部的结构。

对于本章内容来说,我们需要了解点就是我们如何定位一个page页:
文件的pg_relation_filepath +文件的分支类型 +block编号。
1.2为什么要有BufferManager?
如果没有缓冲区,数据库获取信息将直接从磁盘中读取,但是这些需求基本上都是一些随机IO,这会占用很大的IO资源,影响性能。所以设计一个缓冲区,实现将读取的页缓存到缓冲区中,下一次再读取的时候优先从缓冲区中进行读取,避免直接从磁盘中读取。
1.3查看PG的shared_buffer使用情况
sql
SELECT
c.relname,
pg_size_pretty(count(*) * 8192) as buffered,
round(100.0 * count(*) / (SELECT setting
FROM pg_settings
WHERE name='shared_buffers')::integer, 1) AS buffer_percent,
round(100.0 * count(*) * 8192 / pg_table_size(c.oid), 1) AS percent_of_relation
FROM pg_class c
INNER JOIN pg_buffercache b
ON b.relfilenode = c.relfilenode
INNER JOIN pg_database d
ON (b.reldatabase = d.oid AND d.datname = current_database())
GROUP BY c.oid, c.relname
ORDER BY 3 DESC
LIMIT 10;
sql
relname | buffered | buffer_percent | percent_of_relation
---------------+------------+----------------+---------------------
pg_operator | 88 kB | 0.1 | 61.1
pg_amop | 48 kB | 0.0 | 54.5
pg_namespace | 8192 bytes | 0.0 | 16.7
pg_cast | 8192 bytes | 0.0 | 16.7
pg_constraint | 8192 bytes | 0.0 | 12.5
pg_index | 32 kB | 0.0 | 50.0
pg_amproc | 40 kB | 0.0 | 55.6
pg_opclass | 16 kB | 0.0 | 28.6
pg_am | 8192 bytes | 0.0 | 20.0
pg_aggregate | 8192 bytes | 0.0 | 14.3
(10 rows)
1.4清除缓存
bash
SELECT pg_stat_reset();
关库pg_ctl stop -D xxx
echo 3>/proc/sys/vm/drop_cache (centos7)
echo 3 > /proc/sys/vm/drop_caches (fedora41)
起库pg_ctl start -D xxx
1.5shared_buffers参数到底有什么作用?
shared_buffers是一个GUC参数,他影响的是NBuffers全局变量
shared_buffers = 128MB
NBuffers = (128 * 1024* * * 1024) / 8192
1.6查看filenode和filepath
sql
select pg_relation_filepath('t1');
select pg_relation_filenode('t1');
truncate会改变filenode但是不会改变oid
1.7演示缓存命中率
sql
select queryid,query,shared_blks_hit,shared_blks_read,shared_blks_dirtied,shared_blks_written
from pg_stat_statements;
queryid | 7682567160055938297
query | select * from t2 where id < $1 limit $2
shared_blks_hit | 1
shared_blks_read | 1
shared_blks_dirtied | 0
shared_blks_written | 0
SELECT
sum(heap_blks_read) AS heap_read,
sum(heap_blks_hit) AS heap_hit,
100 * sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS cache_hit_rate
FROM pg_statio_user_tables;
heap_read | heap_hit | cache_hit_rate
-----------+----------+------------------------
88387 | 105 | 0.11865479365366360801
2Buffer Manager理论
2.1内存数据块访问标签:Buffer Tag
前面介绍了如何唯一标识一个page页,这里给出在Buffer Manager里面是如何标识一个page页。
结构体的代码定义:
cpp
typedef struct buftag
{
RelFileNode rnode; //表的物理路径标识
ForkNumber forkNum;
BlockNumber blockNum; /* blknum relative to begin of reln */
} BufferTag;
typedef struct RelFileNode
{
Oid spcNode; /* tablespace */
Oid dbNode; /* database */
Oid relNode; /* relation */
} RelFileNode;
typedef enum ForkNumber
{
InvalidForkNumber = -1,
MAIN_FORKNUM = 0,
FSM_FORKNUM,
VISIBILITYMAP_FORKNUM,
INIT_FORKNUM
} ForkNumber;
typedef uint32 BlockNumber;
举例:Buffer Tag { (1663, 16384, 16390), 0, 1 } 表示要访问表空间(1663)中的数据库(16384)存储的表(16390)中的用户数据(0)的第二个数据块(1)。
BufferManager缓冲区管理器由三层组成,缓冲表层、缓冲区描述符层和缓冲池层。
-
缓冲表层是一个共享哈希表,他存储着页面的BufferTag和缓冲区描述符层的Buffer_id的一一对应关系。
-
缓冲区描述符层是一个由缓冲区描述符组成的数组,每个描述符与缓冲池槽相对应,并保留着相应槽的元数据。
-
缓冲池层也是一个数组,他的每一个槽都存储着一个数据文件页,数组槽的索引ID为buffer_id。

2.2三层结构之第一层:Buffer Table 哈希表层
将前面所说的Buffer Tag作为哈希表的key,转化为哈希bucket,用于查找buf_id,也就是哈希表的value。而这里的buf_id作为数组下标后面访问其他两层基于数组的数据结构。

cpp
typedef struct
{
BufferTag key; /* Tag of a disk page */
int id; /* Associated buffer ID */
} BufferLookupEnt;
static HTAB *SharedBufHash;
void
InitBufTable(int size)
{
HASHCTL info;
info.keysize = sizeof(BufferTag);
info.entrysize = sizeof(BufferLookupEnt);
info.num_partitions = NUM_BUFFER_PARTITIONS;
SharedBufHash = ShmemInitHash("Shared Buffer Lookup Table",
size, size,
&info,
HASH_ELEM | HASH_BLOBS | HASH_PARTITION);
}
在src/backend/storage/buffer/buf_table.c中还有一些关于哈希表层的一些接口函数的实现。
cpp
//接受BufferTag作为参数,将其计算转换成哈希值,用作哈希表的查找。
uint32 BufTableHashCode(BufferTag *tagPtr);
//根据BufferTag查找哈希表,并返回对应的buffer_id
int BufTableLookup(BufferTag *tagPtr, uint32 hashcode);
//往哈希表中插入一条记录
int BufTableInsert(BufferTag *tagPtr, uint32 hashcode, int buf_id);
//将哈希表中的记录删除
void BufTableDelete(BufferTag *tagPtr, uint32 hashcode);
2.3三层结构之第二层:BufferDescriptor层
缓冲区描述符层,也是存储在共享内存中,他的结构定义是:
cpp
//共享内存定义
BufferDescriptors = (BufferDescPadded *)
ShmemInitStruct("Buffer Descriptors",
NBuffers * sizeof(BufferDescPadded),
&foundDescs);
//实体结构定义
typedef union BufferDescPadded
{
BufferDesc bufferdesc;
char pad[BUFFERDESC_PAD_TO_SIZE];
} BufferDescPadded;
typedef struct BufferDesc
{
BufferTag tag; /* BufferTag */
int buf_id; /* 标识了缓冲区描述符,同时也是访问下一层的数组下标 */
pg_atomic_uint32 state; /* 这是一个原子变量,他存储着缓冲池层对应page页的状态信息 */
int wait_backend_pid; /* 等待pin本缓冲区的后端检查PID */
int freeNext; /* 指向下一个描述符,构成空闲链表 */
LWLock content_lock; /* 用于控制相关页面的访问 */
} BufferDesc;
这里值得一说的是这个原子变量,在早期的版本中,pg对相关页面的状态信息存储的结构是使用多个变量去存储,但是在这里他是用一个原子变量去存储的,这意味着现在对这些状态值的访问不需要加锁,直接使用原子操作就可以达到相同的目的,减少了锁争用情况,增加了并发效率。
这里的state包括如下的状态信息:
-
refcount:保存了当前访问页面的pg进程数量,也称为钉数(pin count),当pg进程需要访问相应页面时,必须增加其引用计数,访问结束之后必须使其引用计数减一,当前页面的refcount为0时,表示当前页面未被访问,页面将取钉(unpinned),否则他将被钉住(pinned)
-
usageCount:保存着当前页面加载到缓冲池中之后被访问的次数,用于计算是否需要将页面淘汰出缓冲池。
-
flags:用于保存当前页面的状态信息,包括脏位(dirty bit)、有效位(valid bit)、IO进行位(io_in_progress)
cpp
#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) */
这里定义三种描述符状态:
空(
Empty
) :当相应的缓冲池槽不存储页面(即refcount
与usage_count
都是0),该描述符的状态为空。钉住(
Pinned
) :当相应缓冲池槽中存储着页面,且有PostgreSQL进程正在访问的相应页面(即refcount
和usage_count
都大于等于1),该缓冲区描述符的状态为钉住。未钉住(
Unpinned
) :当相应的缓冲池槽存储页面,但没有PostgreSQL进程正在访问相应页面时(即usage_count
大于或等于1,但refcount
为0),则此缓冲区描述符的状态为未钉住。
当然在这里也提供了一些用于操作BufferDescriptor缓冲区描述层的API,下面给出一些例子:
cpp
//根据所给的buffer_id获取对应的缓冲区描述符
#define GetBufferDescriptor(id) (&BufferDescriptors[(id)].bufferdesc)
//加锁或者释放锁 缓冲区描述符
uint32 LockBufHdr(BufferDesc *desc);
UnlockBufHdr(desc, s)
//etc
2.4三层结构之第三层:Buffer pool层
这一层也成为缓冲池层,他存储的是实际的数据块,他也是一个数组,以8k为单位(page页大小在编译时候可以配置),数组的大小和上一层Buffer Descriptor层一样,同样使用buf_id作为数组下标访问缓冲的页面。读写访问通过对应的BufferDescriptor来控制,如果数据块在内存中被改动,则需要落盘替换对应文件上的数据块。
代码结构为:
cpp
//缓冲池层
BufferBlocks = (char *)
ShmemInitStruct("Buffer Blocks",
NBuffers * (Size) BLCKSZ, &foundBufs);
3缓冲区管理器的锁
3.1缓冲表锁
缓冲表层是一个共享哈希表,在对这个共享内存访问的时候需要加锁,而哈希表所持有的是一个BufMappingPartitionLock,这是一个轻量锁,同时也是一个分区锁,这样做是为了减少缓冲表中的争用,因为在后台进程对缓冲区进行插入和删除的时候会获取独占锁。

如图,两个进程分别对不同分区加锁。
3.2缓冲区描述符相关的锁
再注意缓冲区描述符的结构,里面有提到一个content_lock变量,这也是一个轻量锁,他是用于保护数据块内容的锁,如果当前数据块需要落盘,后台进程就可以获取一个共享锁,这样既保证数据不可改变(与写锁互斥),又不影响其他后台进程对数据块的访问,再安全的将数据更新到磁盘上。
前面提到有一个LockBufHdr宏函数,他加锁是通过更改state变量中的某个标志位BM_LOCKED,保证当前只有一个进程在操作。
PinBuffer_Locked这个函数则是为了保证数据块在落盘的时候,此数据块不会被进程释放掉,或者说被置换出内存。
4缓冲区管理器的工作原理
当后台进程需要访问所需页面的时候,它会调用调用ReadBufferExtended
函数,通过该函数去获取所需页面,这里就涉及三种情况:
-
访问存储在缓冲池的页面
-
访问的页面不在缓冲池中,缓冲池未满,从磁盘将页面加载到缓冲池中来
-
访问的页面不在缓冲池中,缓冲池已满,需要将磁盘中的页面和缓冲池中页面进行置换
4.1访问已存在
这是最简单的情况,所需页面已经在缓冲池中存在,他的流程如下:
-
创建所需页面的
buffer_tag
,使用散列函数计算哈希值,获取对应的散列桶槽。 -
根据散列桶槽给对应的分区表加锁
BufMappingPartitionLock
,共享锁。 -
根据
buffer_tag
获取对应的buffer_id
,这里获取的id可以看到是2。 -
将
buffer_id=2
的缓冲区描述符钉住,即将描述符的refcount
和usage_count
增加1。 -
释放分区锁
BufMappingPartitionLock
。 -
访问
buffer_id=2
的缓冲池槽。

4.2访问不存在,但有空闲空间
后台进程需要访问的数据页面在缓冲池中不存在,但是缓冲池有空槽。
-
后台进程提供
Buffer_tag
,使用哈希函数计算获得对应的bucket
,获取该bucket
对应的分区锁的共享锁,查找哈希表中对应存储的buffer_id
,没有查到,释放分区锁。 -
从
freelist
中获取空缓冲区描述符,并将其pin住,在下图中获取得到的buffer_id
为4。 -
以独占模式获取相对应的分区的
BufMappingPartitionLock
。 -
将页面数据加载至
buffer_id
为4的缓冲池槽中:-
以排他模式获取相应缓冲区描述符的
io_in_progress_lock
。 -
将对应描述符的
IO_IN_PROGRESS
标记位置为1,防止其他进程访问。 -
将所需页面数据加载到缓冲池槽中。
-
更改缓冲区描述符的状态,将对应描述符的
IO_IN_PROGRESS
标记位置为0,VALID
为1. -
释放
io_in_progress_lock
-
-
释放相应分区的
BufMappingPartitionLock
。 -
访问buffer_id为4的缓冲池槽。

4.3访问不存在,需要置换
这种模式是最复杂的情况,需要选出合适的受害者页面,将其和磁盘中的页面进行置换。
-
根据所需页面
buffer_tag
查找缓冲表,且对应页面在缓冲表中不存在。 -
使用时钟扫描算法选择一个受害者页面,从缓冲表中获取包含受害者页面槽位的旧表项,并在缓冲区描述符层将受害者槽位的缓冲区描述符
pin
住,本例中受害者槽的buffer_id=5
,旧表项为Tag_F
,id为5。 -
如果受害者页面是脏页(被修改过数据的页面),将其刷盘,否则直接跳过当前步骤。
-
获取
buffer_id=5
描述符的共享content_lock
和独占io_in_progress_lock
。 -
修改相应描述符的状态,
IO_IN_PROGRESS
为1,JUST_DIRTYED
为0。 -
根据情况,调用
XLogFlush
函数将WAL缓冲区上的WAL数据写入当前WAL段文件。 -
将受害者页面的数据刷盘至存储中。(写之前一定先将WAL记录落盘)
-
更改相应描述符的状态,将
IO_IN_PROGRESS
为0,VALID
为1。 -
释放
content_lock
和io_in_progress_lock
。
-
-
以排他模式获取缓冲区表中旧表项所在分区的分区锁
BufMappingPartitionLock
。 -
获取新表项所在分区上的
BufMappingPartitionLock
,并将新表项插入缓冲表:-
创建新表项:
Buffer_tag
与受害者页面的buffer_id
组成的新表项。 -
以独占模式获取其对应的
BufMappingPartitionLock
。 -
将新表项插入缓冲表。
-

-
从缓冲表中删除旧表项,并释放旧表项所在分区的
BufMappingPartitionLock
。 -
将目标页面数据加载到旧表项的槽位,然后用
buffer_id=5
更新缓冲区描述的标识字段,将脏位置为0,并按流程初始化其他标志位。 -
释放新表项所在分区的
BufMappingPartitionLock
。 -
访问
buffer_id=5
所在的缓冲池页面。

5数据块置换算法(时钟扫描算法)
在上述第三种情况需要涉及到的就是缓冲区页面置换,以下图为例,简述整个流程。
-
首先
nextVictimBuffer
指针指向第一个描述符,buffer_id=1
,但是可以看到该描述符对应的位置为深蓝色,表示该描述符已经被pin
住了,所以跳过。 -
然后指向
buffer_id=2
的描述符,他为浅蓝色,表示未被pin住,但是根据该块的数字2表示他的usage_count
为2,因此本次不置换该页面,但是需要将其usage_count
减1,然后nextVictimBuffer
指向下一个缓冲区描述符。 -
指向的
buffer_id=3
的描述符,即没有被pin
住,同时其usage_count
也为0,所以将其选择作为本轮的受害者页面。

5.1环形缓冲区
有一种特殊情况,当系统需要访问一张大表的时候,这时系统会使用环形缓冲区而不是缓冲池,环形缓冲区是一个很小的临界区域,系统这样处理的原因是如果这个时候还是使用缓冲区的话,他将淘汰很多活跃的页面,将只需要使用一次的大表的低活跃页面置换进来,当其他进程在访问的时候就需要重新置换,缓存命中率就会降低。
而环形缓冲区,顾名思义,就是一个比较小的呈环状的缓冲区,该缓冲区只用于在特殊情况下的才会被使用,大体的情况有以下三种:
-
批量读取:
当扫描关系读取数据的大小超过缓冲池的四分之一,也就是
shared_buffers
的1/4时,在这种情况下环形缓冲区的大小是256KB。 -
批量写入:
当执行如下SQL命令的时候,会使用环形缓冲区,大小为16MB。
-
COPY FROM
-
CREATE TABLE AS
-
CREATE MATERIALIZED VIEW
或REFRESH MATERIALIZED VIEW
-
ALTER TABLE
-
-
清理过程:
当自动清理
AUTOVACUUM
进程执行清理过程的时候,环形缓冲区的大小为256KB。
除了置换受害者页面之外,检查点进程(Checkpointer)和后台写入器进程也会将脏页刷写至存储中。尽管两个进程都具有相同的功能(刷写脏页),但它们有着不同的角色和行为。
检查点进程将检查点记录(checkpoint record)写入WAL段文件,并在检查点开始时进行脏页刷写。
后台写入器的目的是通过少量多次的脏页刷盘,减少检查点带来的密集写入的影响。后台写入器会一点点地将脏页落盘,尽可能减小对数据库活动造成的影响。默认情况下,后台写入器每200毫秒被唤醒一次(由参数++bgwriter_delay++ 定义),且最多刷写++bgwriter_lru_maxpages++个页面(默认为100个页面)。
6调试演示
-
函数ReadBufferExtended
-
函数ReadBuffer_common
-
函数BufferAlloc
注:本文大多数制作精美的图片都引用于pigsty博客网站
参考
【博客】:https://pigsty.cc/blog/kernel/ch8/
【博客】:Postgresql源码(5)缓冲区管理
【网站】:Hironobu SUZUKI