Buffer Pool:数据库如何管理内存中的数据

9 阅读10分钟

数据库的数据最终保存在磁盘上,但真正执行查询时,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:

HitRatio=95100=95%HitRatio = \frac{95}{100}=95\%

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 正在被使用,不能淘汰。