数据库的数据最终保存在磁盘上,但真正执行查询时,CPU 不可能每次都直接面对磁盘。
数据库需要在磁盘与内存之间建立一层高速缓存。
这一层,就是 Buffer Pool。
如果说 Page 解决的是“数据如何组织”,那么 Buffer Pool 解决的就是:
哪些数据应该留在内存里?什么时候读进来?什么时候淘汰?什么时候写回磁盘?
一、为什么数据库需要 Buffer Pool?
假设数据库有:
100 GB Data
而服务器只有:
16 GB Memory
显然不可能:
Database
↓
全部加载到内存
数据库只能选择一部分 Page 放进内存:
Disk
│
├── Page 0
├── Page 1
├── Page 2
├── ...
└── Page N
↓
Buffer Pool
┌─────────┐
│ Page 8 │
│ Page 20 │
│ Page 56 │
│ Page 81 │
└─────────┘
当查询需要 Page 20:
Query
↓
Buffer Pool
↓
Page 20
如果 Page 不在内存:
Query
↓
Buffer Pool
↓
Miss
↓
Disk
↓
Page 20
↓
Buffer Pool
↓
Query
因此 Buffer Pool 最重要的作用就是:减少数据库访问磁盘的次数。
二、Buffer Pool 本质上是什么?
从最简单的角度,可以把它理解成:
一大片内存
+
一套 Page 管理机制
例如:
class BufferPool
{
private:
std::vector<Page> pages;
};
当然,真实数据库远远没有这么简单。
它至少需要解决:
- Page 放在哪里?
- Page 是否已经在内存?
- 哪个 Page 可以淘汰?
- Page 有没有被修改?
- Page 是否正在使用?
- 淘汰之后是否需要写回磁盘?
因此 Buffer Pool 实际上更像:
Buffer Pool
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Page Cache Replacement Dirty Page
Policy Management
三、数据库如何知道 Page 是否已经在内存?
这是 Buffer Pool 最基本的问题。
假设磁盘上:
Page ID = 100
数据库执行:
read(Page 100)
它首先需要判断,Page 100 当前是否已经在 Buffer Pool?
因此需要一个映射:
Page ID
↓
Frame ID
例如:
Page 100 → Frame 3
Page 200 → Frame 7
Page 500 → Frame 1
其中:
- **Page:**表示磁盘上的逻辑页面。
- **Frame:**表示Buffer Pool 中能够存放一个 Page 的内存槽位。
可以理解成:
Disk
Page 100
│
↓
Buffer Pool
Frame 3
四、Page 和 Frame 为什么要分开?
这个概念非常重要。
假设:
Buffer Pool = 4 Frames
那么:
Frame 0
Frame 1
Frame 2
Frame 3
现在:
Page 100 → Frame 2
之后 Page 100 被淘汰,Frame 2 的位置又可以装:Page 800
于是:
Page 800 → Frame 2
所以:Page 是数据对象,Frame 是内存位置。
五、Buffer Pool 的核心数据结构
一个简化的 Buffer Pool 通常至少需要:
Page Table
+
Frame Array
+
Replacement Policy
+
Free List
可以画成:
Buffer Pool
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Page Table Frames Replacement
Policy
六、Page Table:快速找到 Page
假设:
Page 100
Page 200
Page 500
分别位于:
Frame 2
Frame 5
Frame 1
可以维护:
unordered_map<PageID, FrameID>
查询:
auto it = page_table.find(page_id);
如果找到:Buffer Hit
如果没有:Buffer Miss
于是:
Page Table
│
├── Hit → 直接使用内存 Page
│
└── Miss → 从磁盘读取
七、什么是 Buffer Hit?
这是数据库性能中非常重要的指标。
假设执行:
100 次 Page Access
其中:
95 次
Page 已经在内存。
那么:
Hit = 95
Miss = 5
Buffer Hit Ratio:
Buffer Hit Ratio 越高:
通常意味着数据库越少需要访问磁盘。
但是要注意:高 Hit Ratio 并不自动意味着数据库一定快。
因为访问模式、I/O 延迟、CPU、锁竞争等因素都会影响最终性能。
八、Buffer Miss 之后发生什么?
假设:
Page 100
不在 Buffer Pool。
数据库需要:
Disk → Memory
但 Buffer Pool 可能已经满了。
例如:
Frame 0 → Page 10
Frame 1 → Page 20
Frame 2 → Page 30
Frame 3 → Page 40
现在需要:
Page 50
没有空闲 Frame。怎么办?
必须:淘汰一个 Page。
这就是 Buffer Pool 最核心的问题之一。
九、谁应该被淘汰?
假设最近访问:
Page 10
Page 20
Page 30
Page 40
现在要加载:
Page 50
我们应该淘汰谁?
如果,Page 40刚刚被访问过,那么很可能很快还会再次访问,淘汰它不合理。
而如果,Page 10已经很久没有访问,那么淘汰 Page 10 更合理。
于是出现:LRU
十、LRU:Least Recently Used
LRU(Least Recently Used): 最近最少使用。
例如访问顺序:A、B、C、D
可以理解为:
最近 最久
D → C → B → A
现在需要淘汰:A
因为它最久没有被访问。
十一、LRU 为什么看起来合理?
因为程序通常具有:Temporal Locality(时间局部性)
例如:
for (...)
{
access(Page A);
}
那么,Page A会被频繁访问。
如果刚访问完就淘汰:
访问 A
↓
淘汰 A
↓
再次访问 A
↓
重新从磁盘读取
就会产生:
I/O
I/O
I/O
I/O
非常浪费,LRU 就是利用:最近访问过的数据,未来可能继续访问。
十二、但是 LRU 并不完美
这是数据库设计非常重要的一点:
一个看起来合理的算法,不一定适合所有数据库访问模式。
假设:
A B C D E F G H
执行一次全表扫描。
那么 LRU 会不断把旧 Page 推出去:
A
B
C
...
H
如果 Buffer Pool 很小:
之前缓存的热点 Page 可能全部被扫描数据挤出去。
例如:
热点:
A B C
全表扫描:
1 2 3 4 5 6 7 8 ...
最后:A B C
可能全部消失,这就是典型的 Sequential Scan Pollution(顺序扫描污染缓存)。
十三、数据库为什么需要其他淘汰算法?
因此真实数据库并不一定简单使用:纯 LRU
而可能采用:
- Clock
- LRU-K
- 2Q
- ARC
- 自定义策略
其中一个非常值得理解的是:LRU-K
十四、LRU-K 的核心思想
普通 LRU:看最近一次访问。
LRU-K:看最近 K 次访问。
例如:
Page A:
访问:
10s
20s
30s
Page B:
访问:
1s
虽然 B 可能最近被访问过:
但 A 明显更加“活跃”。
因此 LRU-K 能够更好地区分“偶尔访问”和“真正热点”。
这也是数据库缓存设计的重要思想:
访问一次,并不意味着它值得长期留在内存。
十五、Clock:为什么数据库喜欢它?
LRU 的一个问题:
维护完整访问顺序需要较高成本。
尤其在:
多线程
高并发
大量 Page
情况下。
于是可以使用:Clock
简单理解:
↓
┌───────────┐
│ │
A B C D
│ │
└───────────┘
每个 Page 有一个类似:
Reference Bit
访问:A
设置:
A.ref = 1
淘汰时:
如果 ref == 1
给它第二次机会
ref = 0
如果 ref == 0
淘汰
因此 Clock 用很低的维护成本近似实现“最近使用”思想。
十六、真正复杂的问题:Dirty Page
现在考虑一个重要场景。
数据库读取:
Page 100
然后执行:
UPDATE ...
内存中的 Page 被修改:
Disk:
Page 100 = Old
Memory:
Page 100 = New
此时内存中的 Page 与磁盘不一致。
这样的 Page:Dirty Page
十七、为什么 Dirty Page 不能直接淘汰?
假设:
Frame 2 → Dirty Page 100
现在 Buffer Pool 满了。
如果直接:
Discard Page 100
那么:
Memory:
New Data
Disk:
Old Data
新数据就丢失了。
所以必须:
Dirty Page
↓
Write Back
↓
Disk
↓
Evict
因此 Buffer Pool 淘汰不仅仅是:选择谁
还要判断:这个 Page 是否被修改?
十八、Pin / Unpin
还有一个问题。
假设:
Thread A
正在使用:
Page 100
这时候:
Thread B
想要淘汰 Page 100。
显然不能。
因此 Buffer Pool 通常需要:
Pin Count / Reference Count
可以抽象成:
Page 100
pin_count = 1
表示当前还有线程或操作依赖这个 Page。
因此:
pin_count > 0
通常意味着不允许被淘汰。
使用完成 Unpin。
变成:
pin_count = 0
之后才可能进入淘汰候选。
十九、把整个 Buffer Pool 流程串起来
现在来看一次完整的 Page 访问:
Request Page
│
▼
Page Table
│
┌──┴──┐
│ │
Hit Miss
│ │
│ ▼
│ Free Frame?
│ │
│ ┌─┴─┐
│ │ │
│ Yes No
│ │ │
│ │ ▼
│ │ Eviction
│ │ │
│ │ Dirty?
│ │ │
│ │ ▼
│ │ Write Back
│ │
│ ▼
└── Page Loaded
│
▼
Buffer Pool
│
▼
Return
这就是 Buffer Pool 的基本生命流程。
二十、数据库为什么不完全依赖操作系统 Page Cache?
这是一个非常值得思考的问题。
操作系统本身也有 Page Cache。那么数据库为什么还要自己做 Buffer Pool?
因为数据库知道:
Page 100
Page 200
Page 300
分别是什么数据。
它还知道:
- 哪些 Page 是热点
- 哪些 Page 是 Dirty
- 哪些 Page 正在使用
- 哪些 Page 可以淘汰
- 哪些 Page 属于哪个事务
- 哪些 Page 应该优先写回
而操作系统看到的主要是:
File Offset
+
Memory Page
它并不了解这个 Page 是索引,还是用户数据?
所以数据库自己管理缓存,可以拥有:数据库语义层面的缓存控制能力。
不过实际数据库与操作系统 Page Cache 的关系非常复杂,并不是简单的“数据库完全不用 OS Cache”。不同数据库、存储引擎和 I/O 路径会采用不同策略。
二十一、三个数据库怎么做?
现在回到我们之前一直比较的:
SQLite
PostgreSQL
InnoDB
它们都需要解决:
Disk
↓
Memory
↓
Page
但实现方式不同。
PostgreSQL
有自己的 Shared Buffers。
多个数据库进程共享。
大致:
Process
↓
Shared Buffers
↓
Page
↓
Storage
同时还会和操作系统 Page Cache 发生关系。
InnoDB
核心是 Buffer Pool。
它不仅缓存数据页:
还会缓存:
- Index Page
- Undo Page
- Change Buffer
- Adaptive Hash Index 等相关结构
InnoDB 的 Buffer Pool 是整个存储引擎的核心组件之一。
SQLite
更加轻量,它使用 Page Cache,管理数据库 Page。
因为 SQLite 通常运行在单个应用进程中,所以整体架构相比服务器型数据库更加紧凑。
二十二、Buffer Pool 真正解决的是什么问题?
不要把它简单理解成把磁盘数据放进内存。
更准确地说 Buffer Pool 是数据库对有限内存资源进行管理的一套机制。
它需要同时解决:
数据加载
+
缓存命中
+
页面淘汰
+
Dirty Page
+
并发访问
+
内存复用
因此它已经不再只是:
std::unordered_map
这么简单。
二十三、从 C++ 的角度看 Buffer Pool
如果我们自己设计一个极简版本,可以抽象成:
using PageId = uint64_t;
using FrameId = uint32_t;
struct Frame {
PageId page_id;
bool dirty;
uint32_t pin_count;
char data[8192];
};
然后:
class BufferPool {
public:
Frame* fetchPage(PageId page_id);
void unpinPage(PageId page_id);
void flushPage(PageId page_id);
private:
std::unordered_map<PageId, FrameId> page_table;
std::vector<Frame> frames;
std::list<FrameId> replacement_list;
};
这已经可以表达 Buffer Pool 的核心思想:
PageID
↓
Page Table
↓
Frame
↓
Memory
真正的数据库只是在这个基础上加入了大量工程机制。
二十四、知识地图
这次,我们的数据库内部结构又向下和向上连接了一层:
Database
│
Storage Engine
│
┌───────────┴───────────┐
↓ ↓
Index Table
│ │
└───────────┬───────────┘
↓
Page
│
↓
Buffer Pool
│
┌───────────┼───────────┐
↓ ↓ ↓
Page Table Replacement Dirty Page
│
┌─────┴─────┐
↓ ↓
LRU Clock
LRU-K
二十五、最值得记住的几个概念
Page: 数据库管理数据的基本单位。
Frame: Buffer Pool 中能够容纳一个 Page 的内存槽位。
Page Table: Page ID → Frame ID 的映射。
Buffer Hit: Page 已经在内存。
Buffer Miss: Page 不在内存,需要加载。
Dirty Page: 内存中的 Page 已经被修改,与磁盘内容不同。
Eviction: 从 Buffer Pool 淘汰 Page。
Pin: 当前 Page 正在被使用,不能淘汰。