openGauss 数据库源码解析系列文章——事务机制源码解析 (四)
openGauss 数据库源码解析系列文章——事务机制源码解析(四)
四.基于鲲鹏服务器的性能优化
本章着重介绍 openGauss 基于硬件结构的锁相关的函数及结构体的性能优化。
1. WAL Group inset 优化
数据库 redo 日志缓存系统指的是数据库 redo 日志持久化的写缓存,数据库 redo 日志落盘之前会写入到日志缓存中再写到磁盘进行持久化。日志缓存的写入效率是决定数据库整体吞吐量的主要因素,而各个线程之间写日志时为了保证日志顺序写存在锁争抢,锁的争抢就成为了性能的主要瓶颈点。openGauss 针对鲲鹏服务器 ARM CPU 的特点,通过 group 的方式进行日志的插入,减少锁的争抢,提升 WAL 日志的插入效率,从而提升整个数据库的吞吐性能。group 的方式主要流程如图 5 所示。
图 5 group 的方式日志插入
(1) 不需要所有线程都竞争锁。
(2) 在同一时间窗口所有线程在争抢锁之前先加入到一个 group 中,第一个加入 group 的线程作为 leader。通过 CAS 原子操作来实现队列的管理。
(3) leader 线程代表整个 group 去争抢锁。group 中的其他线程(follower)开始睡眠,等待 leader 唤醒。
(4) 争抢到锁后,leader 线程将 group 里的所有线程想要插入的日志遍历一遍得到需要空间总大小。leader 线程只执行一次 reserve space 操作。
(5) leader 线程将 group 中所有线程想要写入的日志都写入到日志缓冲区中。
(6) 释放锁,唤醒所有 follower 线程。
(7) follower 线程由于需要写入的日志已经被 leader 写入,不需要再争抢锁,直接进入后续流程。
关键函数代码如下:
2. Cache align 消除伪共享
CPU 在访问主存时一次会获取整个缓存行的数据,其中 x86 典型值是 64 字节,而 ARM 1620 芯片 L1 和 L2 缓存都是 64 字节,L3 缓存是 128 字节。这种数据获取方式本身可以大大提升数据访问的效率,但是假如同一个缓存行中不同位置的数据频繁被不同的线程读取和写入,由于写入的时候会造成其他 CPU 下的同一个缓存行失效,从而使得 CPU 按照缓存行来获取主存数据的努力不但白费,反而成为性能负担。伪共享就是指这种不同的 CPU 同时访问相同缓存行的不同位置的性能低效的行为。
当前锁逻辑中 LWLock 的访问仍然是最突出的热点之一。如果 LWLOCK_PADDED_SIZE 是 32 字节,且 LWLock 是按照一个连续的数组来存储的,对于 64 字节的缓存行可以同时容纳两个 LWLockPadded,128 字节的缓存行则可以同时含有 4 个 LWLockPadded。当系统中对 LWLock 竞争激烈时,对应的缓存行不停地获取和失效,浪费大量 CPU 资源。故在 ARM 机器的优化下将 padding_size 直接设置为 128,消除伪共享,提升整体 LWLock 的使用性能。
3. WAL INSERT 128CAS 无锁临界区保护
目前数据库或文件系统,WAL 需要把内存中生成的日志信息插入到日志缓存中。为了实现日志高速缓存,日志管理系统会并发插入,通过预留全局位置来完成,一般使用两个 64 位的全局数据位置索引分别表示存储插入的起始和结束位置,最大能提供 16EB(Exabyte)的数据索引的支持。为了保护全局的位置索引,WAL 引入了一个高性能的原子锁实现每个日志缓存位置的保护,在 NUMA 架构中,特别是 ARM 架构中,由于原子锁退避和高跨 CPU 访问延迟,缓存一致性性能差异导致 WAL 并发的缓存保护成为瓶颈。
优化的主要涉及思想是将两个 64 位的全局数据位置信息通过 128 位原子操作替换原子锁,消除原子锁本身在跨 CPU 访问、原子锁退避(backoff)、缓存一致性代价。如图 6 所示。
图 6 128CAS 无锁临界区保护示意图
全局位置信息包括一个 64 位起始地址和一个 64 位的结束地址,将这两个地址合并成为一个 128 位信息,通过 CAS 原子操作完成免锁位置信息的预留。在 ARM 平台中没有实现 128 位的原子操作库,openGauss 通过 exclusive 命令加载两个 ARM64 位数据来实现,ARM64 汇编指令为 LDXP/STXP。
关键数据结构及函数 ReserveXLogInsertLocation 的代码如下:
4. CLOG Partition 优化
CLOG 日志即是事务提交日志(详情可参考章节“事务 ID 分配及 CLOG/CSNLOG)”,每个事务存在 4 种状态:IN_PROGRESS、COMMITED、ABORTED、SUB_COMMITED,每条日志占 2 bit。CLOG 日志需要存储在磁盘上,一个页面(8kB)可以包含 215 条,每个日志文件(段=256 x 8k)226 条。当前 CLOG 的访问通过缓冲池实现,代码中使用统一的 SLRU 缓冲池算法。
图 7 CLOG 的日志缓冲池优化前
图 8 CLOG 的日志缓冲池优化后
如图 7 所示,CLOG 的日志缓冲池在共享内存中且全局唯一,名称为名称为“CLOG Ctl”,为各工作线程共享该资源。在高并发的场景下,该资源的竞争成为性能瓶颈,优化分区后如图 8。按页面号进行取模运算(求两个数相除的余数)将日志均分到多个共享内存的缓冲池中,由线程局部对象的数组 ClogCtlData 来记录,名称为“CLOG Ctl i”,同步增加共享内存中的缓冲池对象及对应的全局锁。也就是通过打散的方式提高整体吞吐。
CLOG 分区优化需要将源代码中涉及原缓冲池的操作进行修改,改为操作对应的分区的缓冲池,而通过事务 id、页面号能方便地找到对应的分区,与此同时对应的控制锁也从原来的一把锁改为多把锁的操作,涉及的结构体代码如下,涉及的函数如表 2 所示:
表 2 CLOG 分区优化函数
5. 支持 NUMA-aware 数据和线程访问分布
NUMA 远端访问:内存访问涉及访问线程和被访问内存两个的物理位置。只有两者在同一个 NUMA Node 中时,内存访问才是本地的,否则就会涉及跨 Node 远端访问,此时性能开销较大。
Numactl 开源软件提供了 libnuma 库允许应用程序方便地将线程绑定在特定的 NUMA Node 或者 CPU 列表,可以在指定的 NUMA Node 上分配内存。下面对 openGauss 代码可能涉及的 api 进行描述。
(1) “int numa_run_on_node(int node);”将当前任务及子任务运行在指定的 Node 上。该 API 对应函数如下所示。
numa_run_on_node****函数在特定节点上运行当前任务及其子任务。在使用 numa_run_on_node_mask 函数重置节点关联之前,这些任务不会迁移到其他节点的 CPU 上。传递-1 让内核再次在所有节点上调度。成功时返回 0;错误-1 时返回,错误码记录在 errno 中。
(2) “void numa_set_localalloc(void);”将调用者线程的内存分配策略设置为本地分配,即优先从本节点进行内存分配。该 API 对应函数如下所示。
numa_set_localalloc****函数 设置调用任务的内存分配策略为本地分配。在此模式下,内存分配的首选节点为内存分配时任务正在执行的节点。
(3) “void numa_alloc_onnode(void);”在指定的 NUMA Node 上申请内存。该 API 对应函数如下所示。
numa_alloc_onnode****函数在特定节点上分配内存。分配大小为系统页的倍数并向上取整。如果指定的节点在外部拒绝此进程,则此调用将失败。与函数系列 Malloc(3)相比,此函数相对较慢。必须使用 numa_free 函数释放内存。错误时返回 NULL。
openGauss 基于 NUMA 架构进行了内部数据结构优化。
1) 全局 PGPROC 数组优化
图 9 全局 PGPROC 数组优化
如图 9 所示,对每个客户端连接系统都会分配一个专门的 PGPROC 结构来维护相关信息。ProcGlobal->allProcs 原本是一个 PGPROC 结构的全局数组,但是其物理内存所在的 NUMA Node 是不确定的,造成每个事务线程访问自己的 PGPROC 结构时,线程可能由于操作系统的调度在多个 NUMA Node 间,而对应的 PGPROC 结构的物理内存位置也是无法预知,大概率会是远端访存。
由于 PGPROC 结构的访问较为频繁,根据 NUMA Node 的个数将这个全局结构数组分为多份,每份分别使用 numa_alloc_onnode 来固定 NUMA Node 分配内存。为了尽量减少对当前代码的结构性改动,将 ProcGlobal->allProcs 由 PGPROC* 改为 PGPROC**。对应所有访问 ProcGlobal->allProcs 的地方均需要做相应调整(多了一层间接指针引用)。相关代码如下:
2) 全局 WALInsertLock 数组优化
WALInsertLock 用来对 WAL Insert 操作进行并发保护,可以配置多个,比如 16。优化前,所有的 WALInsertLock 都在同一个全局数组,并通过共享内存进行分配。事务线程运行时在整个全局数组中分配其中的一个 Insert Lock 进行使用,因此大概率会涉及远端访存,即多个线程会进行跨 Node、跨 P 竞争。WALInsertLock 也可以按 NUMA Node 单独分配内存,并且每个事务线程仅使用本 Node 分组内的 WALInsertLock,这样就可以将数据竞争限定在同一个 NUMA Node 内部。基本原理如图 10 所示。
图 10 全局 WALInsertLock 数组优化原理
假如系统配置了 16 个 WALInsertLock,同时 NUMA Node 配置为 4 个,则原本长度为 16 的数组将会被拆分为 4 个数组,每个数组长度为 4。全局结构体为“WALInsertLockPadded **GlobalWALInsertLocks”,线程本地 WALInsertLocks 将由指向本 Node 内的 WALInsertLock[4],不同的 NUMA Node 下拥有不同地址的 WALInsertLock 子数组。GlobalWALInsertLocks 则用于跟踪多个 Node 下的 WALInsertLock 数组,以方便遍历。WALInsertLock 分组方式如图 11 所示。
图 11 WALInsertLock 分组示意图
ARM 平台下,访问 WALInsertLock 需遍历 GlobalWALInsertLocks 两维数组,第一层遍历 NUMA Node,第二层遍历 Node 内部的 WALInsertLock 数组。
WALInsertLock 引用的 LWLock 内存结构在 ARM 平台下也进行的相应的优化适配
这里的 lock 成员变量将引用共享内存中的全局 LWLock 数组中的某个元素,在 WALInsertLock 优化之后,尽管 WALInsertLock 已经按照 NUMA Node 分布了,但是其引用的 LWLock 却无法控制其物理内存位置,因此在访问 WALInsertLock 的 lock 时仍然涉及了大量的跨 Node 竞争。因此将 LWLock 直接嵌入到 WALInsertLock 内部,从而将使用的 LWLock 一起进行 NUMA 分布,同时还减少了一次缓存访问。
基于鲲鹏服务器的性能优化
本章着重介绍 openGauss 基于硬件结构的锁相关的函数及结构体的性能优化。
1. WAL Group inset 优化
数据库 redo 日志缓存系统指的是数据库 redo 日志持久化的写缓存,数据库 redo 日志落盘之前会写入到日志缓存中再写到磁盘进行持久化。日志缓存的写入效率是决定数据库整体吞吐量的主要因素,而各个线程之间写日志时为了保证日志顺序写存在锁争抢,锁的争抢就成为了性能的主要瓶颈点。openGauss 针对鲲鹏服务器 ARM CPU 的特点,通过 group 的方式进行日志的插入,减少锁的争抢,提升 WAL 日志的插入效率,从而提升整个数据库的吞吐性能。group 的方式主要流程如图 5 所示。
图 5 group 的方式日志插入
(1) 不需要所有线程都竞争锁。
(2) 在同一时间窗口所有线程在争抢锁之前先加入到一个 group 中,第一个加入 group 的线程作为 leader。通过 CAS 原子操作来实现队列的管理。
(3) leader 线程代表整个 group 去争抢锁。group 中的其他线程(follower)开始睡眠,等待 leader 唤醒。
(4) 争抢到锁后,leader 线程将 group 里的所有线程想要插入的日志遍历一遍得到需要空间总大小。leader 线程只执行一次 reserve space 操作。
(5) leader 线程将 group 中所有线程想要写入的日志都写入到日志缓冲区中。
(6) 释放锁,唤醒所有 follower 线程。
(7) follower 线程由于需要写入的日志已经被 leader 写入,不需要再争抢锁,直接进入后续流程。
2. Cache align 消除伪共享
CPU 在访问主存时一次会获取整个缓存行的数据,其中 x86 典型值是 64 字节,而 ARM 1620 芯片 L1 和 L2 缓存都是 64 字节,L3 缓存是 128 字节。这种数据获取方式本身可以大大提升数据访问的效率,但是假如同一个缓存行中不同位置的数据频繁被不同的线程读取和写入,由于写入的时候会造成其他 CPU 下的同一个缓存行失效,从而使得 CPU 按照缓存行来获取主存数据的努力不但白费,反而成为性能负担。伪共享就是指这种不同的 CPU 同时访问相同缓存行的不同位置的性能低效的行为。
当前锁逻辑中 LWLock 的访问仍然是最突出的热点之一。如果 LWLOCK_PADDED_SIZE 是 32 字节,且 LWLock 是按照一个连续的数组来存储的,对于 64 字节的缓存行可以同时容纳两个 LWLockPadded,128 字节的缓存行则可以同时含有 4 个 LWLockPadded。当系统中对 LWLock 竞争激烈时,对应的缓存行不停地获取和失效,浪费大量 CPU 资源。故在 ARM 机器的优化下将 padding_size 直接设置为 128,消除伪共享,提升整体 LWLock 的使用性能。
3. WAL INSERT 128CAS 无锁临界区保护
目前数据库或文件系统,WAL 需要把内存中生成的日志信息插入到日志缓存中。为了实现日志高速缓存,日志管理系统会并发插入,通过预留全局位置来完成,一般使用两个 64 位的全局数据位置索引分别表示存储插入的起始和结束位置,最大能提供 16EB(Exabyte)的数据索引的支持。为了保护全局的位置索引,WAL 引入了一个高性能的原子锁实现每个日志缓存位置的保护,在 NUMA 架构中,特别是 ARM 架构中,由于原子锁退避和高跨 CPU 访问延迟,缓存一致性性能差异导致 WAL 并发的缓存保护成为瓶颈。
优化的主要涉及思想是将两个 64 位的全局数据位置信息通过 128 位原子操作替换原子锁,消除原子锁本身在跨 CPU 访问、原子锁退避(backoff)、缓存一致性代价。如图 6 所示。
图 6 128CAS 无锁临界区保护示意图
全局位置信息包括一个 64 位起始地址和一个 64 位的结束地址,将这两个地址合并成为一个 128 位信息,通过 CAS 原子操作完成免锁位置信息的预留。在 ARM 平台中没有实现 128 位的原子操作库,openGauss 通过 exclusive 命令加载两个 ARM64 位数据来实现,ARM64 汇编指令为 LDXP/STXP。
4. CLOG Partition 优化
CLOG 日志即是事务提交日志(详情可参考章节“事务 ID 分配及 CLOG/CSNLOG)”,每个事务存在 4 种状态:IN_PROGRESS、COMMITED、ABORTED、SUB_COMMITED,每条日志占 2 bit。CLOG 日志需要存储在磁盘上,一个页面(8kB)可以包含 215 条,每个日志文件(段=256 x 8k)226 条。当前 CLOG 的访问通过缓冲池实现,代码中使用统一的 SLRU 缓冲池算法。
图 7 CLOG 的日志缓冲池优化前
图 8 CLOG 的日志缓冲池优化后
如图 7 所示,CLOG 的日志缓冲池在共享内存中且全局唯一,名称为名称为“CLOG Ctl”,为各工作线程共享该资源。在高并发的场景下,该资源的竞争成为性能瓶颈,优化分区后如图 8。按页面号进行取模运算(求两个数相除的余数)将日志均分到多个共享内存的缓冲池中,由线程局部对象的数组 ClogCtlData 来记录,名称为“CLOG Ctl i”,同步增加共享内存中的缓冲池对象及对应的全局锁。也就是通过打散的方式提高整体吞吐。
CLOG 分区优化需要将源代码中涉及原缓冲池的操作进行修改,改为操作对应的分区的缓冲池,而通过事务 id、页面号能方便地找到对应的分区,与此同时对应的控制锁也从原来的一把锁改为多把锁的操作。
表 2 CLOG 分区优化函数
5. 支持 NUMA-aware 数据和线程访问分布
NUMA 远端访问:内存访问涉及访问线程和被访问内存两个的物理位置。只有两者在同一个 NUMA Node 中时,内存访问才是本地的,否则就会涉及跨 Node 远端访问,此时性能开销较大。
Numactl 开源软件提供了 libnuma 库允许应用程序方便地将线程绑定在特定的 NUMA Node 或者 CPU 列表,可以在指定的 NUMA Node 上分配内存。下面对 openGauss 代码可能涉及的 api 进行描述。
(1) “int numa_run_on_node(int node);”将当前任务及子任务运行在指定的 Node 上。该 API 对应函数如下所示。
numa_run_on_node****函数在特定节点上运行当前任务及其子任务。在使用 numa_run_on_node_mask 函数重置节点关联之前,这些任务不会迁移到其他节点的 CPU 上。传递-1 让内核再次在所有节点上调度。成功时返回 0;错误-1 时返回,错误码记录在 errno 中。
(2) “void numa_set_localalloc(void);”将调用者线程的内存分配策略设置为本地分配,即优先从本节点进行内存分配。该 API 对应函数如下所示。
numa_set_localalloc****函数 设置调用任务的内存分配策略为本地分配。在此模式下,内存分配的首选节点为内存分配时任务正在执行的节点。
(3) “void numa_alloc_onnode(void);”在指定的 NUMA Node 上申请内存。该 API 对应函数如下所示。
numa_alloc_onnode****函数在特定节点上分配内存。分配大小为系统页的倍数并向上取整。如果指定的节点在外部拒绝此进程,则此调用将失败。与函数系列 Malloc(3)相比,此函数相对较慢。必须使用 numa_free 函数释放内存。错误时返回 NULL。
openGauss 基于 NUMA 架构进行了内部数据结构优化。
1) 全局 PGPROC 数组优化
图 9 全局 PGPROC 数组优化
如图 9 所示,对每个客户端连接系统都会分配一个专门的 PGPROC 结构来维护相关信息。ProcGlobal->allProcs 原本是一个 PGPROC 结构的全局数组,但是其物理内存所在的 NUMA Node 是不确定的,造成每个事务线程访问自己的 PGPROC 结构时,线程可能由于操作系统的调度在多个 NUMA Node 间,而对应的 PGPROC 结构的物理内存位置也是无法预知,大概率会是远端访存。
由于 PGPROC 结构的访问较为频繁,根据 NUMA Node 的个数将这个全局结构数组分为多份,每份分别使用 numa_alloc_onnode 来固定 NUMA Node 分配内存。为了尽量减少对当前代码的结构性改动,将 ProcGlobal->allProcs 由 PGPROC* 改为 PGPROC**。对应所有访问 ProcGlobal->allProcs 的地方均需要做相应调整(多了一层间接指针引用)。**
2) 全局 WALInsertLock 数组优化**
WALInsertLock 用来对 WAL Insert 操作进行并发保护,可以配置多个,比如 16。优化前,所有的 WALInsertLock 都在同一个全局数组,并通过共享内存进行分配。事务线程运行时在整个全局数组中分配其中的一个 Insert Lock 进行使用,因此大概率会涉及远端访存,即多个线程会进行跨 Node、跨 P 竞争。WALInsertLock 也可以按 NUMA Node 单独分配内存,并且每个事务线程仅使用本 Node 分组内的 WALInsertLock,这样就可以将数据竞争限定在同一个 NUMA Node 内部。基本原理如图 10 所示。
图 10 全局 WALInsertLock 数组优化原理
假如系统配置了 16 个 WALInsertLock,同时 NUMA Node 配置为 4 个,则原本长度为 16 的数组将会被拆分为 4 个数组,每个数组长度为 4。全局结构体为“WALInsertLockPadded **GlobalWALInsertLocks”,线程本地 WALInsertLocks 将由指向本 Node 内的 WALInsertLock[4],不同的 NUMA Node 下拥有不同地址的 WALInsertLock 子数组。GlobalWALInsertLocks 则用于跟踪多个 Node 下的 WALInsertLock 数组,以方便遍历。WALInsertLock 分组方式如图 11 所示。
图 11 WALInsertLock 分组示意图
在 ARM 平台下,访问 WALInsertLock 需遍历 GlobalWALInsertLocks 两维数组,第一层遍历 NUMA Node,第二层遍历 Node 内部的 WALInsertLock 数组。
评论