引子:为什么数据库不能"相信"操作系统的内存管理
在上一节中,我们从磁盘 I/O 现实出发,讨论了数据页、文件组织形式以及不同工作负载下的存储模型。但是,仅仅把数据如何放在磁盘上 设计清楚,并不足以支撑一个高性能数据库系统。真正决定系统性能上限的,往往是:这些数据何时、以什么方式、被加载到内存中,又在内存中停留多久。
相信朋友们应该都学过《计算机组成原理》和《操作系统》这两门计算机核心专业课吧。从这两门专业课的角度来看,计算机操作系统的本职工作,本质上就是在不同层级的存储介质之间协调数据的移动。操作系统通过虚拟内存、页表、缺页中断等机制,为通用程序提供了"看似无限内存"的抽象,并在后台负责页面调度。然而,数据库系统却很少"信任"这套机制,原因在于数据库对内存管理有着截然不同且更严格的目标。
首先是空间控制 。这里的"空间"指DBMS 是否能够精确地感知并控制数据页在内存与磁盘之间的映射关系 。数据库关心的不是"某个虚拟地址对应哪个物理页",而是"某个逻辑数据库页是否在内存中、是否为脏页、是否正在被其他线程使用、是否可以被安全地驱逐"。这些信息对查询执行、并发控制和恢复机制至关重要,但在操作系统的页缓存模型中是不可见的。
其次是时间局部性的控制 。数据库系统希望将"有价值的数据页"尽可能长时间地保留在内存中,以服务尽可能多的查询或事务;只有在明确判断某些页面在未来一段时间内不太可能再被访问时,才会主动将其移出内存。其目标是避免频繁的磁盘访问带来的长时间延迟。而操作系统的页面替换策略(如通用 LRU 变种)并不了解数据库查询的语义,无法区分一次性的顺序扫描与高频访问的索引页,往往会在分析型查询中"误伤"真正重要的数据页。
而比起前两者,在我认为更重要的是,数据库必须支持大于物理内存容量的数据集 ,但又不能像普通应用那样依赖操作系统的缺页中断来"被动"调页。一次不可控的缺页中断,可能直接阻塞执行线程,这在数据库系统中是不可接受的。
正是基于这些原因,现代 DBMS 普遍选择自行实现一套面向数据库语义的内存管理机制 :由数据库自己决定哪些页面进入内存、何时写回磁盘、如何进行页面替换,以及如何在并发访问下保证一致性。这套机制的核心抽象,便是 Buffer Pool。在接下来的内容中,我们将围绕 Buffer Pool 展开,着重介绍数据库如何管理内存中的页面、副本与状态,以及数据是如何在磁盘与内存之间被显式、可控地移动的。
一.Buffer Pool:数据库内存管理的核心抽象
在现代数据库系统中,Buffer Pool 是 DBMS 内存管理的核心抽象 。它并不是简单地"把磁盘数据缓存到内存里",而是一套由数据库完全掌控的数据页缓存与状态管理机制。在企业级服务器环境中,Buffer Pool 往往会被分配为系统内存中的一个重要比例------通常是物理内存的 50%~80% ,具体取决于工作负载类型、并发度以及是否需要为其他组件(如排序、哈希、连接算子等)预留内存空间。与操作系统的页缓存不同,这部分内存的使用策略、淘汰规则和一致性语义完全由 DBMS 自身决定。
从结构上看,Buffer Pool 由一组缓冲帧(Frame) 组成。每个缓冲帧的大小通常与数据库的数据页大小保持一致(例如 4KB、8KB 或 16KB),这样可以避免在内存与磁盘之间搬运数据时产生额外的拆分或拼接成本。我需要强调的是,内存中缓冲帧的排列顺序与磁盘上数据页的物理布局没有任何必然关系:数据库并不试图在内存中复现磁盘的顺序结构,而是根据访问模式与替换策略动态地决定哪些页面常驻内存。

在缓存方面,数据库中的 Buffer Pool 属于一种回写缓存(Write-Back Cache) ,而非直写缓存(Write-Through Cache)。所谓回写缓存,就是指当事务修改了页面内容时,这些修改首先只体现在内存中的缓冲帧里,页面被标记为"脏页 ",但并不会立即同步写回磁盘。只有在页面被驱逐、检查点触发,或恢复协议要求时,DBMS 才会将其刷新到磁盘。相对而言,直写缓存要求每次内存修改都同步写盘,这会在数据库高并发写入场景下带来不可接受的 I/O 开销,因此几乎不被主流数据库采用。
为了在 Buffer Pool 中快速定位某个数据库页面,DBMS 需要维护一套页表(Page Table) 。这里的"页表"是数据库层面的概念,用于将逻辑页面 ID 映射到内存中的缓冲帧位置 ,通常通过哈希表实现。它与操作系统中的页表完全不同,也不应与磁盘上的页目录 (例如文件中记录页面位置的元数据结构)混淆:前者存在于内存中,用于快速查找;后者存在于磁盘上,用于描述数据的物理组织。当数据库需要访问某个页面时,首先查询内存中的页表,判断该页面是否已被缓存,以及它当前位于哪个缓冲帧中。
除了存放实际的数据页内容,每个缓冲帧还必须维护一组额外的元数据 ,用于保证系统的正确性与并发安全。其中最重要的包括:脏位(Dirty Bit) :标识该页面自上次写回磁盘后是否被修改 。脏页在被回收之前,必须被安全地刷新到磁盘,否则会造成数据丢失。固定计数(Pin Count) :记录当前有多少线程正在使用该页面。 当某个线程持有指向页面内容的指针时,页面会被"固定"在内存中,其固定计数增加;只有当计数归零时,该页面才有资格被替换或驱逐。这些元数据使得 DBMS 能够精确判断:哪些页面可以安全地被丢弃,哪些页面必须继续驻留内存,以及在何时执行写回操作。

这里也许有些朋友会有疑问:既然操作系统已经有页缓存和虚拟内存,数据库为什么还要自己造一个 Buffer Pool?确实,在理论上,我们完全可以使用 mmap 将数据库文件映射到虚拟地址空间 ,让操作系统负责页面调度和缺页处理。程序只需访问内存地址,数据是否在磁盘、何时调入、何时写回,都由操作系统自动完成。这种方式在普通应用中极其优雅。但数据库系统恰恰不能采用这种"优雅"的方式。而在第三节我们会介绍原因。
最后,我需要澄清一个在数据库系统中非常容易混淆的概念区别:Latch 与 Lock 。在数据库语境下,Latch 是一种轻量级的内部同步原语 ,用于保护内存中的数据结构或代码临界区,例如页表、缓冲帧元数据等;它关注的是线程安全与执行正确性,持有时间极短。而 Lock 则是一种更高层次的并发控制机制,用于保护逻辑实体(如元组、索引键或事务之间的可见性关系),直接参与事务隔离级别的实现。二者的目标、粒度和生命周期完全不同,但在 Buffer Pool 的实现中都会频繁出现。
二.Buffer Pool Manager:数据进出的指挥者
在理解了 Buffer Pool 的静态结构之后,一个自然的问题是:谁来负责管理这些缓冲帧?
页面什么时候被加载进来?什么时候可以被驱逐?又是谁决定是否需要将其写回磁盘?
在数据库系统中,这一职责由 Buffer Pool Manager(BPM) 承担。从功能上看,Buffer Pool Manager 是 DBMS 中唯一允许主动触发磁盘 I/O 的组件。上层的执行器、访问方法或查询算子并不会直接读写磁盘文件,而是通过 BPM 请求某个逻辑页面;至于该页面是否已经在内存中、是否需要从磁盘加载、是否需要驱逐其他页面,全部由 BPM 统一协调。
BPM 的核心工作并不是"存储数据",而是维护页面在磁盘与内存之间的映射关系,并控制它们的生命周期。当上层模块请求一个页面时,BPM 首先在页表中查找该页面是否已被缓存;如果命中,则返回对应的缓冲帧,并增加其固定计数;如果未命中,则需要选择一个可替换的缓冲帧,将目标页面从磁盘读入内存。同样地,当页面不再被使用时,上层模块会通知 BPM 解除固定。只有当页面的固定计数归零时,BPM 才能将其视为可驱逐对象;若该页面被标记为脏页,还必须在驱逐前将其安全地写回磁盘。
我们不难发现,Buffer Pool Manager 并不关心查询的语义,也不理解事务的业务含义。它只做一件事:在正确的时间,协调数据页在内存与磁盘之间的流动,并保证这一过程的正确性与可控性。而具体"驱逐谁、留下谁",则交由页面替换策略来决定,这正是我们接下来要重点讨论的内容。
三.数据的移动
在计算机系统中,数据在存储层级之间的移动通常是被动发生的 。在操作系统的抽象下,程序只需访问虚拟地址,至于数据是否位于内存、是否需要从磁盘调入,往往由缺页中断和内核的页面置换机制在后台完成。这种设计在我看来虽然极大地简化了通用程序的编程模型,但这么做的代价是:访问延迟不可预测,数据移动的时机和代价对应用程序是不可见的。
数据库系统无法接受这种不确定性。我们在前一节有介绍过,磁盘 I/O 的成本远高于内存访问,而一次不可控的磁盘读写,足以主导整个查询或事务的执行时间。因此,在数据库中,数据的移动不再是一种运行时"意外",而是一种被精心设计和显式控制的行为。
在 DBMS 中,数据的基本移动单位是页面。页面是否驻留在内存中、是否为脏页、是否正在被线程使用,都是数据库可以精确掌握的状态。当上层模块需要访问某个页面时,它并不会直接触发硬件或操作系统机制,而是通过数据库自身的内存管理组件显式地请求该页面。由此,DBMS 可以在真正发生 I/O 之前,提前判断其代价,并决定是否需要延迟、合并或避免这次数据移动。
这种"显式数据移动"的设计,使得数据库能够围绕查询语义和访问模式进行优化。例如,多个算子可以共享同一份内存中的页面副本,避免重复加载;顺序扫描和随机访问可以被区别对待;频繁访问的页面可以被长期保留在内存中,而一次性访问的数据则可以尽早被淘汰。数据在磁盘与内存之间的流转,不再是由缺页中断驱动的被动反应,而是由 DBMS 主导的、有意识的资源调度行为。
当然,显式控制也意味着必须做出取舍。当内存空间有限、请求不断到来时,数据库需要决定哪些页面可以被安全地移出内存、哪些页面值得继续保留,以及在何时将修改过的数据写回磁盘。这些问题最终汇聚为一个核心主题:页面替换与驱逐策略。
四.页面驱逐算法
1.LRU算法
在 Buffer Pool 需要腾出空间时,数据库必须回答一个核心问题:淘汰哪一页? 这个问题本质上是对"时间局部性 "的利用。根据计算机组成原理中这门课中介绍的的局部性原理,最近被访问的数据,在未来被再次访问的概率更高。因此,一个直观且理论上合理的策略是:优先淘汰最久未被访问的页面 。这就是 LRU(Least Recently Used)算法的基本思想。

LRU 的逻辑实际上非常直接:为每个页面记录其最近一次访问时间 ,当需要驱逐页面时,选择时间戳最旧的那个。为了避免每次驱逐时线性扫描整个 Buffer Pool,工程实现通常采用"哈希表 + 双向链表"的组合结构 。双向链表按访问顺序组织页面,链表头表示最新访问,链表尾表示最久未访问;哈希表负责将 page_id 映射到链表节点,从而保证访问、更新、删除操作均为 O(1)。当某页被访问时,将其移动到链表头部;当发生驱逐时,从链表尾部选择被驱逐的节点victim。
但在数据库系统中,值得我们注意的是,LRU 并非简单的缓存算法,它还必须满足两个约束。第一,不能驱逐尚未持久化的脏页 。若页面被修改(dirty = true),在满足 WAL 持久化顺序之前不能直接丢弃,否则会破坏事务安全性。第二,不能驱逐仍被线程持有的页面,即 pin_count > 0 的页面必须跳过。因此,真实的 LRU 驱逐过程往往需要在链表尾部向前扫描,直到找到既未被 pin 且满足持久化条件的页面。
从理论上看,LRU 能很好地刻画时间局部性;从工程上看,它实现简单、复杂度低,因此成为最经典、最常见的缓存替换策略之一,也是互联网大厂面试中高频出现的手撕题。然而在高并发数据库系统中,精确 LRU 需要在每次访问时更新链表结构,容易成为 latch 竞争热点,这也是后续引出 Clock 和 LRU-K 等近似或改进算法的原因。我这里给出一版最简单的LRU算法实现C++代码以供大家参考!
cpp
#include <unordered_map>
#include <list>
class LRUReplacer {
private:
size_t capacity;
// 双向链表:头部是最近使用,尾部是最久未使用
std::list<int> lru_list;
// 哈希表:page_id -> iterator
std::unordered_map<int, std::list<int>::iterator> page_table;
public:
LRUReplacer(size_t cap) : capacity(cap) {}
// 访问页面
void Access(int page_id) {
if (page_table.count(page_id)) {
// 已存在,移动到头部
lru_list.erase(page_table[page_id]);
}
lru_list.push_front(page_id);
page_table[page_id] = lru_list.begin();
// 超出容量则删除尾部
if (lru_list.size() > capacity) {
int victim = lru_list.back();
lru_list.pop_back();
page_table.erase(victim);
}
}
// 驱逐页面(返回 victim)
int Evict() {
if (lru_list.empty()) return -1;
int victim = lru_list.back();
lru_list.pop_back();
page_table.erase(victim);
return victim;
}
};
2.Clock算法
如果说 LRU 是对"时间局部性"的精确建模,那么我认为Clock 算法就是在工程实践中对 LRU 的一次理性妥协。它保留了"最近被访问的页面更不应被驱逐"的核心思想,但避免了为每次访问维护全局排序结构所带来的开销与锁竞争。
Clock 的基本思想非常简单:用一个比特近似表示"最近是否被访问过" ,而不是维护精确时间戳 。Buffer Pool 中的页面被组织成一个环形数组 ,就像钟表的刻度;系统维护一个不断向前移动的"钟表指针 "。每个页面都有一个 reference bit(引用位):当页面被访问时,将其引用位置为 1。当需要驱逐页面时,钟表指针依次扫描页面:若当前页面引用位为1,则将其清零,给予"第二次机会",继续前进;若引用位为0,则选择该页面作为 victim 进行驱逐。

从直觉上理解,Clock 并不追踪"谁最久未访问",而是问一个更粗粒度的问题:自上次检查以来,你是否被访问过? 如果是,就暂缓驱逐;如果不是,就淘汰。这是一种LRU的近似实现。与精确 LRU 相比,Clock 的优势在于:其不需要维护全局有序链表或时间戳;而且每次访问仅需设置一个 bit,元数据极小。在高并发环境下锁竞争更低,扩展性更好。这也是为什么现代操作系统(如 Linux 的页替换机制)和许多数据库系统更倾向于使用 Clock 或其多指针变种(如多队列 Clock、改进型 Clock),而不是严格 LRU。
当然,在数据库系统中,Clock 仍然必须遵守两条基本约束:不能驱逐 pin_count > 0 的页面,也不能丢弃尚未安全持久化的脏页。因此实际实现时,在扫描过程中若遇到 pinned 页面,会直接跳过;若遇到脏页,则可能触发后台刷盘或延迟驱逐。
3.LRU-K算法
如果说 LRU 与 Clock 都是在"最近是否被访问"这一维度上建模时间局部性,那么它们都有一个经典的设计缺陷:无法有效抵抗顺序扫描。
我们设想这样一个场景:在一个 OLTP 系统中,Buffer Pool 里缓存着大量热点页,此时突然执行一次大表连接或全表扫描 。扫描会按顺序读取大量页面,每个页面只访问一次,却都会被视为"最近使用",从而被插入到 LRU 队列头部。结果是:原本的热点页被逐出,扫描结束后又需要重新从磁盘加载------由此我们认为Buffer Pool 被"污染 "或"泛滥 "。这类问题在 OLAP 查询中尤为明显,因为分析型查询天然具有大范围顺序访问的特征。正是为了解决这个问题,科学家提出了 LRU-K 算法。它的核心思想并不是"记录最近一次访问时间",而是用页面的第 K 次最近访问时间 ,来估计它的重用距离 ,从而更准确地预测未来访问概率。
在传统 LRU 中,每个页面只记录"最近一次访问时间"。而在 LRU-K 中,每个页面需要记录最近 K 次访问的时间戳。当需要驱逐页面时,算法计算:
Backward K-Distance=当前时间−第K次最近访问时间
之后驱逐 Backward K-Distance 最大 的页面。
直观理解如下:若一个页面被频繁访问,那么它的第 K 次访问时间会很近,则不应驱逐;若一个页面只被访问一次(例如顺序扫描),它甚至没有 K 次访问记录,由此被视为"冷页",优先淘汰。这等价于:区分"真正的热点页"和"偶然访问页"。例如:K = 2 时,页面必须被访问至少两次,才会被认为"有重用价值";只访问一次的扫描页,会迅速被淘汰,而不会污染缓存。

如上图,InnoDB(MySQL)采用了一种 两段式 LRU 机制,本质上是 LRU-2 的工程近似。在该机制中,对比传统的LRU算法,LRU列表被拆分为两部分:Young List(新子链) 和Old List(老子链) 。默认情况下,新加载的页面不会直接进入 Young List ,而是先进入 Old List 的中间位置 。只有在一定时间窗口内被再次访问,才会提升到 Young List。
实际上这等价于一个简化版 LRU-2,可以理解为第一次访问进入 Old List(还不算热点),而第二次访问可提升进 Young List(确认是热点页)。因此:顺序扫描页通常只访问一次,其会停留在 Old List 很快被淘汰。而真正的热点页会被多次访问,将其提升到 Young List并长时间保留这在效果上接近 LRU-2,但实现复杂度远低于完整 LRU-K。
最后我用ChatGPT总结了一份LRU算法,Clock,LRU-K算法的对比表格,大家可以参考一下,相信结合上面的介绍,朋友们一定可以对这几种算法建立起自己的理解:

五.Buffer Pool的工程级优化
在了解驱逐算法之后,我们必须意识到:**Buffer Pool 的性能瓶颈从来不只来自算法本身,还来自并发争用与负载模式。**工业级数据库系统往往会在工程层面对 Buffer Pool 进行一系列优化,以降低锁竞争、减少缓存污染、提升 I/O 命中率。下面我们从四个典型方向进行梳理。
首先是采用多Buffer Pool实例 。在单一 Buffer Pool 设计中,所有线程会共享页表,驱逐链表,元数据结构等临界资源,随着并发度提升,这些资源会成为严重的 latch 争用热点。为了解决这个问题,工业系统通常采用 Buffer Pool 分区 :将整个缓冲池划分为多个独立实例;且每个实例拥有独立的页表、替换结构与锁;通过哈希(通常基于 PageID)决定某个页面归属于哪个实例。例如 InnoDB 允许配置多个Buffer Pool 实例,每个实例管理一部分内存空间。这种设计的本质是:用空间换并发,通过减少共享结构,降低 latch争用。其代价是可能降低全局替换决策的最优性,因为每个实例只看到局部页面。但在高并发 OLTP 场景下,这是值得的工程折中。

第二种优化策略是预取(Pre-Fetching) 。数据库不同于操作系统的一个关键点是:
DBMS 能看到查询计划(Query Plan),因此可以预测未来访问模式。 基于这一能力,系统可以提前发起 I/O 请求,而不是等访问发生时被动触发。常见预取类型包括:顺序预取(Sequential Read-Ahead) ,当检测到页面按顺序访问时,自动预取后续 N 个页面,将随机 I/O 转化为顺序 I/O以提高磁盘带宽利用率。以及索引预取(Index Prefetch), 在索引扫描中,若已知即将访问某些叶子页或数据页,可提前异步加载。 实际上预取这种机制的本质是将 I/O latency 隐藏在计算过程中。

第三种优化策略是扫描共享(Scan Sharing) 。我们设想两个查询同时执行全表扫描:Q1 从 page0 开始,而Q2 稍后也从 page0 开始。如果它们各自独立运行,毫无疑问,会重复加载相同页面,导致页面在 Buffer Pool 中频繁进出,从而造成污染。工业系统(如 PostgreSQL)实现了 Synchronized Scan 机制,其规定后来的查询不会从头开始,而是"加入"当前扫描的位置,也就是说多个查询共享同一数据流 。不难发现这是一种典型的:以吞吐量为优先的优化策略。它能显著减少 I/O 次数,避免 Buffer Pool 被顺序扫描冲垮,尤其适合 OLAP 或混合负载场景。

最后我想介绍的是Buffer Pool旁路(Buffer Pool Bypass) 。实际上并非所有查询都值得进入 Buffer Pool 。例如一次性的全表扫描,批量导入操作,分析型临时查询等。如果每次都将页面加载到 Buffer Pool,更新页表、LRU 元数据,参与驱逐决策,这么做反而会造成严重污染。因此 PostgreSQL 引入了Ring Buffer 机制,其为特定查询分配一个小型循环缓冲区, 页面只在这个小区域内循环使用而不参与全局 LRU。其本质思想是将"冷数据访问"与"热点缓存"隔离。
结语
在这一篇中,我们从"为什么数据库不能依赖操作系统"出发,系统梳理了 Buffer Pool 的结构设计、数据移动机制、替换算法(LRU、Clock、LRU-K)以及工业级优化策略。可以看到,内存管理并非简单的缓存实现,而是一套围绕可预测的数据移动、可控的空间分配与可扩展的并发结构 展开的系统工程。它决定了数据库能否在磁盘现实与高并发负载之间保持稳定性能。然而,缓存解决的是"数据在哪里"的问题,下一篇我们将着重介绍数据库系统真正的逻辑核心------访问方法(Access Methods)。在这一部分中,我们将系统介绍哈希索引、树形索引(B+Tree 及其变种)、倒排索引以及过滤器结构等关键技术,它们决定了数据库如何高效定位数据,是整个系统性能设计中最值得深入探讨的模块之一。本文参考了诸多论文与博客,以及我自己的学习笔记,如有疏漏请及时指正,我们一起进步!