读书笔记 - PostgreSQL指南 - 内幕探索 - 8.缓冲区管理器

来源:这里教程网 时间:2026-03-14 21:04:36 作者:
  • 8.缓冲区管理器
  • 8.1概览
  • 8.2缓冲区管理器的结构
  • 8.2.1缓冲表
  • 8.2.1缓冲区描述符
  • 8.2.3缓冲区描述符层
  • 8.2.3缓冲池
  • 8.3缓冲区管理器锁
  • 8.3.1缓冲表锁
  • 8.3.2缓冲区描述符相关的锁
  • 8.4缓冲区管理器的工作原理
  • 8.4.1访问存储在缓冲池中的页面
  • 8.4.2将页面从存储加载到空槽
  • 8.4.3将页面从存储加载到受害者缓冲池槽
  • 8.4.4 页面替换算法:时钟扫描
  • 8.5环形缓冲区
  • 8.6脏页刷盘

    8.缓冲区管理器

    冲区管理器管理着共享内存和持久存储之间的数据传输

    8.1概览

    PostgreSQL缓冲区管理器由缓冲表、缓冲区描述符和缓冲池组成。缓冲池层存储着数据文件页面,诸如表页与索引页,以及其相应的自由空间映射和可见性映射的页面。缓冲池是一个数组,数据的每个槽中存储数据文件的一页。缓冲池数组的序号索引称为buffer_id。

    PostgreSQL中的每个数据文件页面都可以分配到unique的标签,即缓冲区标签。当缓冲区管理器收到请求时,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.htypedef 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

    参考

  • https://www.rockdata.net/zh-cn/docs/13/explicit-locking.html
  • https://www.rockdata.net/zh-cn/docs/13/storage-page-layout.html

    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个页面)。

    本节英文版

    https://www.rockdata.net/docs/13/

  • 相关推荐