【PGCCC】postgresql 缓存池并发设计

ops/2024/11/8 14:22:06/

前言

postgresql 对于缓存池的并发控制,比较复杂。下面通过一步步的优化,来讲述 postgresql 的设计思路。它为了提升锁的性能,大量使用了 CAS 操作,只有在操作比较慢的时候,才会加入读写锁。在继续阅读之前,需要熟悉它的存储设计,可以参考这篇文章 {% post_link postgresql-storage-architecture postgresql 存储设计 %} 。

设计思路推演

首先考虑下单线程的情况,我们先要查找缓存池是否已经包含了要读取的page,如果包含了则直接返回该 buffer。如果没有,需要从磁盘加载到缓存池中,然后返回。下面是通过一步步的加锁,来实现并发的控制。

Hash 表锁

hash 表保存了 page 到 buffer 的对应关系,很明显所有的读取都需要从 hash 表查找开始,我们可以从 控制 hash 表的锁来开始。假设现在只有 hash 分区锁,我们写的程序应该是这样的

// 获取分区 id,page_number 表示要获取的 page 标识
partition_id = get_hash_parition(page_number);
// 对此分区加读锁
lock_partition_read(hash_table, partition_id);
// 查找该 page 是否存在缓存
buffer_id, found = lookup(hash_table, page_number);if (found) {// 如果找到该缓存,则返回ublock(hash_table, partition);return buffer_id;
}// 如果没有对应的缓存,则需要从缓存池中,找到替换的位置
free_buffer_id = find_victim_buffer();
// 获取旧有位置的缓存对应哪个 page number
free_page_number = get_page_number(free_buffer_id);
// 对其所在的分区加写锁
free_partition_id = get_hash_parition(free_page_number);
lock_partition_write(hash_table, free_partition_id);
lock_partition_write(hash_table, partition_id);
// 删除旧有的缓存
delete(hash_table, free_page_number);
// 从磁盘读取数据到缓存,并且添加到 hash 表
read_from_disk(page_number, free_buffer_id);
insert(hash_table, pageNumber, free_buffer_id);// 释放分区锁
unlock_partition(hash_table, free_buffer_id);
unlock_partition(hash_table, partition_id);
return free_buffer_id;

Pin Buffer

其中 find_victim_buffer 函数负责挑选出可被替换的 buffer,它的原理是找到 pin count 等于 0 的 buffer。所以这里面会有个问题,当我们查找到数据后,需要及时的 pin 操作,不然有可能会被替换掉,造成读取的数据不是想要的 page number。

// 获取分区 id,page_number 表示要获取的 page 标识
partition_id = get_hash_parition(page_number);
// 对此分区加读锁
lock_partition_read(hash_table, partition_id);
// 查找该 page 是否存在缓存
buffer_id, found = lookup(hash_table, page_number);if (found) {// 需要及时 pin 操作pin_buffer(buffer_id)ublock(hash_table, partition);return buffer_id;
}// 如果没有对应的缓存,则需要从缓存池中,找到替换的位置
free_buffer_id = find_victim_buffer();
// 获取旧有位置的缓存对应哪个 page number
free_page_number = get_page_number(free_buffer_id);
// 及时 pin 操作
pin_buffer(free_buffer_id);// 对其所在的分区加写锁
free_partition_id = get_hash_parition(free_page_number);
lock_partition_write(hash_table, free_partition_id);
lock_partition_write(hash_table, partition_id);
// 删除旧有的缓存
delete(hash_table, free_page_number);
// 从磁盘读取数据到缓存,并且添加到 hash 表
read_from_disk(page_number, free_buffer_id);
insert(hash_table, pageNumber, free_buffer_id);// 释放分区锁
unlock_partition(hash_table, free_buffer_id);
unlock_partition(hash_table, partition_id);
return free_buffer_id;

查找空闲Buffer优化

我们观察上述代码,如果我们没有找到对应的 buffer,那么需要从buffer数组中挑选出一个合适的,这个步骤是很花费时间的。所以会造成长时间的加锁,并且hash表的一个分区会包含了多个 buffer,这样会造成性能影响。所以 在执行这一步之前,我们可以将分区锁释放。不过这样会遇到一个问题,如下所示

// 获取分区 id,page_number 表示要获取的 page 标识
partition_id = get_hash_parition(page_number);
// 对此分区加读锁
lock_partition_read(hash_table, partition_id);
// 查找该 page 是否存在缓存
buffer_id, found = lookup(hash_table, page_number);if (found) {// 需要及时 pin 操作pin_buffer(buffer_id)ublock(hash_table, partition);return buffer_id;
}// 释放锁,防止长时间占住分区锁
unlock_partition(hash_table, partition_id);// 如果没有对应的缓存,则需要从缓存池中,找到替换的位置
free_buffer_id = find_victim_buffer();
// 获取旧有位置的缓存对应哪个 page number
free_page_number = get_page_number(free_buffer_id);
// 及时 pin 操作
pin_buffer(free_buffer_id);// 对其所在的分区加写锁
free_partition_id = get_hash_parition(free_page_number);// 步骤1
lock_partition_write(hash_table, free_partition_id);
lock_partition_write(hash_table, partition_id);// 删除旧有的缓存
delete(hash_table, free_page_number);
// 从磁盘读取数据到缓存,并且添加到 hash 表
read_from_disk(page_number, free_buffer_id);
insert(hash_table, pageNumber, free_buffer_id);// 释放分区锁
unlock_partition(hash_table, free_buffer_id);
unlock_partition(hash_table, partition_id);
return free_buffer_id;

避免重复读取

我们假设有两个线程都跑到了 步骤1,那么就会产生两个 buffer 都是保存了同一份 Page 的数据。为了避免这一情况,我们在执行步骤1之后,需要检查一下。

// 获取分区 id,page_number 表示要获取的 page 标识
partition_id = get_hash_parition(page_number);
// 对此分区加读锁
lock_partition_read(hash_table, partition_id);
// 查找该 page 是否存在缓存
buffer_id, found = lookup(hash_table, page_number);if (found) {// 需要及时 pin 操作pin_buffer(buffer_id)ublock(hash_table, partition);return buffer_id;
}// 释放锁,防止长时间占住分区锁
unlock_partition(hash_table, partition_id);// 如果没有对应的缓存,则需要从缓存池中,找到替换的位置
free_buffer_id = find_victim_buffer();
// 获取旧有位置的缓存对应哪个 page number
free_page_number = get_page_number(free_buffer_id);
// 及时 pin 操作
pin_buffer(free_buffer_id);// 对其所在的分区加写锁
free_partition_id = get_hash_parition(free_page_number);// 步骤1
lock_partition_write(hash_table, free_partition_id);
lock_partition_write(hash_table, partition_id);// 增加查看是否有别的进程已经在读取磁盘数据了
buffer_id, found = lookup(hash_table, page_number);
if (found) {return buffer_id;
}// 删除旧有的缓存
delete(hash_table, free_page_number);
// 从磁盘读取数据到缓存,并且添加到 hash 表
read_from_disk(page_number, free_buffer_id);
insert(hash_table, pageNumber, free_buffer_id);// 释放分区锁
unlock_partition(hash_table, free_buffer_id);
unlock_partition(hash_table, partition_id);
return free_buffer_id;

磁盘读取优化

同样从磁盘读取数据的操作也会很花费时间,为了避免长时间占用锁,我们需要在执行之前释放相关的锁。如果要实现这一步,需要设置一个 valid 标记位,表示数据是否成功的被磁盘读取。所以我们在哈希表中找到缓存后,还需要分辨数据是否完成磁盘读取,并且我们还需要额外的锁(称为 io_lock),来保证同步等待。

// 获取分区 id,page_number 表示要获取的 page 标识
partition_id = get_hash_parition(page_number);
// 对此分区加读锁
lock_partition_read(hash_table, partition_id);
// 查找该 page 是否存在缓存
buffer_id, found = lookup(hash_table, page_number);if (found) {pin_buffer(buffer_id);unlock_partition(hash_table, partition_id);while(! is_valid(buffer_id)) {wait_io_lock(buffer_id)}return buffer_id;
}// 释放锁,防止长时间占住分区锁
unlock_partition(hash_table, partition_id);// 如果没有对应的缓存,则需要从缓存池中,找到替换的位置
free_buffer_id = find_victim_buffer();
// 获取旧有位置的缓存对应哪个 page number
free_page_number = get_page_number(free_buffer_id);
// 及时 pin 操作
pin_buffer(free_buffer_id);// 对其所在的分区加写锁
free_partition_id = get_hash_parition(free_page_number);// 步骤1
lock_partition_write(hash_table, free_partition_id);
lock_partition_write(hash_table, partition_id);// 增加查看是否有别的进程已经在读取磁盘数据了
buffer_id, found = lookup(hash_table, page_number);
if (found) {return buffer_id;
}// 删除旧有的缓存
delete(hash_table, free_page_number);// 设置该缓存无效
set_buffer_invalid(free_buffer_id);// 并且添加到 hash 表
insert(hash_table, pageNumber, free_buffer_id);// 释放分区锁
unlock_partition(hash_table, free_buffer_id);
unlock_partition(hash_table, partition_id);// 加锁
lock_io(free_buffer_id);
// 如果该缓存已经成功读取完了,那么直接返回。
while (! is_valid(free_buffer_id)) {// 否则从磁盘读取read_from_disk(page_number, free_buffer_id);set_buffer_valid(free_buffer_id);
}
unlock_io(free_buffer_id)
return free_buffer_id;

Valid标记位

现在基本的流程确定好了,不过 postgresql 认为加载磁盘是很慢的操作,是可以被终止的。所以当检测到一个 buffer 为 invalid 时,可能对应着两种情况,一种是其它进程正在进行读取,一种是之前的读取被终止了。所以当发现 buffer invalid 的时候,都需要主动去磁盘读取。但是这样会造成并发重复读取,所以还需要提供一个锁,专门负责这一块的同步,称为 io_lock。

// 获取分区 id,page_number 表示要获取的 page 标识
partition_id = get_hash_parition(page_number);
// 对此分区加读锁
lock_partition_read(hash_table, partition_id);
// 查找该 page 是否存在缓存
buffer_id, found = lookup(hash_table, page_number);if (found) {pin_buffer(buffer_id);unlock_partition(hash_table, partition_id);// 加锁lock_io(free_buffer_id);// 如果该缓存已经成功读取完了,那么直接返回。while (! is_valid(free_buffer_id)) {// 否则从磁盘读取read_from_disk(page_number, free_buffer_id);set_buffer_valid(free_buffer_id);}unlock_io(free_buffer_id)return buffer_id;
}// 释放锁,防止长时间占住分区锁
unlock_partition(hash_table, partition_id);// 如果没有对应的缓存,则需要从缓存池中,找到替换的位置
free_buffer_id = find_victim_buffer();
// 获取旧有位置的缓存对应哪个 page number
free_page_number = get_page_number(free_buffer_id);
// 及时 pin 操作
pin_buffer(free_buffer_id);// 对其所在的分区加写锁
free_partition_id = get_hash_parition(free_page_number);// 步骤1
lock_partition_write(hash_table, free_partition_id);
lock_partition_write(hash_table, partition_id);// 增加查看是否有别的进程已经在读取磁盘数据了
buffer_id, found = lookup(hash_table, page_number);
if (found) {return buffer_id;
}// 删除旧有的缓存
delete(hash_table, free_page_number);// 设置该缓存无效
set_buffer_invalid(free_buffer_id);// 并且添加到 hash 表
insert(hash_table, pageNumber, free_buffer_id);// 释放分区锁
unlock_partition(hash_table, free_buffer_id);
unlock_partition(hash_table, partition_id);// 加锁
lock_io(free_buffer_id);
// 如果该缓存已经成功读取完了,那么直接返回。
while (! is_valid(free_buffer_id)) {// 否则从磁盘读取read_from_disk(page_number, free_buffer_id);set_buffer_valid(free_buffer_id);
}
unlock_io(free_buffer_id)
return free_buffer_id;

锁实现

上面一步步推导出了最后的并发流程,接下来依次介绍这些锁的实现。

hash 表分区锁

postgresql 将 hash 表分成多个区,每个区都对应了一个读写锁。

BufferTag	newTag;  // 表示要查找的 page
newHash = BufTableHashCode(&newTag);  // 计算 hash 值
newPartitionLock = BufMappingPartitionLock(newHash);   // 根据hash值找到对应的分区锁
LWLockAcquire(newPartitionLock, LW_SHARED); // 加读锁

BufMappingPartitionLock函数分为两步,首先是找到对应的分区。然后是找到该分区对应的锁。

// 这里只是简单的取余操作,找到对应的分区。NUM_BUFFER_PARTITIONS 表示分区数目
#define BufTableHashPartition(hashcode)  ((hashcode) % NUM_BUFFER_PARTITIONS) // postgresql 分配了一个大的lwlock 数组,分区锁占用了其中一部分,从 BUFFER_MAPPING_LWLOCK_OFFSET 位置开始
#define BufMappingPartitionLock(hashcode) 	(&MainLWLockArray[BUFFER_MAPPING_LWLOCK_OFFSET + BufTableHashPartition(hashcode)].lock)

buffer header 锁

BufferDesc 包含了buffer的头部信息,也包含了相关的锁

typedef struct BufferDesc
{BufferTag	tag;			// 缓存哪个文件的blockint			buf_id;			// buffer idpg_atomic_uint32 state;		// 状态值int			wait_backend_pid;	/* backend PID of pin-count waiter */int			freeNext;		// 指向下个空闲位置LWLock		content_lock;	// 读写锁,用来控制内容的访问
} BufferDesc;

state字段是一个 atomic 类型的值,它的更新非常频繁。它有一个标记位BM_LOCKED,作为锁的实现。postgresql 使用了自旋 + CAS 操作来实现乐观锁。

uint32 LockBufHdr(BufferDesc *desc)
{// 用于自适应控制自旋间隙SpinDelayStatus delayStatus;uint32		old_buf_state;// 初始化init_local_spin_delay(&delayStatus);while (true){// CAS 操作,来设置BM_LOCKED标记位old_buf_state = pg_atomic_fetch_or_u32(&desc->state, BM_LOCKED);// 如果之前BM_LOCKED标记位没有设置,说明之前没有人加锁,现在我们已经成功加锁了。if (!(old_buf_state & BM_LOCKED))break;// 如果没有加锁成功,则判断是否需要延迟perform_spin_delay(&delayStatus);}finish_spin_delay(&delayStatus);return old_buf_state | BM_LOCKED;
}

释放锁就很简单,只是简单的取消标记位BM_LOCKED

#define UnlockBufHdr(desc, s)	\do {	\pg_write_barrier(); \pg_atomic_write_u32(&(desc)->state, (s) & (~BM_LOCKED)); \} while (0)

Pin Buffer

pin 操作也是基于 CAS 操作,state字段中保存了 pin count。不过它并没有使用BM_LOCKED标记位,因为 pin 操作非常频繁,postgresql 做了进一步的优化。它分为两种场景,一种是pin操作之前没有获取到锁,一种是已经获取到了锁。

// 调用之前没有获取到锁
static bool PinBuffer(BufferDesc *buf, BufferAccessStrategy strategy)
{Buffer		b = BufferDescriptorGetBuffer(buf);old_buf_state = pg_atomic_read_u32(&buf->state);for (;;){// 如果之前有锁,那么需要等待锁释放BM_LOCKED标记位if (old_buf_state & BM_LOCKED)// 注意到这里返回的值,肯定不会包含old_buf_state = WaitBufHdrUnlocked(buf);// 计算出新值buf_state = old_buf_state;buf_state += BUF_REFCOUNT_ONE;// 调用CAS操作,需要注意到 old_buf_state 不会包含BM_LOCKED标记位,所以永远不会更新带有BM_LOCKED标记位的值if (pg_atomic_compare_exchange_u32(&buf->state, &old_buf_state, buf_state)) {break;}}
}

第二种情况适用于获取到了锁,这种情况只是简单的修改state值就行。

// 调用之前已经获取到了锁
static void PinBuffer_Locked(BufferDesc *buf)
{uint32		buf_state;// 获取state值buf_state = pg_atomic_read_u32(&buf->state);Assert(buf_state & BM_LOCKED);// 修改state值buf_state += BUF_REFCOUNT_ONE;UnlockBufHdr(buf, buf_state);
}

pin 操作只是保证了对应的 buffer 不会被替换掉。如果需要保证 buffer 里面的数据并发修改,则需要加入 buffer 的读写锁。buffer 读写锁是BufferDesc的content_lock字段,操作比较简单,这里不再详述。

Buffer IO 锁

为了控制 IO 的同步,postgresql 使用了 buffer_io_lock 锁来控制。因为 IO 操作非常耗时,所以这里就不在使用了乐观锁。

  // IO 锁数组,对应了每个 Buffer
LWLockMinimallyPadded *BufferIOLWLockArray = NULL;
// 直接返回对应位置的IO锁
#define BufferDescriptorGetIOLock(bdesc)  (&(BufferIOLWLockArray[(bdesc)->buf_id]).lock)

总结

postgresql 对于缓存并发控制是非常精细的,它尽量将耗时的操作放在锁外面执行,很大的提高了并发性。还有进一步将不耗时的操作,通过乐观锁的方式实现。这些设计思想值得细细品味,对我们的程序设计会有很大帮助。
作者:zhmin
链接:https://zhmin.github.io/posts/postgresql-buffer-concurrent/
#PG证书#PG考试#postgresql培训#postgresql考试#postgresql认证


http://www.ppmy.cn/ops/131951.html

相关文章

Linux下的ADC

ADC ADC简介 ADC是 Analog Digital Converter 的缩写,翻译过来为模数转换器,ADC可以将模拟值转换成数字值。模拟值是什么呢?比如我们日常生活中的温度,速度,湿度等等都是模拟值。所以如果我们想测量这些模拟值的值是多少&#x…

【华为HCIP实战课程二十九】中间到中间系统协议IS-IS邻居关系建立和LSP详解,网络工程师

一、广播环境邻居关系建立详解 1、广播环境邻居关系建立 广播邻居关系采用三次握手,携带的邻居列表为接口的MAC来标识 2、LSP同步:3种报文(CSNP和PSNP和具体的LSP) CSNP作用类似DBD,请求者发送PSNP(类似LSR)来请求具体的LSP 广播网络LSP交互过程: R1-R2(DIS)--R3…

安装和卸载Mysql(压缩版)

一、安装Mysql 1.解压后在文件夹里创建一个名为“data”的文件夹,再新建一个名为“my.ini”的文件 2.打开“my.ini”文件,编辑,复制以下内容到文件里面(注意,安装目录不能有中文,并且把"\"改为…

[VUE]框架网页开发1 本地开发环境安装

前言 其实你不要看我的文章比较长,但是他就是很长!步骤其实很简单,主要是为新手加了很多解释! 步骤一:下载并安装 Node.js 访问 Node.js 官网: Node.js — Download Node.js 下载 Windows 64 位版本&…

idea | 搭建 SpringBoot 项目之配置 Maven

目录 1 配置 Maven1.1 打开 settings.xml 文件1.2 配置本地仓库路径1.3 配置中央仓库路径1.4 配置 JDK 版本1.5 重新下载项目依赖 2 配置 idea2.1 在启动页打开设置2.2 配置 Java Compiler2.3 配置 File Encodings2.4 配置 Maven2.5 配置 Auto Import2.6 配置 Cod…

第二章 虚拟仪器及其构成原理

目录 第二章 虚拟仪器及其构成原理 第七章 基于VXI总线的虚拟仪器 7.2 VXI总线系统的体系结构 7.3.1 系统和硬件 7.5 PXI总线与PCI性能比较 第二章 虚拟仪器及其构成原理 2.4 虚拟仪器及其系统集成 2.4.1 虚拟仪器硬件组成 接口硬件:根据不同的标…

达梦数据库安全管理

1、概述 数据库安全管理是指采取各种安全措施对数据库及其相关文件和数据进行保护。数据库 系统的重要指标之一是确保系统安全,以各种防范措施防止非授权使用数据库,主要通过数 据库管理系统进行实现。数据库系统中一般采用用户标识与鉴别、存取控制以及…

Hive 操作基础(进阶篇☝️)

Hive 入门操作 创建表 EXTERNAL,创建外部表 PARTITIONED BY, 分区表 CLUSTERED BY,分桶表 STORED AS,存储格式 LOCATION,存储位置 ......, 数据类型 内部表 创建普通内部表: create table [if not exists…