lyorz's blog

Lec6 内存 & 磁盘I/O管理

2026/02/05
loading

如何实现一个Buffer Pool Manager。

Buffer Pool

Organization

内存区域组织为一个固定页大小(等同数据库页面大小)的数组,其中每个元素称为帧(frame)。

当上层请求访问一个页面时,从磁盘读取该页面后,将在这些frame中保留一个该页面的副本。

如果对副本做出修改,则称其为脏页(dirty page),脏页回写磁盘往往不是立刻执行的。

Meta-Data

页表:负责跟踪当前内存中的页面,主要维护page ID与frame的映射关系,通常实现为线程安全的固定大小哈希表。

如图所示,应当使用latch来确保写入页表时线程安全。

其他元数据:

  • Dirty Flag:标记脏页;
  • Pin/Reference Counter:统计多少工作线程正在访问该页面;
  • Access Tracking Infomation:上次/近几次访问该页面的时间,可以通过该信息在内存不够用时驱逐页面。

~有关latch与lock:~

为了和数据库中锁(lock)的概念用作区分,使用latch来称呼buffer pool中用到的闩锁。latch更接近操作系统中的互斥锁,用于保护切实的数据结构,且无需保证回滚;而lock则是在数据库并行事务时对逻辑数据的保护,需要能够支持回滚。

~有关page table与page directory~

二者在数据库运行期间都存在于内存中。page directory是page id和数据库文件中page磁盘物理位置的映射关系,其所有变更必须落盘确保重启后能够恢复;page table维护的是page id和内存中页面副本之间的映射关系,无需落盘。

why not use mmap

mmap

mmap 将文件内容映射到进程虚拟地址空间,建立“虚拟页 → 文件偏移”的映射关系,实际物理页由操作系统按需从磁盘加载到 page cache。

mmap使用懒加载,具体而言,mmap首先给予用户可用的起始和终止虚拟内存地址,当用户通过虚拟内存访问某页面时,首先发现虚拟页没有映射到物理页,触发缺页中断(page fault),内存检查该页是否已经在page cache中,如果不在则从磁盘读取并建立映射。

假设继续这样操作,直到物理内存已满,无法加载新的页面,触发页面置换。操作系统将中断发起请求的线程,执行页面置换算法淘汰部分页面并加载新页。

problems of mmap

假设我们使用mmap进行内存管理,会存在哪些问题:

  1. 事务安全:当一次事务中涉及多个页面的修改,按照事务逻辑,其中一个页面发生错误则所有修改回滚。在os中,由于os将自己决定刷新脏页的时机,可能是任何时机,因此可能在事务执行过程中将修改后脏页刷盘,导致其他页面发生错误时无法正确进行回滚;
  2. I/O停顿:由于数据库不知道内存中到底存在哪些页面,因此会导致查询不可预知的停顿;
  3. 错误处理:mmap 错误以信号形式暴露(SIGBUS/SIGSEGV),不利于数据库进行细粒度错误恢复;
  4. 性能问题:操作系统使用mutex维护自己的页表,但性能表现并不好。(为啥不好)

solutions

可以通过一些系统调用解决上面说到的问题:

  1. madvise:可以向操作系统提供页面访问模式提示(如顺序访问、随机访问、即将访问);
  2. mlock:锁定页在物理内存,防止被 swap out;
  3. msync:可强制将指定映射页写回磁盘。

Buffer Replacement Policies

Least-Recently Used (LRU)

记录每个页面上次被访问的时间,当需要淘汰页面时,选择上次被访问时间距今最久的一个。

我们可以通过按照访问时间排序,以便在淘汰页面时降低检索时间。

但这要求为每个页面访问维护一个时间戳。

Clock

LRU的近似算法,但无需为每个页面维护上次访问时间戳:

  • 为每个page保留一个比特位,该比特位用于表示自上次扫描后,页面是否被访问过;
  • 当页面被访问时,该比特位设为1;

Clock算法以环形缓冲区组织页面,并在运行时按序对各个页面进行扫描:

  • 当指针扫描到某页面时,检查其比特位是否被设为1:
    • 如果是,则将其设为0,扫描下一个页面;
    • 如果不是,淘汰该页面。

极端情况:假设多轮循环扫描都无法淘汰任何页面,可以考虑返回内存不足的错误,因为这表明当前内存中页面都在被积极使用。

Problem of LRU & Clock:Sequential Flooding

LRU + Clock算法容易受到顺序泛洪(sequential flooding)的影响:

  • sequential flooding:顺序扫描查询将大量只访问一次的页面加载进入内存(例如nested join)从而导致真正的热点页被淘汰。

LRU和Clock这种基于最近访问的淘汰策略,容易受到访问模式污染,有时真正的热点页面可能并非最近访问的页面。

LRU-K

为每个页面保留最近K次访问时间,定义Backward K-distance = current timestamp - kth last access timestemp,并淘汰Backward K-distance最大的页面。

MySQL Approximate LRU-K

数据结构:LRU链表,但存在两个Head节点,从而将LRU链表分割为两部分,一个为Old List,一个为Young List。

  • 新页面初次访问时,总是插入Old List的头部;
  • 如果Old List中的页面被再次访问,则插入Young List的头部。

本质上是近似了LRU-2,对每个页面记录其上次访问时间,并标记当前位于Old List / Young List。

Adaptive Replacement Cache (ARC)

核心思想:ARC将缓存中的页面分为最近访问和最常访问两类,并且自动根据访问模式动态调整两类比例。

ARC维护四个队列:

  • 实际缓存:
    • T1-Recent:页面刚进入缓存,只命中一次。
    • T2-Frequent:命中次数$\ge$两次。
  • 幽灵缓存:只存页面ID,不存实际数据
    • B1:T1中被淘汰的历史记录。
    • B2:T2中被淘汰的历史记录。

ARC工作流程
页面访问请求到达:

  • case1:命中T1或T2,将页面移动到T2,表示被多次访问。
  • case2:命中B1,说明最近被访问页面被淘汰的太多,因此需要扩大T1容量,缩减T2容量。
  • case3:命中B2,说明最近高频页被淘汰的太多,因此需要扩大T2容量,缩减T1容量。

上述T1和T2的容量动态调整通过ARC的自适应参数p实现,在ARC内部维护一个目标大小参数p,表示这是T1的目标大小,系统运行过程中根据B1和B2的运行状况增减参数p。

当需要页面置换时,将根据目标大小参数和T1的实际大小来决定

  • case1:$|T1| > p$,说明最近访问队列超标,优先从T1的LRU淘汰。
  • case2:$|T1| \le p$,说明最常访问队列超标,优先从T2的LERU淘汰。

Localization

核心思想:不要让一个查询弄脏整个buffer pool,而是让每个查询只影响到自己的一小块缓存。

实际上pg是这样的做法,为每个顺序扫描查询(例如nested join)分配一个小的环形缓冲区,而非整个buffer pool。当该环形缓冲区满,新的数据页将覆盖该小环中的旧页,而不影响全区属于其他查询的缓存。

Priority Hints

给予替换策略一定提示,告知某page是否重要。

Dirty Pages

关于脏页处理:

  • 若buffer pool中某页面是干净的,则可以直接drop掉;
  • 若为脏页,则必须落盘后才能drop掉该页面。

此时需要在快速页面淘汰和脏页落盘之间进行权衡。

Backgroud Writing:令工作线程定期查看buffer pool,找到其中的脏页并刷入磁盘,写入完成后可以标记页面为clean,在下次置换时能够快速drop。

Disk I/O Scheduling

DBMS维护内部队列来跟踪整个系统的读写请求,请求的优先级将基于一些因素计算决定:

  • 顺序/随机磁盘读写;
  • 关键任务/后台任务;
  • 。。。

OS Page Cache

OS Page Cache 是由操作系统维护的文件数据缓存,对所有进程共享,用于缓存从磁盘读取的文件页面。

当 DBMS 通过 read() 发起文件读取请求时:

  • 内核首先在 page cache 中查找目标页

  • 若命中,则直接从 page cache 拷贝到用户态缓冲区

  • 若未命中,则触发磁盘 IO,将数据读入 page cache

  • 再通过 copy_to_user 复制到用户态返回

从上述过程,可以看到read系统调用下发后,将产生两次文件的拷贝。

因此大多数DBMS选择使用直接I/O,从而跳过OS的page cache,避免冗余副本以及受到OS缓存区淘汰策略的影响。

冷知识:pg依赖于OS的page cache。

Problems of fsync

fsync 依赖操作系统与存储设备的正确写回语义,但存在若干问题:

  • 设备写缓存问题:某些设备会将数据写入设备缓存后立即报告成功,若未开启 write-through / barrier,断电可能丢失数据。

  • 写回错误传播复杂:写回错误可能只在首次 fsync 时报告,后续 fsync 不一定再次报告同一错误,

Buffer Pool Optimizations

Multiple Buffer Pools

允许系统中存在多个而非唯一Buffer Pool,例如每个database/每个page一个buffer pool。

此时内存分配仅在各个buffer pool之间进行分配,能够降低临界区争用,并提升内存局部性。

那么如何决定某个页面归属哪一个buffer pool:

  • 使用object id,维护从object id至buffer pool的映射;
  • 使用hash,将page id散列到不同的buffer pool。

Pre-Fetching

基于query plan识别出未来需要访问的页面,并预先读取到内存。

例如:顺序扫描,在加载page0的同时可以加载page1,page2…,因为这都是未来需要的页面。

Scan Sharing

Scan Sharing 是一种顺序扫描优化技术,允许多个对同一对象进行全表扫描的查询共享一次物理扫描过程,从而减少重复 I/O 与缓冲池污染。

例如,Q1对table A执行聚合求和计算,从page0开始扫描,到达page3的时候,由于内存页满,page0被替换为page3。

此时,另一个线程发起Q2查询,对table A进行聚合均值计算,同样也需要全量扫描table A。如果Q2仍然从page0开始,那么在我们的例子中随着Q1向下读取、替换,Q2又会不断将置换出的页面重新读回内存,造成重复IO。

scan sharing就是当系统察觉到Q1和Q2实际上要访问的是完全相同的页面,因此允许Q1和Q2共享扫描流,在我们的例子中就是Q2加入Q1当前的扫描位置:

一直到最后一页page5,Q1扫描完毕,Q2将再次回到page0扫描最开始跳过的三个页面:

Buffer Pool Bypass

顺序扫描算子可绕过全局 buffer pool,将读取页面仅保存在查询私有内存中,读取后即丢弃,避免污染共享缓存。

该策略适用于:

  • 大范围顺序扫描

  • 磁盘连续页面读取

  • 临时数据处理(排序、join中间结果)

Informix 中称为 Light Scan。

CATALOG
  1. 1. Buffer Pool
    1. 1.1. Organization
    2. 1.2. Meta-Data
  2. 2. why not use mmap
    1. 2.1. mmap
    2. 2.2. problems of mmap
    3. 2.3. solutions
  3. 3. Buffer Replacement Policies
    1. 3.1. Least-Recently Used (LRU)
    2. 3.2. Clock
    3. 3.3. Problem of LRU & Clock:Sequential Flooding
    4. 3.4. LRU-K
    5. 3.5. MySQL Approximate LRU-K
    6. 3.6. Adaptive Replacement Cache (ARC)
    7. 3.7. Localization
    8. 3.8. Priority Hints
    9. 3.9. Dirty Pages
  4. 4. Disk I/O Scheduling
    1. 4.1. OS Page Cache
    2. 4.2. Problems of fsync
  5. 5. Buffer Pool Optimizations
    1. 5.1. Multiple Buffer Pools
    2. 5.2. Pre-Fetching
    3. 5.3. Scan Sharing
    4. 5.4. Buffer Pool Bypass