PostgreSQL缓冲区管理器

------------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 :当相应的缓冲池槽不存储页面(即refcountusage_count都是0),该描述符的状态为

  • 钉住( Pinned :当相应缓冲池槽中存储着页面,且有PostgreSQL进程正在访问的相应页面(即refcountusage_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的缓冲区描述符钉住,即将描述符的refcountusage_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_lockio_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 VIEWREFRESH 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

相关推荐
昵称是6硬币33 分钟前
MongoDB系列教程-教程概述
数据库·mongodb
极限实验室4 小时前
IK 字段级别词典的升级之路
数据库
曾几何时`5 小时前
MySQL(配置)——MariaDB使用
数据库·mysql
努力学习java的哈吉米大王5 小时前
MySQL——MVCC
数据库·mysql
数据要素X5 小时前
【数据架构10】数字政府架构篇
大数据·运维·数据库·人工智能·架构
lixzest6 小时前
Redis实现数据传输简介
数据库·redis·缓存
搬砖的小熊猫6 小时前
MySQL常见面试题
数据库·mysql
weixin_419658316 小时前
MySQL的JDBC编程
数据库·mysql
JavaLearnerZGQ6 小时前
Docker部署Nacos
数据库·docker·容器
何传令7 小时前
SQL排查、分析海量数据以及锁机制
数据库·sql·mysql