不能直接用 std::list + std::unordered_map 实现 Buffer Pool,因为 std::list 节点分散分配导致物理地址不稳定,std::unordered_map 重散列会引发指针失效;而 Buffer Pool 要求页帧地址固定、无锁/细粒度锁访问、支持 pin 计数及快速定位磁盘页 ID 对应内存帧。

Buffer Pool 的核心结构设计:为什么不能直接用 std::list + std::unordered_map
直接套用 STL 容器实现 LRU 缓存,在数据库引擎里会出问题——std::list 节点分散分配,std::unordered_map 的哈希桶可能重散列,都会导致不可控的内存分配和指针失效。而 Buffer Pool 必须保证:页帧(frame)物理地址稳定、访问无锁(或细粒度锁)、支持 pin 计数、能快速定位磁盘页 ID 对应的内存帧。
正确做法是用数组 + 双向链表指针(非节点嵌入式链表)+ 哈希表索引:
-
std::vector<frame>固定大小预分配,每个Frame包含page_id_t、data(指向 mmap 或 malloc 的连续 buffer)、pin_count、is_dirty、以及prev/next指针(不嵌入链表节点,避免移动) - 哈希表用
std::unordered_map<page_id_t frame></page_id_t>,key 是{table_id, page_no}组成的 struct(需自定义 hash 和 ==) - LRU 链表头尾用裸指针维护(
head_/tail_),每次access()把 frame 摘下再插到 head,淘汰时从 tail 往前找第一个pin_count == 0的 frame
Page 访问时的 pin / unpin 与并发安全:别让 unpin 触发误淘汰
Buffer Pool 不是纯缓存,而是内存页生命周期管理者。pin_count 表示该页正被上层(如 B+ 树遍历、更新操作)持有引用,未 unpinned 前绝不能被淘汰或刷盘。
常见错误是把 pin/unpin 当作简单计数器用,忽略以下关键点:
立即学习“C++免费学习笔记(深入)”;
- pin 必须在获取 frame 后、返回给调用方前完成;unpin 必须在调用方明确释放后才执行,且需加锁保护
pin_count修改 - 淘汰线程(后台 flusher 或 LRU scan)必须跳过所有
pin_count > 0的 frame,哪怕它很久没被访问 - 如果使用读写锁(如
shared_mutex),读路径(pin)可并发,但淘汰/flush 必须独占写锁;更推荐分段锁(shard per N frames)减少争用 - 注意:B+ 树节点分裂/合并过程中可能跨多个 page 访问,这些 page 必须统一 pin,否则中间某页被换出会导致逻辑错误
LRU-K 与 clock 算法的实际取舍:为什么纯 LRU 在数据库里容易失效
真实 OLTP 场景中,全表扫描、大范围索引扫描会把大量冷页“刷”进 Buffer Pool,挤走高频热页——这就是 LRU 的固有缺陷(stack distance 失效)。纯 LRU 淘汰策略在数据库引擎中基本不用。
生产级实现通常折中:
- 用
Clock(二次机会)算法替代双向链表:每个 frame 加一个ref_bit,淘汰线程循环扫描,第一次遇到ref_bit == 1就清零并跳过,第二次才淘汰;访问时置位ref_bit即可,开销极小 - 或者升级为
LRU-K(K=2):记录每页最近两次访问时间戳,淘汰时优先选 second-to-last 访问最久的页;但需额外存储和排序成本,适合对延迟敏感的场景 - MySQL InnoDB 实际用的是
midpoint insertion strategy+LRU list split,把新读入页插入到 LRU 链表中部而非头部,防止全表扫描污染热区——这比算法本身更重要
Flusher 线程与 checkpoint 协同:脏页刷盘不能只看 LRU 顺序
Buffer Pool 的淘汰(evict)和刷盘(flush)是两个独立动作:evict 只关心内存回收,flush 关心事务持久性。若仅按 LRU 顺序刷盘,可能把刚修改但尚未 commit 的脏页提前写出,破坏 WAL 协议。
必须解耦处理:
- 维护单独的
flush_list(按lsn排序的 dirty frame 双向链表),由 log manager 在 write-ahead log 写入后通知加入;flusher 线程从此链表头部开始刷,确保老日志对应页先落盘 -
BufferPool::FlushPage(page_id)应只负责同步刷指定页,并清除其is_dirty标志;批量刷由后台线程驱动,避免阻塞事务提交 - checkpoint 时,需等待
flush_list中所有lsn 的页刷完,再写 checkpoint record 到日志文件——这里最容易漏掉对 flush_list 的 wait 逻辑
真正难的不是链表怎么连,而是 pin 状态、脏页标记、日志序列号、淘汰时机这四者的状态同步。任意一步错位,轻则性能抖动,重则 crash recovery 失败。


















