数据库内核设计: Buffer Pool的实现与并发设计

0 阅读9分钟

程序员都以为数据库在读磁盘,其实它根本没有

最近在学习 CMU 15-445《数据库系统导论》,这门课是卡内基梅隆大学的经典数据库课程,Andy Pavlo 讲得相当硬核。

学习过程中发现一个问题——网上几乎找不到对应这门课的中文讨论。

于是我决定:边学边写,把自己的理解记录下来。 不保证完全正确,但保证是我真正消化过的东西。如果你也在学这门课,或者只是对数据库底层感兴趣,希望这些文章能帮到你。

如果有错误,欢迎在评论区锤我 🔨


先问一个灵魂问题:你以为 SELECT 在干什么?

你写下这行 SQL:

SELECT * FROM orders WHERE id = 42;

你脑子里的画面是什么?

数据库打开磁盘文件,找到第 42 行,读出来,返回给你。

太天真了。

如果真是这样,你的业务系统早就被 I/O 卡死了。一次磁盘随机读取大概需要 5~10 毫秒,内存访问是 100 纳秒量级。换句话说:磁盘比内存慢了将近 10 万倍

如果数据库每次查询都老老实实地去读磁盘,你的 QPS 大概能维持在个位数——不夸张。

所以数据库工程师早就想好了:不能每次都去读磁盘,得把磁盘上的 Page 缓存在内存里。

这个"缓存层",就是今天的主角:Buffer Pool


先别急着看结构,先问它为什么存在

很多文章上来就给你一张图:Free List、LRU List、Flush List……

然后你看完觉得:哦,好像懂了。但其实没有。

我们换个角度。假设你是数据库的设计者,你面对的问题是这样的:

  1. 磁盘上有几百 GB 的数据,内存只有几 GB
  2. 每次查询需要访问若干个 Page(还记得上篇说的,数据是以 Page 为单位组织的)
  3. 你希望尽量少读磁盘,多复用内存里已经加载过的 Page

这不就是操作系统里的 页面缓存(Page Cache) 问题吗?

对,思路完全一样。Buffer Pool 本质上就是数据库自己管理的一套内存缓存,绕过操作系统的文件缓存,自己控制 Page 的换入换出

为什么要自己管,不让 OS 管?因为数据库比 OS 更懂自己的访问模式——比如它知道哪些 Page 是"顺序扫描一次性的",哪些是"被频繁访问的热点索引页"。OS 的 LRU 策略面对这种场景会被"污染",而数据库可以针对性地优化。

好,现在我们带着"为什么"的问题,去看它的结构。


Buffer Pool 长什么样?——一块内存,三条链表

Buffer Pool 在内存里是一块连续分配的区域,被切成一个个等大的 frame,每个 frame 正好装下一个磁盘 Page(通常是 16KB)。

buffer_pool_memory_layout.svg

光有内存格子不够,还需要一个目录:告诉你 page_id = 42 的 Page 现在在哪个 frame 里,有没有被加载,有没有被修改过。

这个目录叫 Page Table,是一个哈希表:

page_id → { frame_id, is_dirty, pin_count, ... }

敲黑板:pin_count 非常重要。它记录当前有多少个线程正在使用这个 Page。pin_count > 0 的 Page,是不能被驱逐出去的——你不能把别人正在用的东西扔掉。

然后是三条链表,它们各司其职:

Buffer Pool 的三条链表
─────────────────────────────────────
Free List    ──→ 空闲的 frame,随时可用
LRU List     ──→ 已加载的 Page,按访问时间排序
Flush List   ──→ 脏页(Dirty Page),等待刷回磁盘
─────────────────────────────────────
链表管的是什么解决什么问题
Free List空闲 frame快速找到空位装新 Page
LRU List所有已加载 Page决定内存不够时驱逐谁
Flush List被修改过的 Page知道哪些 Page 需要写回磁盘

这三条链表是 Buffer Pool 的骨架,下面我们挨个"解剖"。


Free List:最简单,也最容易被忽略

Free List 就是一个空闲 frame 的池子。

当数据库需要把一个新 Page 载入内存,第一步就是从 Free List 里取一个空闲 frame。

这很简单,但有一个问题:Free List 用完了怎么办?

这时候就得从 LRU List 里找一个"最不常用"的 Page,把它驱逐出去——如果它是脏页,还得先刷盘。

这就是为什么 Free List 和 LRU List 要配合工作。


LRU List:最复杂,也最容易翻车

LRU(Least Recently Used)的思路很直观:最近没用过的 Page,优先驱逐。

但经典 LRU 在数据库里有个致命弱点

想象一下全表扫描(SELECT * FROM big_table):你把整张表从头到尾扫一遍,每个 Page 只访问一次。这些 Page 一涌而入,把 LRU List 里所有的热点 Page 全挤走了——但全表扫描完之后,这些 Page 再也不会被访问了。

这就是所谓的 LRU 污染(Cache Pollution)。

MySQL InnoDB 的解决方案是 LRU List 分区

LRU List 分成两段:
┌─────────────────────────────────────────┐
│  New Sublist (5/8)  │  Old Sublist (3/8) │
│  热数据区           │  冷数据区           │
└─────────────────────────────────────────┘
         ↑                    ↑
    多次访问后晋升          新 Page 初次进入这里

新加载的 Page 先进 Old Sublist(冷数据区)。只有被再次访问,才会晋升到 New Sublist(热数据区)。

全表扫描的 Page?进冷数据区,扫完就凉凉,根本污染不到热数据。

CMU 15-445 里还介绍了另一种更精妙的方案:LRU-K。它不只看"最近一次访问时间",而是看"最近 K 次访问的间隔"——访问越频繁,越不容易被驱逐。

lru_problem.svg

lruk_solution.svg

这里有个容易踩的坑:LRU List 里的 Page,pin_count > 0 的不能被驱逐。所以 Buffer Pool 在驱逐时要扫 LRU List 尾部,跳过 pinned 的 Page,找第一个 pin_count == 0 的来驱逐。


Flush List:脏活累活都在这里

一个 Page 被修改后,内存里的内容和磁盘就不一样了,这个 Page 叫 Dirty Page(脏页)

你可能会问:改完直接写磁盘不就行了?

不行。 每次修改都同步写磁盘,那 I/O 开销又回来了。数据库的策略是:修改先在内存里做,异步地、批量地刷回磁盘。

但这里有个新问题:如果数据库崩溃了,内存里的修改不就丢了吗?

这就是 WAL(Write-Ahead Logging)和 Redo Log 的工作了。这是后面会讲的内容,这里先留个悬念——

"脏页可以不立刻写磁盘,但 Redo Log 必须先写。"

Flush List 就是跟踪所有 Dirty Page 的链表,按照 LSN(Log Sequence Number) 排序。LSN 是个单调递增的日志序号,记录了每次修改对应哪条 Redo Log。

Flush List(按 LSN 排序):
oldest_modification_lsn
       ↓
[Page A, LSN=1000][Page C, LSN=1050][Page B, LSN=1200] → ...
       ↓
  Checkpoint 就从这里往后刷

Checkpoint 机制会周期性地把 Flush List 里 LSN 最小的那些脏页刷到磁盘,然后推进 Checkpoint LSN。这样崩溃恢复时,只需要重放 Checkpoint 之后的 Redo Log 就够了——不用从头开始。

这三者的关系,是整个数据库"持久性"保证的核心,后面会专门拆。


一次完整的 Page 读取——串起来看

光说三条链表还是抽象,我们走一遍完整的流程:

查询需要 page_id = 42
          │
          ▼
    查 Page Table
          │
    ┌─────┴─────┐
    │ 命中?     │
   Yes          No
    │            │
    ▼            ▼
 直接返回      Free List 有空闲 frame?
 frame 地址        │
(pin_count++)  ┌──┴──┐
                Yes    No
                 │      │
                 │      ▼
                 │   从 LRU 尾部选一个受害者
                 │   (pin_count==0,如果是脏页先刷盘)
                 │      │
                 └──────┘
                         ▼
                  从磁盘读 page 42 → frame
                  更新 Page Table
                  加入 LRU List
                  pin_count++
                         │
                         ▼
                    返回 frame 地址

用完之后别忘了调用 UnpinPage(page_id, is_dirty)——把 pin_count--,告诉 Buffer Pool "我用完了,你可以考虑驱逐这个 Page 了"。

这个 Unpin 很关键,忘记调用就是内存泄漏,还会导致 Buffer Pool 里全是 pinned Page,新 Page 进不来,系统卡死。(如果你在做 CMU 15-445 的 Lab,这里大概率会有一个 bug 等着你。)


并发怎么办?——Latch 和 Lock 是两回事

Buffer Pool 是所有线程共享的,Page Table 这个哈希表是共享资源,并发访问必须加锁保护。

但这里有个术语区分要搞清楚,否则你看论文和代码会一脸懵:

名称保护什么持有时间会死锁吗
Latch(闩锁)内存数据结构(Page Table、frame)极短,操作期间不允许死锁,设计上规避
Lock(事务锁)数据库逻辑对象(行、表)整个事务期间有死锁检测机制

Buffer Pool 用的是 Latch,不是事务 Lock。你操作 Page Table 时加 latch,操作完立刻释放,不会持有到事务结束。

如果你在实现 Buffer Pool(比如 CMU 的 Lab),要特别注意 latch 的粒度和顺序——用全局大锁简单,但并发性很差;用细粒度锁(每个 frame 一把 latch)性能好,但容易产生死锁,需要严格规定加锁顺序。

buffer_pool_concurrency_v2.svg


Buffer Pool 和上下游的关系

光看 Buffer Pool 自己还不够,得知道它在整个数据库里处于什么位置:

上层:执行引擎(SQL 执行、索引查找、Join)
          │  FetchPage / UnpinPage
          ▼
     Buffer Pool Manager
      ├── Page Table(哈希表)
      ├── Free List
      ├── LRU List
      └── Flush List
          │           ↑
          │ 刷脏页     │ Redo Log 先行写入
          ▼           │
     磁盘(Heap File)  WAL / Redo Log(下篇)
组件与 Buffer Pool 的关系
执行引擎通过 FetchPage/UnpinPage 使用 Buffer Pool
Disk Manager负责实际的磁盘读写,Buffer Pool 调用它
WAL / Redo Log脏页刷盘前必须先写 Log,保证崩溃可恢复
Checkpoint定期推进,缩短崩溃恢复时间

总结:用一张表收尾

概念一句话关键细节
Buffer Pool数据库的内存缓存层绕过 OS page cache,自己管
Frame内存里的一个 Page 槽位大小 = 磁盘 Page 大小(16KB)
Page Tableframe 的目录page_id → frame_id 哈希表
pin_count使用中的引用计数> 0 不可驱逐,用完必须 Unpin
Free List空闲 frame 池优先从这里拿 frame
LRU List驱逐决策链表冷热分区,防止全表扫描污染
Flush List脏页追踪链表按 LSN 排序,配合 Checkpoint
Dirty Page内存已修改、磁盘未同步的 Page不立刻写盘,靠 WAL 保安全
Latch内存结构的短期锁不是事务 Lock,持有时间极短

Buffer Pool 本质上在做一件事:用有限的内存,尽可能多地挡住磁盘 I/O。它的每一个设计细节——LRU 分区、pin_count、Flush List——都是在这个目标下做的工程取舍。

理解了 Buffer Pool,你才算真正踏进了数据库内核的门。接下来,我们聊聊B+树。如果说 Buffer Pool 是数据库的记忆,B+树就是它的加速器。


🔖 系列导航


如果这篇对你有帮助,点个赞让算法知道一下,感谢 🙏