Tbase 源码 (八)

BUFFER POOL   MANAGER

缓冲区管理器管理共享内存和持久存储之间的数据传输, 并可能对DBMS的性能产生重大影响。Tbase  底层PostgreSQL节点 的shared_buffer 使用了linux操作系统的文件缓存VM .

数据库物理结构

在下文讲述缓存池管理之前,我们需要简单介绍下数据库集簇的物理结构。数据文件是按特定的目录和文件名组成:

如果是特定的tablespace 的表/索引数据,则文件名形如

$PGDATA/pg_tblspc/$tablespace_oid/$database_oid/$relation_oid.no

如果不是特定的tablespace 的表/索引数据,则文件名形如

$PGDATA/base/$database_oid/$relation_oid.num

其中PGDATA 是初始化的数据根目录,tablespace_oid 是tablespace 的oid,database_oid 是database 的oid,relation_oid是表/索引的oid。no 是一个数值,当表/索引的大小超过了1G(该值可以在编译 源码时由configuration的–with-segsize 参数指定大小),该数值就会加1,初始值为0,但是为0时文件的后缀不加.0。

除此之外,表/索引数据文件中还包含以_fsm(与free space map 相关,详见文档 ) 和_vm (与visibility map 相关,详见文档 )为后缀的文件。这两种文件在PostgreSQL 中被认为是表/索引数据文件的另外两种副本,其中_fsm 结尾的文件为该表/索引的数据文件副本1,_vm结尾的文件为该表/索引的数据文件副本2,而不带这两种后缀的文件为该表/索引的数据文件副本0。

无论表/索引的数据文件副本0或者1或者2,都是按照页面(page)为组织单元存储的,具体数据页的内容和结构,我们这里不再详细展开。但是值得一提的是,缓冲池中最终存储的就是一个个的page。而每个page我们可以按照(tablespace_oid, database_oid, relation_oid, fork_no, page_no) 唯一标示,而在PostgreSQL 源码中是使用结构体BufferTag 来表示,其结构如下,下文将会详细分析这个唯一标示在内存管理中起到的作用。

typedef struct buftag
{
  RelFileNode rnode;      /* physical relation identifier */
  ForkNumber  forkNum;
  BlockNumber blockNum;    /* blknum relative to begin of reln */
} BufferTag;
typedef struct RelFileNode
{
    Oid         spcNode;        /* tablespace */
    Oid         dbNode;         /* database */
    Oid         relNode;        /* relation */
} RelFileNode;

缓存管理结构

缓存池可以简单理解为在共享内存上分配的一个数组,其初始化的过程如下:

BufferBlocks = (char *) ShmemInitStruct("Buffer Blocks", NBuffers * (Size) BLCKSZ, &foundBufs);

其中NBuffers 即缓存池的大小与GUC 参数shared_buffers 相关(详见链接)。数组中每个元素存储一个缓存页,对应的下标buf_id 可以唯一标示一个缓存页。

为了对每个缓存页进行管理,我们需要管理其元数据,在PostgreSQL 中利用BufferDesc 结构体来表示每个缓存页的元数据,下文称其为缓存描述符,其初始化过程如下:

BufferDescriptors = (BufferDescPadded *) 
                                ShmemInitStruct("Buffer Descriptors",
                         NBuffers * sizeof(BufferDescPadded),
                          &foundDescs);

可以发现,缓存描述符是和缓存池的每个页面一一对应的,即如果有16384 个缓存页面,则就有16384 个缓存描述符。而其中的BufferTag 即是上文的PostgreSQL 中数据页面的唯一标示。

直到这里,我们如果要从缓存池中请求某个特定的页面,只需要遍历所有的缓存描述符即可。但是很显然这样的性能会非常的差。为了优化这个过程,PostgreSQL 引入了一个BufferTag 和缓存描述符的hash 映射表。通过它,我们可以快速找到特定的数据页面在缓存池中的位置。

概括起来,缓存管理主要包括三层结构,如下图:

缓存池,是一个数组,每个元素其实就是一个缓存页,下标buf_id 唯一标示一个缓存页。

缓存描述符,也是一个数组,而且和缓存池的缓存一一对应,保存每个缓存页的元数据信息。

缓存hash 表,是存储BufferTag 和缓存描述符之间映射关系的hash 表。

Tbase 源码 (八)_第1张图片

我们将分析每层结构的具体实现以及涉及到的锁管理和缓冲页淘汰算法,深入浅出,介绍缓存池的管理机制。

缓存hash 表

从上文的分析,我们知道缓存hash 表是为了加快BufferTag 和缓存描述符的检索速度构造的数据结构。在PostgreSQL 中,缓存hash 表的数据结构设计比较复杂,我们会在接下来的月报去介绍在PostgreSQL 中缓存hash 表是如何实现的。在本文中,我们把缓存hash 表抽象成一个个的bucket slot。因为哈希函数还是有可能碰撞的,所以bucket slot 内可能有几个data entry 以链表的形式存储,如下图:

Tbase 源码 (八)_第2张图片

而使用BufferTag 查找对应缓存描述符的过程可以简述如下:

获取BufferTag 对应的哈希值hashvalue

通过hashvalue 定位到具体的bucket slot

遍历bucket slot 找到具体的data entry,其数据结构BufferLookupEnt 如下:

/* entry for buffer lookup hashtable */
typedef struct
{
  BufferTag  key;      /* Tag of a disk page */
  int      id;        /* Associated buffer ID */
} BufferLookupEnt;

BufferLookupEnt 的结构包含id 属性,而这个属性可以和唯一的缓存描述符或者唯一的缓存页对应,所以我们就获取了BufferTag 对应的缓存描述符。

缓存hash 表初始化的过程如下:

InitBufTable(NBuffers + NUM_BUFFER_PARTITIONS);

可以看出,缓存hash 表的bucket slot 个数要比缓存池个数NBuffers 要大。除此之外,多个后端进程同时访问相同的bucket slot 需要使用锁来进行保护,后文的锁管理会详细讲述这个过程。

缓存描述符

上文的缓存hash 表可以通过BufferTag 查询到对应的 buffer ID,而PostgreSQL 在初始化 缓存描述符和缓存页面一一对应,存储每个缓存页面的元数据信息,其数据结构

BufferDesc 如下:

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;

其中:

tag 指的是对应缓存页存储的数据页的唯一标示

buffer_id 指的是对应缓存页的下标,我们通过它可以直接访问对应缓存页

state 是一个无符号32位的变量,包含:

18 bits refcount,当前一共有多少个后台进程正在访问该缓存页,如果没有进程访问该页面,本文称为该缓存描述符unpinned,否则称为该缓存描述符pinned

4 bits usage count,最近一共有多少个后台进程访问过该缓存页,这个属性用于缓存页淘汰算法,下文将具体讲解。

10 bits of flags,表示一些缓存页的其他状态,如下:

#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) */

freeNext,指向该缓存之后第一个空闲的缓存描述符

content_lock,是控制缓存描述符的一个轻量级锁,我们会在缓存锁管理具体分析其作用

上文讲到,当数据启动时,会初始化与缓存池大小相同的缓存描述符数组,其每个缓存描述符都是空的,这时整个缓存管理的三层结构如下

Tbase 源码 (八)_第3张图片

第一个数据页面从磁盘加载到缓存池的过程可以简述如下:

从freelist 中找到第一个缓存描述符,并且把该缓存描述符pinned (增加refcount和usage_count)

在缓存hash 表中插入这个数据页面的BufferTag 与buf_id 的对应新的data entry

从磁盘中将数据页面加载到缓存池中对应缓存页面中

在对应缓存描述符中更新该页面的元数据信息

缓存描述符是可以持续更新的,但是如下场景会使得对应的缓存描述符状态置为空并且放在freelist 中:

数据页面对应的表或者索引被删除

数据页面对应的数据库被删除

数据页面被VACUUM FULL 命令清理

缓存池

上文也提到过,缓存池可以简单理解为在共享内存上分配的一个缓存页数组,每个缓存页大小为PostgreSQL 页面的大小,一般为8KB,而下标buf_id 唯一标示一个缓存页。

缓冲池的大小与GUC 参数shared_buffers 相关,例如shared_buffers 设置为128MB,页面大小为8KB,则有128MB/8KB=16384个缓存页。

缓存锁管理

在PostgreSQL 中是支持并发查询和写入的,多个进程对缓存的访问和更新是使用锁的机制来实现的,接下来我们分析下在PostgreSQL 中缓存相关锁的实现。

因为在缓存管理的三层结构中,每层都有并发读写的情况,通过控制缓存描述符的并发访问就能够解决缓存池的并发访问,所以这里的缓存锁实际上就是讲的缓存hash 表和缓存描述符的锁。

BufMappingLock

BufMappingLock 是缓存hash 表的轻量级锁。为了减少BufMappingLock 的锁争抢并且能够兼顾锁空间的开销,PostgreSQL 中把BufMappingLock 锁分为了很多片,默认为128片,每一片对应总数/128 个bucket slot。

当我们检索一个BufferTag 对应的data entry是需要BufMappingLock 对应分区的共享锁,当我们插入或者删除一个data entry 的时候需要BufMappingLock 对应分区的排他锁。

除此之外,缓存hash 表还需要其他的一些原子锁来保证一些属性的一致性,这里不再赘述。

content_lock

content_lock 是缓存描述符的轻量级锁。当需要读一个缓存页的时候,后台进程会去请求该缓存页对应缓存描述符的content_lock 共享锁。而当以下的场景,后台进程会去请求content_lock 排他锁:

插入或者删除/更新该缓存页的元组

vacuum 该缓存页

freeze 该缓存页

io_in_progress_lock

io_in_progress_lock 是作用于缓存描述符上的I/O锁。当后台进程将对应缓存页加载至缓存或者刷出缓存,都需要这个缓存描述符上的io_in_progress_lock 排它锁。

其他

缓存描述符中的state 属性含有很多需要原子排他性的字段,例如refcount 和 usage count。但是在这里没有使用锁,而是使用pg_atomic_unlocked_write_u32()或者pg_atomic_read_u32() 方法来保证多进程访问相同缓存描述符的state 的原子性。

缓存页淘汰算法

在PostgreSQL 中采用clock-sweep 算法来进行缓存页的淘汰。clock-sweep 是NFU(Not Frequently Used)最近不常使用算法的一个优化算法。其算法在PostgreSQL 中的实现可以简述如下:

获取第一个候选缓存描述符,存储在freelist 控制信息的数据结构BufferStrategyControl 的nextVictimBuffer 属性中

如果该缓存描述符unpinned,则跳到步骤3,否则跳到步骤4

如果该候选缓存描述符的usage_count 属性为0,则选取该缓存描述符为要淘汰的缓存描述符,跳到步骤5,否则,usage_count–,跳到步骤4

nextVictimBuffer 赋值为下一个缓存描述符(当缓存描述符全部遍历完成,则从第0个继续),跳到步骤1继续执行,直到发现一个要淘汰的缓存描述符

返回要淘汰的缓存描述符的buf_id

clock-sweep 算法一个比较简单的例子如下图:

Tbase 源码 (八)_第4张图片

我们已经对PostgreSQL 的缓存池管理整个构架和一些关键的技术有了了解,下面我们会举例说明整个流程。

为了能够涉及到较多的操作,我们将缓存池满后访问某个不在缓存池的数据页面这种场景作为例子,其整个流程如下:

1.根据请求的数据页面形成BufferTag,假设为Tag_M,用Tag_M 去从缓存hash 表中检索data entry,很明显这里没有发现该BufferTag

2.使用clock-sweep 算法选择一个要淘汰的缓存页面,例如这里buf_id=5,该缓存页的data entry 为’Tag_F, buf_id=5’

3.如果是脏页,将buf_id=5的缓存页刷新到磁盘,否则跳到步骤4。刷新一个脏页的步骤如下:

a. 获得buffer_id=5的缓存描述符的content_lock 共享锁和io_in_progress 排它锁(步骤f会释放) b. 修改该描述符的state,BM_IO_IN_PROGRESS 和BM_JUST_DIRTIED 字段设为1 c. 根据情况,执行 XLogFlush() 函数,对应的wal 日志刷新到磁盘 d. 将缓存页刷新到磁盘 e. 修改该描述符的state,BM_IO_IN_PROGRESS 字段设为1,BM_VALID 字段设为1 f. 释放该缓存描述符的content_lock 共享锁和io_in_progress 排它锁

4.获取buf_id=5的bucket slot 对应的BufMappingLock 分区排他锁,并将该data entry 标记为旧的

5.获取新的Tag_M 对应bucket slot 的BufMappingLock 分区排他锁并且插入一条新的data entry

6.删除buf_id=5的data entry,并且释放buf_id=5的bucket slot 对应的BufMappingLock 分区锁

7.从磁盘上加载数据页面到buf_id=5的缓存页面,并且更新buf_id=5的缓存描述符state 属性的BM_DIRTY字段为0,初始化state的其他字段。

8.释放Tag_M 对应bucket slot 的BufMappingLock 分区排他锁

9.从缓存中访问该数据页面

其过程的示意图如下所示:

RING BUFFER(环形缓冲区)

在读写大表时,PostgreSQL使用环形缓冲区而不是缓冲池。环形缓冲区是一个小而临时的缓冲区。当满足下面列出的任何条件时,将为共享内存分配一个环形缓冲区:

1.bulk-reading
当扫描的对象大小超过缓冲池大小四分之一(shared_buffers/4)时。这种情况下,环形缓冲区大小为256KB。

2.bulk-writing
执行下面列出的SQL命令时。环形缓冲区大小为16MB。

COPY FROM命令。
CREATE TABLE AS命令。
CREATE MATERIALIZED VIEW或REFRESH MATERIALIZED VIEW命令。
ALTER TABLE命令。
3.vacuum processing
当autovacuum执行时。这种情况下,环形缓冲区大小为256KB。

分配的环形缓冲区在使用后立即释放。

环形缓冲区的好处是显而易见的。如果后端进程在不使用环形缓冲区的情况下读取一个巨大的表,所有存储在缓存池的页面被删除(踢出);那么,缓存命中率就会降低。环形缓冲区可以有效的避免此类问题。

为什么批量读取和vacuum的默认环形缓冲区大小为256KB?
为什么是256KB?答案在缓冲区管理器源目录下的README中解释。
对于顺序扫描,使用256KB环。这么小的尺寸在L2级缓存是合适的,这使得从OS缓存到共享缓冲区缓存的页面传输效率更高。更少的尺寸在通常情况下足够了,但是环必须足够大,以同时容纳扫描到的pin住的所有页面。

FLUSHING DIRTY PAGES(清除脏页)

除了替换victim页面之外,checkpointer和后台写进程将脏页面刷新到磁盘。两个进程都具有相同的功能(刷新脏页);但是,它们有不同的角色和行为。

checkpointer进程将检查点记录写入WAL段文件,并在检查点开始时刷新脏页。 

后台写进程角色的作用是减少密集检查点的影响。后台写进程持续一点一点地刷新脏页面,对数据库的影响最小。默认情况下,后台写进程每隔200毫秒唤醒(由bgwriter_delay定义)并最多刷新bgwriter_lru_maxpages(默认值为100页)。

checkpointer进程 与后台bgwriter写进程分开的。bgwriter进程执行后台写,检查点和其他一些职责。这意味着我们在不停止后台写入的情况下,无法执行最终检查点fsync,因此在一个进程中执行这两项操作会产生负面的性能影响。

刷脏页面和CLOG的动作同样会产生XLOG, 又要保证数据的一致性。

在做数据恢复时,先找到检查点结束的XLOG位置,然后根据这里的结束检查点时写入的XLOG信息找到开始的位置,然后读取XLOG并实施xlog replay恢复,至少要恢复到检查点结束的XLOG位置才能保证数据的一致性和完整性。

Tbase 源码 (八)_第5张图片

数据库中执行checkpoint时,就会将其之前的脏数据刷到磁盘,从而实现数据缩短数据库崩溃恢复时间的目的。

以下情况会执行 checkpoint 】:

超级用户手动执行checkpoint命令
参数checkpoint_timeout中指定的间隔(默认300秒)
写入WAL的数据量已达到参数max_wal_size(默认值:1GB)
online backup开始的时候
执行pg_start_backup函数时
在实例关闭时(除了pg_ctl stop -m命令执行)
在进行数据库配置时(例如CREATE DATABASE / DROP DATABASE语句)


执行checkpoint时,数据库主要完成以下几个工作:
识别shared_buffers中所有的脏页
将脏页写入相应的数据文件
确保修改后的文件通过fsync()写入到磁盘

注意:fsync()函数用于强制从缓冲区高速缓存中物理写入数据,并确保在系统崩溃或其他故障之后,直到fsync()调用时的所有数据都记录在磁盘上。

checkpoint相关的参数:

1、checkpoint_timeout:
这是自动WAL检查点之间的最长时间(默认为5分钟)。增加此参数可能会增加崩溃恢复所需的时间。

2、max_wal_size:
使WAL增长到自动WAL检查点之间的最大大小。默认值为1 GB。增大此参数可能会增加崩溃恢复所需的时间。

如果我们同时设置了这两个参数,则检查点将以先到者为准。

3、min_wal_size:
只要WAL磁盘使用率保持低于此设置,旧的WAL文件将始终在检查点被回收以备将来使用,而不是被删除。这可以用来确保保留足够的WAL空间来处理WAL使用率的峰值,例如在运行大型批处理作业时。 (默认为80 MB)

4、checkpoint_completion_target :
由于每5分钟或达到每个max_wal_size阈值都会发生一次检查点,因此在检查点时间内,共享缓冲区中存在的所有脏页将被刷新到磁盘,从而导致巨大的IO。
checkpoint_completion_target来这里进行救援。
这会使刷新速度变慢,这意味着PostgreSQL应该花费checkpoint_completion_target * checkpoint_timeout的时间来写入数据。
例如,如果我的checkpoint_completion_target为0.5,并且数据库将限制写入,以便最后写入在2.5分钟后完成。

5、wal_buffers :
用于尚未写入磁盘的WAL数据的共享内存量。默认设置为-1,选择的大小等于shared_buffers的1/32(大约3%),但不小于64kB,也不大于一个WAL段的大小,通常为16MB。

6、checkpoint_flush_after:
在执行检查点时,只要写入的字节数超过checkpoint_flush_after,则尝试强制OS将这些写入操作刷到存储中。这样做将限制内核页面缓存中的脏数据量,从而减少在检查点末尾发出fsync时停顿的可能性。 

数据栅栏分割点-Barrier

Barrier handling for PITR

按一定周期生成数据栅栏分割点-Barrier,用来支撑数据库的 PITR (任意时间点数据恢复)。

入口 RequestBarrier  =》 PrepareBarrier=》 ExecuteBarrier=》 EndBarrier 

 \src\backend\pgxc\barrier\barrier.c

void
RequestBarrier(const char *id, char *completionTag)
{
    PGXCNodeAllHandles *prepared_handles;
    const char *barrier_id;

    elog(DEBUG2, "CREATE BARRIER request received");
    /*
     * Ensure that we are a Coordinator and the request is not from another
     * coordinator
     */

    if (!IS_PGXC_COORDINATOR)
        ereport(ERROR,
                (errcode(ERRCODE_INTERNAL_ERROR),
                 errmsg("CREATE BARRIER command must be sent to a Coordinator")));

    if (IsConnFromCoord())
        ereport(ERROR,
                (errcode(ERRCODE_INTERNAL_ERROR),
                 errmsg("CREATE BARRIER command is not expected from another Coordinator")));

    /*
     * Get a barrier id if the user has not supplied it
     */

    barrier_id = generate_barrier_id(id);

    elog(DEBUG2, "CREATE BARRIER <%s>", barrier_id);

    /*
     * Step One. Prepare all Coordinators for upcoming barrier request
     */

    prepared_handles = PrepareBarrier(barrier_id);

    /*
     * Step two. Issue BARRIER command to all involved components, including
     * Coordinators and Datanodes
     */

    ExecuteBarrier(barrier_id);

    /*
     * Step three. Inform Coordinators about a successfully completed barrier
     */

    EndBarrier(prepared_handles, barrier_id);
    /* Finally report the barrier to GTM to backup its restart point */
    ReportBarrierGTM(barrier_id);

    /* Free the handles */
    pfree_pgxc_all_handles(prepared_handles);

    if (completionTag)
        snprintf(completionTag, COMPLETION_TAG_BUFSIZE, "BARRIER %s", barrier_id);
}

你可能感兴趣的:(数据库,postgresql)