程序员都以为数据库在读磁盘,其实它根本没有
最近在学习 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……
然后你看完觉得:哦,好像懂了。但其实没有。
我们换个角度。假设你是数据库的设计者,你面对的问题是这样的:
- 磁盘上有几百 GB 的数据,内存只有几 GB
- 每次查询需要访问若干个 Page(还记得上篇说的,数据是以 Page 为单位组织的)
- 你希望尽量少读磁盘,多复用内存里已经加载过的 Page
这不就是操作系统里的 页面缓存(Page Cache) 问题吗?
对,思路完全一样。Buffer Pool 本质上就是数据库自己管理的一套内存缓存,绕过操作系统的文件缓存,自己控制 Page 的换入换出。
为什么要自己管,不让 OS 管?因为数据库比 OS 更懂自己的访问模式——比如它知道哪些 Page 是"顺序扫描一次性的",哪些是"被频繁访问的热点索引页"。OS 的 LRU 策略面对这种场景会被"污染",而数据库可以针对性地优化。
好,现在我们带着"为什么"的问题,去看它的结构。
Buffer Pool 长什么样?——一块内存,三条链表
Buffer Pool 在内存里是一块连续分配的区域,被切成一个个等大的 frame,每个 frame 正好装下一个磁盘 Page(通常是 16KB)。
光有内存格子不够,还需要一个目录:告诉你 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 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 和上下游的关系
光看 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 Table | frame 的目录 | 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+树就是它的加速器。
🔖 系列导航
- 上一篇:数据库的基本单位:一切从 Page 开始
- 本篇:数据库内核设计: Buffer Pool的实现与并发设计
- 下一篇:数据库内核设计: B+树实现以及保证线程安全(敬请期待)
如果这篇对你有帮助,点个赞让算法知道一下,感谢 🙏