Skip to content

Memory Management & Buffer Pools

Database Storage

Spatial Control

跟踪被写入磁盘的数据的位置,尽量让他们存放在连续的位置,以方便读取的时候可以读取连续的数据,尤其是机械硬盘,提升十分显著。

Temporal Control

考虑什么时候将数据页读入内存,什么时候将脏页写回磁盘,尽可能预测需要被读取的页读入内存中,延迟脏页写回磁盘的时间,以减少 I/O 的次数。

Buffer Pool Meta-Data

实际上 Buffer Pool 也是有一个 Page table 在 Buffer Pool 的内存的前面,它用于查找内存中或不存在内存中的页面,以及检查它们是否在内存中。
类似于哈希表, Page Table 存储 Page ID ,当需要使用某个 Page 时,通过 Page ID 判断 Page 是否存在 Buffer Pool 中,如果存在,在哪个位置。

Page Table 中还会存储一些 meta-data ,例如 Dirty Flag, Pin/Reference Counter, Access Tracking Information

Locks vs. Latches

Locks 保护逻辑数据,服务于事务;Latches 保护物理数据结构,服务于线程。

Locks 由 Lock Manager 提供, Latches 由数据库开发者提供。

Locks 持续时间长,有回滚,Latches 持续时间短,没有回滚。

Page Table vs. Page Directory

Page table 是 Buffer Pool 在内存中用于寻找 Page 的,它不需要被存储在磁盘中。
Page Directory 是 DBMS 在磁盘中用于寻找 Page 的,它需要被存储在磁盘中。

Memory Mapped I/O Problems

使用 mmap 会导致的问题:

  1. Transaction Safety
    • OS 有可能在任何时候将脏页写回磁盘中
  2. I/O Stalls
    • DBMS 不知道 Pages 是否在内存中,如果不在内存中,会出现 Page Fault Stalls
  3. Error Handing
    • OS 不能校验 Page 是否正确,而当出现读取文件出错时,OS 会无法处理错误,默认情况下, OS 会直接 kill 数据库服务
  4. Performance Issues
    • OS 数据结构竞争,多核 CPU 频繁的争抢内核锁,会导致性能下降,频繁的页面换入换出会导致 TLB Shootdown

Buffer Replacement Policies

当 DBMS 需要释放一些 Buffer Pool 中的 Page 时,会使用缓存替换策略。

缓存替换策略至少需要满足:正确性、准确性、速度、内存开销

LRU: Least-Recently Used (1965)

LRU 算法的基本思想:只需要根据上次访问的时间来跟踪内存中所有 Page 的 Timestamp ,在需要释放 Page 时,直接释放上次访问时间是最早的 Page ,而每次访问 Page 时,只需要更新 timestamp 。

LRU 存在一个严重的问题 Sequential Flooding ,当使用一次全表扫描,它会顺序读取大量页面。这些页面都只被访问一次,但它们会把 LRU 缓存中那些真正“热门”的、被频繁访问的 Page 全部冲刷出去。

Clock (Second-Chance) (1969)

Clock 让每个 Page 都有一个简单的 reference bit ,通常只有一个 bit ,然后维护一个环形数组,有一个“时钟指针”指向它,当需要释放页面的时候,查看指针指向的 Frame ,当 Frame 里面的 bit 为 1 时,将它设置为 0 ,当 Frame 里面的 bit 为 0 时,将它释放。每次使用 Page 时,将 bit 设置为 1 。

通俗易懂的说,就是在我上次检查之后,找到一个没有被访问过的 Page 。
在 Linux 的内部,页表也是使用的这个算法。

Clock 算法是一个近似 LRU 的算法,它避免了为每次访问都更新时间戳和维护复杂数据结构的开销。

但是 Clock 也没有解决 LRU 的 Sequential Flooding 问题,也就是当全表扫描时,不管是“热门” Page 还是“冷门” Page 都会变得相同。

LFU: Least Frequently Used (1971)

LFU 的基本思想是为每个 Page 维护一个 Counter ,每次访问 Counter + 1 ,当需要释放 Page 时找到 Counter 最小的 Page 释放掉。

LFU 解决了 Sequential Flooding 的问题,但是同时也诞生了更多的问题:

  1. 是需要维护一个更复杂的数据结构
  2. The "New Page" Problem

    当 Buffer Pool 中满了,需要读取新的 Page 时,当读取了一个“热点” Page 时,旧的 Page 由于长时间呆在 Buffer Pool 中,即使不再需要它,它的 Counter 还是很高,而新的 Page 即使很“热门” ,也还是会被清除。

LRU-K (1993)

LRU-K 的基本思想是维护多个 LRU 列表,当需要释放某些 Page 时,替换掉倒数第 K 次访问最古老的 Page 。

Ghost Cache

LRU-K 有一个缺陷,它会清除被释放的 Page 的历史。
当有一个有“热度”,但是“热度”不是最高的 Page 需要被清除,这时不希望它的访问记录被清空,就可以使用 Ghost Cache 。

Ghoth Cache 的基本思想就是在释放 Page 的时候,不清除访问记录。

ARC Adaptive Replacement Cache (2003)

ARC 是一个自适应的缓存算法,它动态地平衡 LRU (新近度) 和 LFU (频率) 两种策略,以自动适应当前的工作负载,无需手动调参。

ARC 需要维护两个 Real 列表和两个Ghost 列表,它们都是 LRU 列表。

Real 列表:

  1. T1(Top 1): 存放最近只被访问一次的 Page ,这个列表用于记录 Recency
  2. T2(Top 2): 存放最近被访问至少两次的 Page ,这个列表用于记录 Frequecy
    T1T2 的大小是动态变化的,它们的总和 |T1| + |T2| = c 。ARC 算法的核心就是调整 T1T2 的相对大小。

Ghost 列表:

  1. B1(Bottom 1): 存放从 T1 中释放的 Page 的 meta-data ,它代表了最近被访问过一次就被淘汰的历史。
  2. B2(Bottom 2): 存放从 T2 中淘汰出去的 Page 的 meta-data。它代表了曾经是热点但最近被释放的历史。
    B1B2 的大小也是动态的,它们的总和 |B1| + |B2| = c

ARC 的 Adaptive 是通过一个目标参数 p 来控制 T1 的理想大小, T2 的理想大小就是 c - p

p 的调整公式:

  • 命中 'B1': p = min(c, p + max(|B2| / |B1|, 1))
  • 命中 'B2': p = max(0, p - max(|B1| / |B2|, 1))

Gemini:

text
假设缓存的总容量为 c。下面是当一个数据页 x 被请求时的详细流程:
情况 1:缓存命中 (Hit)

    如果 x 在 T1 或 T2 中被找到,这是一次命中。
    操作:将 x 移动到 T2 的头部(MRU 位置)。因为现在它至少被访问了两次,理应进入“常用数据区”。

情况 2:缓存未命中 (Miss)

    x 不在 T1 和 T2 中。这时需要从磁盘(或更慢的存储)加载 x,并放入缓存。放入之前,需要先腾出空间。
    腾出空间(替换/淘汰)的逻辑:
        检查 T1 的当前大小 |T1| 是否大于等于其目标大小 p。
        如果 |T1| >= p:说明 T1(新数据区)的空间已经足够或超了,应该从 T1 中淘汰数据。
            将 T1 的尾部(LRU 位置)的页移除。
            将其元数据放入 B1 的头部。
        否则(|T1| < p):说明 T1 空间还很充足,应该从 T2(常用数据区)中淘汰数据。
            将 T2 的尾部(LRU 位置)的页移除。
            将其元数据放入 B2 的头部。
    加载新数据 x 并调整 p:
        如果 x 的元数据在 B1 中:
            这说明我们之前从 T1 淘汰 x 是个错误的决定。
            调整 p:增加 p 的值(倾向于 Recency)。p = min(c, p + |B2|/|B1|)。
            腾出空间(执行上面的替换逻辑)。
            将 x 加载并放入 T2 的头部(因为它现在是“热门”数据)。
            从 B1 中移除 x 的元数据。
        如果 x 的元数据在 B2 中:
            这说明我们之前从 T2 淘汰 x 是个错误的决定。
            调整 p:减小 p 的值(倾向于 Frequency)。p = max(0, p - |B1|/|B2|)。
            腾出空间。
            将 x 加载并放入 T2 的头部。
            从 B2 中移除 x 的元数据。
        如果 x 完全是新数据(B1 和 B2 都不包含它):
            腾出空间。
            将 x 加载并放入 T1 的头部(因为它只被访问了一次)。

    注意:为了防止幽灵列表无限增长,B1 和 B2 的总大小也有限制,通常 |B1| + |B2| <= c。如果它们满了,也会按 LRU 规则淘汰掉最旧的元数据。

Dirty Page

当需要释放 Page 时,碰到了 Dirty Page ,通常不会选择先将 Dirty Page 写入磁盘,这样做速度很慢。

最常用的做法是 Background Writer / Flusher ,系统会有一个或多个专门的后台线程,它们的任务就是定期、主动地将 Dirty Page 写回磁盘。
第二种做法是单纯的优先选择普通 Page 。