跳转至

缓冲池

Project_1_rk

Project #1 Buffer Pool Manager

Buffer Pool Manager 的常见中文翻译有 “缓冲池”、“内存池” 等。

磁盘数据库需要处理庞大的数据(通常是内存的很多倍),因此任意时刻只能有一部分数据在内存中。而 Buffer Pool Manager 就是负责管理这些内存的。

值得一提的是,Andy 在课上花了很多的时间介绍了操作系统的 mmap(虚拟内存) 技术,并且强调绝对不要在数据库设计中使用 mmap。在我看来有以下几点理由:

  1. 事务安全:操作系统会在任意时刻将虚拟内存的页面出页,一旦出页就会把脏页刷回磁盘,但是 DBMS 的事务执行失败时会涉及到回滚,那么本该回滚的数据被刷回了磁盘就非常棘手。
  2. 性能问题:DBMS 自己管理内存可以对数据页进行更加精细的管控,举个例子,针对 DBMS 的应用场景,可以使用更优秀的页面置换算法,为不同的页面、不同的操作类型规定停留在缓冲池的时间长短。而如果使用 mmap ,一方面其他程序的也会挤占 DBMS 可用的内存大小,另一方面,mmap 的页面置换算法通常实现为 LRU,并不适合 DBMS 的场景,从而导致性能问题(后面会介绍一个 LRU 的例子)。
  3. IO 开销。mmap 什么时候把内存写回磁盘?mmap 很可能在一次写页面的请求中将这个页面多次出页刷回磁盘,造成额外的IO开销。另外,mmap 到底开多少个线程来 IO 数据?如果 DBMS 使用了 mmap 作为缓冲池,就面临着和其他进程抢 mmap IO 线程 的问题。

  4. 错误处理,mmap 是有可能失败的,如果 DBMS 使用了 mmap 作为缓冲池,就不得不处理 SIGBUS 信号。

Andy 在课上还说了一句印象深刻的话:

如果我死了,并且我的墓碑上只能写一句话,我会写“永远不要在 DBMS 中使用 mmap”。


虽然一部分冷门的 DMBS 使用了 mmap,但绝大多数 DBMS 自己管理缓冲池。

我们定义页面为数据在内存和磁盘之间 IO 的基本单位,一个文件由若干个页面组成。在 Postgres 中默认页面大小为 8KBMySQL 的默认页面大小为 16KB

缓冲池实际就是一个数组,数组中每个位置可容纳一个页面。

接下来介绍缓冲池的三个重要部分:

页面置换

后端请求一个页面有三种情况:

  • 页面在缓冲池中,此时直接返回缓冲池中对应的地址。
  • 页面不在缓冲池中,但此时缓冲池拥有空闲的空间(空闲是指缓冲池数组中某个没有实际页面数据的位置),则在这个位置读入请求页面,然后返回对应的地址。
  • 页面不在缓冲池中,此时缓冲池已满,那么我们需要置换一个页面,被置换出去的页面如果脏了就需要写回磁盘。然后,在这个位置上,我们读入页面数据,最后返回对应的地址。

本节讨论的是第三种情况所使用的页面置换算法,操作系统的页面置换算法有 LRU、FIFO、LFU 等,其中最常见的就是 LRU。

LRU 算法虽然通用,但在 DBMS 中存在一些问题,例如,一次全表扫描会将缓冲池的所有页面都替换出去,进而导致缓冲池中都是全表扫描的页面,原先的热点页面都没了,这是不可接受的。通常称这个问题为页面污染,也就是不正确的页面污染了缓冲池。

针对 LRU 的缺陷,大多数 DBMS 都对其进行改进。普通的 LRU 仅仅考虑上次命中页面的时间,而没有考虑页面命中了多少次,就会出现

Project 1 选用的页面置换算法为 LRU-KLRU-K 额外记录了每个页面(在缓冲池期间)命中的次数。根据次数是否达到了 K 次,将页面分为两个表,其中大于等于 K 次的部分称为热点表,热点表按照 LRU 维护,不足 K 次的部分称为普通表,普通表按照 FIFO 进行维护。在置换页面时,优先从普通表中选择。

  • 普通的 LRU 实际就是 LRU-1 的特化,由于此时 K 为 \(1\),相当于只有热点表,而没有普通表。
  • MySQL 中采用的是一种类似 LRU-2 的特化方案,除了考虑次数以外,MySQL 还会考虑页面在缓冲池中待了多长时间,只有超过一定阈值的页面才能从普通表升级到热点表。
  • Postgres 采用的页面置换算法是 clock sweep 算法,该算法同样记录页面命中的次数,同时维护一个指针,这个指针就像钟表的指针一样不停的转动,来寻找第一个命中次数为 \(0\) 的页面,将其置换。如果一个页面次数不为 \(0\),则其次数减少 \(1\),然后指针向后移动继续寻找。

IO 调度

缓冲池需要只在必要时才进行 IO 的调度,具体来说,对于后端的页面请求,我们需要保证在后端释放页面之前页面处于“钉住”(Pinned),被钉住的页面是无论如何都不能被置换的。

什么时候才必须执行 IO?

  1. 被置换的页面是脏页面。我们知道被置换的页面一定是没有被后端钉住的页面,但这个页面曾经可能被后端改写过,因而成为脏页面。所以在被置换出去前,必须将脏页面数据写回磁盘。
  2. 新读入页面时,需要从磁盘将页面读到缓冲池中。

显然,对于情况 1,我们会向 IO 调度发起一次写数据的请求,对于情况 2 则是发起一次读数据的请求。IO 调度就是负责接收 IO 请求,和文件进行读写交互的部分。

在 IO 调度的优化方面,为了吃满磁盘 IO 的性能,IO 调度需要支持并发的处理 IO 请求。在并发编程时,需要考虑读写数据的一致性,保证多线程 IO 调度和单线程 IO 调度拥有一致的结果。

此外,大多数 DBMS 会在磁盘 IO 负载较低时,在后台自动将缓冲池的脏页面写回,提高页面置换的速度。

缓冲池设计

回顾一下后端请求页面的三种情况:

  • 页面在缓冲池中,此时直接返回缓冲池中对应的地址。
  • 页面不在缓冲池中,但此时缓冲池拥有空闲的空间(空闲是指缓冲池数组中某个没有实际页面数据的位置),则在这个位置读入请求页面,然后返回对应的地址。
  • 页面不在缓冲池中,此时缓冲池已满,那么我们需要置换一个页面,被置换出去的页面如果脏了就需要写回磁盘。然后,在这个位置上,我们读入页面数据,最后返回对应的地址。

我们需要知道某个页面是否在缓冲池中,显然,缓冲池还需要维护一个哈希表,建立页面编号到已有缓冲池的映射。对于后端的页面请求,查找哈希表就能得到页面是否在缓冲池中。

除了哈希表之外,缓冲池还拥有一个巨大的页面数组,在初始化时所有的位置都是空闲位置,再加上刚刚提到的页面置换以及 IO 调度的组件,缓冲池就设计完成了。

缓冲池最终需要保证交付给后端可用的页面(内存空间),允许后端对页面进行读写。

实现过程记录

大概花了两个星期实现了缓冲池以及优化,最终在课程的 bench-mark 中取得了排名第 1 的成绩,系统基本的功能是非常简单的,只要能梳理出缓冲池的实现即可实现最简单的单线程版本。

我大概用了 \(2\) 天的时间完成缓冲池的基础版本,随后的时间都花在了优化、重构、调试上。

在我对缓冲池的实现以及优化过程中,有比较重要的几个节点:

  • 第一个单线程可用的基础版本,花了 \(2\) 天实现,在排行榜中排大概200多位。
  • 对单线程版本进行重构,思考每个组件是否有更优的实现(从数据结构、C++ 开销、更简洁的逻辑等角度),这个版本耗费 \(2\) 天,最终在保持单线程不变的情况下,性能排名达到 \(70\) 位左右(是的,即使只用单线程,仍然能达到这个排名)
  • 在确保单线程程序优化的差不多的时,我开始将缓冲池改为多线程,这个版本耗费了大概 \(10\) 天左右,不断的重构、优化、调试确实非常麻烦,经过几次迭代,排名持续上涨,最后终于冲上了排行榜的第 \(1\)

附上排名(截图于2024.05.30)

Project_1_rk

优化建议

基于本课程的 Collaboration Policy,我不会在本文公布源码以及我具体做了哪些优化,仅仅给出想要对缓冲池优化的建议,以及前期的准备。

  1. 首先,请考虑官方给出的优化建议,在此,我附上简短的翻译:

    1. 考虑读取页面的类型,即:默认、扫描、概率分布、索引四种类型的请求。
    2. 允许并发的进行 IO 调度,并保证数据的一致性。
    3. 允许并发在缓冲池中的读取页面。
    4. 不要使用投机取巧的办法。例如,试图突破缓冲池大小的限制,将所有数据都读到缓冲池中。(这一条更像是一种禁止规则)
    5. 在必要时使用无锁队列以及自己的 std::promise 实现。
  2. 如果你不会 C++ 并发编程,请参阅 《C++并发编程实战(第2版)》,这本书内容量很大。请带着自己的思考去读(同时进行必要的实验),更重要的是带着“优化性能”的目的去读。

  3. 针对并发性能的提升,无非就是逐步减小锁的粒度,换取更高的并发量。因此,请务必问自己一些问题:
    • 哪些地方必须加锁?证明?
    • 哪些地方没必要加锁?
    • 哪些地方不加锁会发生冲突?
    • 冲突能否解决?如果能解决,代价是什么?
    • \(\cdots\cdots\)

为什么会想优化到排名第 \(1\) ?并非刻意的攀比,只是想将所学的并发编程应用于实际的场景。在完成第一个单线程版本之后,我知道还可以用多线程进一步优化,所以我列了一些 TODO,每一个 TODO 对应一个优化方式,我做的也仅仅是(按照性能提升的优先级)完成了这些 TODO 而已。

在优化的过程中免不了碰到高并发环境下的问题,而并发调试是非常痛苦的(尤其是在这个缓冲池的 bench 中,当吞吐量高到一定程度之后,甚至连打日志都会影响 bug 的复现),中间一度想过放弃,最终还是坚持下来了,同时积累了一些并发编程调试的经验。事实上,这种逐步提高性能带给我的反馈是很足的,这也是我能坚持下去的原因。


最后更新: 2024-06-01
创建日期: 2024-06-01