
数据库系统玩得转玩不转第一步就看内存管理这一段。我在刷 CMU 15445 的时候最深的感受是这门课几乎把所有“看着没什么”的工程细节都变成了噩梦级 Lab而缓冲池Buffer Pool就是第一道坎。别笑很多自认为自己懂数据库的人都是在这一章才发现自己对内存的理解全是操作系统课上的幻觉。这篇学习心得会从内存管理和数据移动两条线索出发把 Buffer Pool 的 Why 和 How 讲透再结合 Project 1 的实操把踩过的坑摆出来。文章适合两类人一是正在刷 CMU 15445、准备动手写 Project 1 的学生二是平时写业务代码、想补数据库内核基础的后端工程师。看完你应该能回答一个问题数据库凭什么不能放心把内存交给操作系统管1. 数据库为什么非要自己管内存不可1.1 从存储层级看问题先看一组把存储层级拉开的数据。L1 缓存访问大约是 1ns内存随机访问大约 100ns而 SSD 随机读大约 100μs机械硬盘更是到了 10ms 量级。做个不严谨但很直观的比喻如果 L1 缓存是一次 1 秒的眨眼那访问内存就是 1 分钟读一次 SSD 相当于快两个小时机械硬盘直接是两天。数据库的核心工作就是在这个巨大的速度鸿沟里做数据搬运任何一次不必要的磁盘 I/O代价都是数量级层面的。数据库的数据访问模式和普通应用还不一样。OLTP 场景里大量随机点查OLAP 场景里又有大范围顺序扫描两种模式对缓存的偏好完全相反。操作系统内核的页缓存Page Cache和虚拟内存管理做得再好也是为通用场景设计的它不知道某个页是不是被一个事务“钉住”了不知道脏页刷出的顺序会影响崩溃恢复更不知道查询引擎此刻最想要哪些页留在内存。数据库要想在负载层做精准控制就必须自己动手。1.2 mmap 的争论为什么不能直接把文件映射进内存很多人会问Linux 的 mmap 不是能把磁盘文件映射到进程地址空间让操作系统帮忙按需换页吗这正好戳中了热词里的Linux 内存管理。内存映射文件看起来很美内核确实做了按需分页数据库只要把文件当一个大数组用就行了但实际用起来全是坑。第一阻塞不可控。访问一个不在内存的 mmap 页会触发缺页中断进程直接被挂起数据库完全不知道这次 I/O 什么时候发生、要多久。对于要严格控制查询延迟的事务型数据库这种“随机卡顿”是不能接受的。第二刷盘时机和顺序不可控。内核可能在任意时刻把脏页写回磁盘而数据库的崩溃恢复严重依赖 WAL 日志和页的写入顺序乱了顺序就可能出现恢复后数据不一致。第三替换策略不可控。OS 用全局 LRU 近似算法管理页缓存一个全表扫描就能把你精心维护的热数据全部挤出内存数据库却毫无办法。Andy Pavlo 在 CIDR 2022 那篇著名的论文里专门论证过这件事标题就叫Are You Sure You Want to Use MMAP in Your Database Management System?结论很直接OLTP 型数据库最好别用 mmap。这背后的核心诉求不是“内存够不够大”而是数据库要掌握数据移动的顺序、时机和决定权。自己管内存代价是实现复杂收益是性能可预期、恢复可保证。1.3 Buffer Pool 到底在解决哪三件事Buffer Pool 的本质是给上层所有执行算子提供一个“按 Page ID 拿页”的统一接口把磁盘细节屏蔽掉。我认为它其实只干三件事放什么把磁盘上的页Page内容缓存到内存槽位Frame里建立 Page ID 到 Frame 的映射。放多久内存装不下时按替换策略决定驱逐哪些页释放帧给新页。脏数据去哪被修改过的页要有序写回磁盘保证崩溃后不丢数据。这三件事串起来就是“数据移动”磁盘到内存的读入、内存到磁盘的刷写、页内部的元组被执行引擎物化后搬到算子之间。直观一点说Buffer Pool 就是数据库的物流仓库页面是货物替换策略是仓库管理员脏页回写是定期发货。仓库管不好后面索引、事务、查询执行全都得跟着遭殃。2. Buffer Pool 核心设计数据如何进出内存2.1 Page ID 和 Frame别把两个概念搞混我在初学的时候栽过一个大跟头以为 Page 就是内存里的那块缓冲区。实际上 Page ID 是磁盘上的地址Frame 才是内存数组里的槽位。数据库文件会被切分成等长的页Bustub 里默认是 4KB。磁盘管理器DiskManager负责分配和回收 Page ID而 Buffer Pool 内部维护一个预分配的 frames 数组每个 frame 是一块 4KB 的数据缓冲区外加 page_id、pin_count、is_dirty 等元信息。它们之间的对应关系存在一张页表里通常就是一个哈希表std::unordered_mappage_id_t, frame_id_t page_table_;打个比方Frame 是酒店房间Page ID 是房客的身份证号。前台人员页表根据身份证告诉你住几号房如果房客还没入住就得先从仓库磁盘里把人接过来放进一间空房。Buffer Pool 的整个工作就是在维护这张“入住登记表”和每个房间的状态。也正因为 Page 和 Frame 是两套概念很多调试问题都出在这里你在内存里看到的内容明明是 Page忘了它其实住在某个 Frame 里而替换器操作的是 Frame ID 而不是 Page ID。搞混了这两个概念后面 LRU-K 的代码会写得一塌糊涂。2.2 Pin、Unpin、Dirty三个标志撑起整个语义Buffer Pool 对外提供的核心方法是 FetchPage、UnpinPage、NewPage、DeletePage。理解这套接口最关键的是理解三个状态量Pin Count这个页当前有多少个线程在引用。引用数为正说明页正被使用禁止被驱逐。Dirty这个页的內容是否被修改过。脏页在被覆盖之前必须写回磁盘干净页则可以直接丢弃。Evictable在替换器视角里pin_count 归零且没有被其他路径占用的帧才允许被换出。调用流程通常是这样执行引擎要读某个页先 FetchPage拿到的时候 pin_count 会从 0 变成 1用完必须 UnpinPage计数减回 0。如果中途还写了数据就顺带把 is_dirty 置为 true。驱逐它的时候Buffer Pool 发现脏标记就先写回磁盘再复用这个帧。这里最容易犯的错是忘记 Unpin。想象一个场景你连续 Fetch 了 1000 个页每个都忘了 UnpinPin Count 全部堆在 1 以上替换器眼里全都是“不可驱逐”缓冲池很快就被钉死后续所有 Fetch 全部变成磁盘读性能瞬间崩塌。这种 bug 在 Project 1 里几乎人人都会遇到一次。2.3 替换策略LRU 为什么败给 LRU-K简单 LRU 的思路是“最近被访问过的页保留最久没被访问的页滚蛋”。听起来很合理但在数据库场景里有致命弱点顺序扫描污染。一个 1 亿页的全表扫描会把每一页都触摸一次LRU 列表被扫描出的页刷满原本那些每秒钟都被点查命中的热页反而被挤出去下一次点查全部 missI/O 直接爆炸。15445 教的核心方案是 LRU-K也就是看“最近 K 次访问的时间”K 通常取 2。它记录每个页最近 K 次被访问的时间戳驱逐时比较的是倒数第 K 次访问时间——一个页如果被反复访问它的倒数第二次访问时间会非常新因此不容易被淘汰。历史访问次数不足 K 次的页则优先被驱逐。算法核心依据适合场景主要问题LRU最近一次访问时间通用缓存顺序扫描污染Clock参考位近似 LRU低开销场景精度有限LRU-K倒数第 K 次访问时间数据库缓冲池实现复杂需维护访问历史实现上LRU-K 需要给每个帧维护一个访问时间戳队列。你可以用哈希表记录每个 frame 的历史时间戳再用一个有序结构按“第 K 次访问时间”排序这样驱逐时能快速找到最该被换出的帧。很多人写这个数据结构写得想吐但这是理解“为什么数据库要内存管理”的最好练习因为不同的工作负载对“热”的定义完全不同。3. 实操解析Project 1 Buffer Pool Manager 的关键路径3.1 四个组件的分工边界如果你是照着 2023 年秋季版刷的 Project 1要实现的组件有四个DiskManager、LRUKReplacer、Page、BufferPoolManagerInstance。我建议动手前先想清楚它们的边界DiskManager只负责把指定 Page ID 的数据从磁盘文件读进内存、从内存写回磁盘以及分配和回收页号。它不懂任何缓存策略。Page只是一个数据容器保存 4KB 数据、页号、pin_count、脏标记附带一个读写闩锁。LRUKReplacer只维护“哪些 Frame 当前可被驱逐”这个集合以及访问历史。它不碰磁盘也不知道页的内容。BufferPoolManagerInstance唯一知道全局的人。它负责查页表、调 DiskManager 做 I/O、调替换器做驱逐、更新 pin 和脏标记。新手最常见的错误是把替换逻辑写进 Buffer Pool把 I/O 逻辑写进 Page最后代码耦合成一团乱麻。写之前先定义好“谁不管什么”比定义“谁管什么”更重要。替换器不需要知道脏页怎么刷Buffer Pool 不需要知道访问历史该怎么排序边界清楚了测试挂了也容易定位。3.2 FetchPage 与 NewPage 的完整调用链以 FetchPage 为例完整流程是这样的Page *BufferPoolManagerInstance::FetchPage(page_id_t page_id) { std::lock_guardstd::mutex lock(latch_); // 1. 先查页表命中就直接 pin 住返回 if (page_table_.count(page_id)) { frame_id_t frame_id page_table_[page_id]; pages_[frame_id].pin_count_; replacer_-RecordAccess(frame_id); replacer_-SetEvictable(frame_id, false); return pages_[frame_id]; } // 2. 未命中先找一个可用的帧没有就得驱逐 frame_id_t victim; if (!GetFreeFrame(victim)) return nullptr; // 3. 如果受害帧是脏的先写回磁盘 if (pages_[victim].IsDirty()) { disk_manager_-WritePage(pages_[victim].GetPageId(), pages_[victim].GetData()); pages_[victim].SetDirty(false); } // 4. 移除旧映射读入新页 page_table_.erase(pages_[victim].GetPageId()); disk_manager_-ReadPage(page_id, pages_[victim].GetData()); pages_[victim].SetPageId(page_id); pages_[victim].ResetMemory(); pages_[victim].pin_count_ 1; page_table_[page_id] victim; replacer_-RecordAccess(victim); replacer_-SetEvictable(victim, false); return pages_[victim]; }NewPage 的流程大同小异差别只在第 4 步它需要先向 DiskManager 申请一个全新的 Page ID然后同样要经历“找空帧或驱逐”的过程之后直接清零内存不需要读盘。这里有个我踩过的坑以为 NewPage 不需要驱逐。完全错误缓冲池满的时候新页同样要先把某个旧页挤出去。还有一个小细节在写回脏页之前原来的 page_table_ 映射必须被删掉否则等会儿插入新映射时旧映射还占着位置。好多人在这里写反了顺序导致同一个 frame 对应两个页号查错查到怀疑人生。3.3 UnpinPage、DeletePage、FlushPage 的边界条件这几个方法表面上简单实际上处处是边界UnpinPage先检查 pin_count 是否大于 0否则直接返回 false。把计数减一如果归零就调用replacer_-SetEvictable(frame_id, true)同时把传入的 is_dirty 合并进页的脏标记。注意是“或”合并哪怕这次调用传了 false之前已经置过的脏位也不能被清掉。DeletePage前提是 pin_count 为 0。如果页是脏的先写回磁盘然后从页表删除映射让替换器把这条记录移除最后调用 DiskManager 回收页号。漏了任何一步后面都会出现“删了还能 Fetch”的灵异现象。FlushPage只负责把当前帧内容写回磁盘写完后清掉脏标记。FlushAllPages 则是遍历整个 pages_ 数组逐个刷写。注意刷盘是慢 I/O不要在持有全局锁的时候做超出必要的长操作否则整个系统吞吐直接被锁拖死。这些方法看起来零散但组合起来就是完整的“数据移动”闭环读入、引用、修改、回写、回收。每一步都在移动物理数据而状态标记决定什么时候移动、能不能移动。理解了这一点Buffer Pool 在你眼里就不再是几个std::unordered_map了。3.4 Latch 与并发安全一把全局锁先跑对再谈优化Buffer Pool 是要被多线程并发访问的所以必须引入并发控制。课程里特别强调区分两类同步机制Latch是保护内存里的临界资源生命周期很短Lock是保护数据库逻辑数据的并发访问可能要跨事务持有一段时间。Buffer Pool 内部用的是 Latch 语义。15445 Fall 2023 的 Project 1 允许你用一把全局互斥锁串行化整个 Buffer Pool 操作这是最简单的正确性方案。性能测试大概率能过因为评测重点在正确性。如果你想做更细的并发控制可以按帧加 latch但复杂度直线上升还容易写出死锁。我在实现时遇到的经典死锁是这样的一个线程在 Evict 流程里先拿了 victim frame 的 latch再去调 replacer 的内部方法另一个线程在 UnpinPage 时先操作 replacer再回头碰同一个 frame 的 latchAB-BA 死锁就产生了。排查了半天才发现是锁的顺序不统一。后来干脆在 Buffer Pool 层用一把全局大锁replacer 内部再管好自己的小锁所有对外接口统一先拿大锁再操作锁序彻底固定问题消失。我的建议是第一版先用粗粒度锁把正确性跑通性能问题留到后面项目再说。新版课程还引入了 ReadPageGuard 和 WritePageGuard本质是 RAII 思想进入作用域时 Fetch 并自动 pin离开作用域时自动 Unpin彻底杜绝手动管理 pin_count 导致的泄漏。这个思路不只是应付课程日常工程里凡是“用了必须释放”的资源都应该考虑 scope guard。4. 数据移动的另一面执行引擎里的内存协作4.1 页内元组的移动与物化开销Buffer Pool 解决了页在磁盘和内存之间的移动但数据移动远不止这一层。执行引擎拿到一个页之后还需要把页里的元组Tuple解析出来送到各个算子之间。Bustub 用的是 Slotted Page 结构页尾部是槽位目录保存每个元组的偏移量和大小数据区从页头往后排列。解析 tuple 时最好直接基于页内的内存指针操作而不是把整页数据复制到算子私有区域否则一次查询涉及几百万个 tuple拷贝开销会直接压垮性能。这里要引入“物化”的概念。早期物化Early Materialization把列数据尽早拼成完整行后续算子处理起来方便但内存占用大晚物化Late Materialization先只搬运列引用等最终需要输出时才凑齐整行能显著减少中间数据量这在列存系统里尤其关键。我在写 Project 3 的执行算子时就有过切身教训用memcpy把每个 tuple 从一页搬到另一个结构性能直接掉了一个数量级改成引用页内数据之后才恢复正常。内存带宽是现代数据库的隐形瓶颈计算早就不是了。4.2 内存装不下时外部排序与哈希连接落盘数据移动最刺激的场景是“内存装不下”。经典的两板斧是外部归并排序和哈希连接落盘。外部归并排序的思路很简单先用内存里的 B 个缓冲页对数据分块排序每块排好后写回磁盘称为一个 Run然后再多路归并这些 Run。比如有 100 页数据B 5先排成 20 个 Run归并时用 B - 1 4 个输入缓冲加 1 个输出缓冲第一趟把 20 个 Run 并成 5 个第二趟并成 3 个4 路不够合并成 3 路第三趟并成 1 个。总 I/O 次数大约是 4 倍的原始数据量。这里的核心洞察是排序算法的“分治”思路本质是在磁盘和内存之间反复搬运数据。哈希连接也类似。Build 阶段把 inner 表扫进内存建哈希表Probe 阶段用 outer 表的每一行去查。如果 inner 表太大装不进内存就得先做分区用同一个哈希函数把两个表按哈希值分到 B - 1 个分区装不下的分区继续递归分区每个分区内部再做 build 和 probe。所有溢出的分区都被临时写回磁盘这就是“落盘”。也是在这个问题上我开始理解“中间结果该不该走 Buffer Pool”是个真问题。如果排序和连接产生的临时数据全写进 Buffer Pool就会把真正的业务热页全部挤出去导致缓存污染。所以很多数据库执行引擎会为排序、聚合等操作分配独立的临时内存池或者干脆绕过 Buffer Pool 直接跟磁盘管理器要文件空间。这种“哪里有数据要走、走哪条路、占用谁的配额”的设计才是数据移动这个词完整的意思。4.3 内存分配器数据库连 malloc 都不放心除开 Buffer Pool数据库内部还有大量小对象的分配释放元组缓冲区、哈希表的桶、索引节点、闩锁队列等等。如果直接依赖 glibc 的 malloc在高并发下会遇到两个问题一是内存碎片越来越多二是分配器内部的锁竞争直接拖垮吞吐。数据库领域更常见的做法是使用 Arena 内存池、对象池Object Pool或者调优 jemalloc / tcmalloc 这类可扩展分配器。Andy 在课程里专门提过“内存分配器对数据库的影响被严重低估”。我自己在刷完 Project 1 之后也养成了一个习惯凡是生命周期高度一致的大量小对象尽量自己维护一个空闲链表复用而不是频繁向系统要内存。这算是内存管理这章带给我的工程红利不只在数据库场景里有用。5. 常见问题与排查技巧实录5.1 LRU-K 实现里的三个经典 bugLRU-K 的隐藏测试点基本都集中在替换逻辑的边界上我自己和周围同学踩过的坑至少有三个访问历史没更新。FetchPage 命中时只记得 pin忘了调 RecordAccess 记录访问时间。结果一个被频繁访问的页替换器眼里永远是“访问次数不足 K 次”每次都被优先驱逐缓冲池形同虚设。排查方法是在替换器的Size()里打日志看每轮驱逐后还剩多少可驱逐帧。Evict 返回了 pin_count 0 的帧。Pin 的时候必须调用 SetEvictable(frame_id, false)把帧从可驱逐集合里摘掉Unpin 归零后再放回去。如果漏掉这个切换替换器就可能选中一个正在被使用的帧导致上层逻辑直接崩溃或数据错乱。帧被删除后替换器里还有残留记录。DeletePage 只删了页表映射忘了replacer_-Remove(frame_id)后续 Evict 可能把一个空帧当候选进而读到脏数据。这个 bug 比较隐蔽建议在 Evict 时对返回的帧做一次完整性校验页号是否有效、pin_count 是否为 0。5.2 Pin 泄漏跑着跑着缓冲池就满了症状非常典型程序一开始正常跑几分钟后所有 FetchPage 都 miss性能断崖式下跌。原因九成是 pin 泄漏。正常情况 Unpin 和 Fetch 应该一一对应但代码里总有一些提前 return 的路径忘记 Unpin或者异常分支直接跳走。排查步骤我总结成三条第一写一个辅助函数打印所有帧的page_id_和pin_count_看看谁一直不为 0第二人肉审计每个 FetchPage 之后的代码路径尤其注意 if-else 里的提前 return第三如果工程允许直接用 RAII Guard 替换手动 Unpin让资源管理自动对称。新版 Project 的 ReadPageGuard 和 WritePageGuard 就是为了消灭这类问题你自己写生产代码时也应该这么干。5.3 脏页到底要不要立刻刷脏页写回是数据移动的最后一公里时机选择很微妙。通常脏页在三个时机被写回被驱逐时、显式调 FlushPage/FlushAllPages 时、系统关闭时。为什么不做到“一改就刷”因为磁盘随机写太贵了一改就刷等于把数据库性能拖到泥潭里。但这里有个和日志系统相关的大坑脏页的写回顺序必须和 WAL 日志的 LSN 顺序对应否则崩溃恢复时可能出现“旧页覆盖新日志对应数据”的问题。Project 4 和后续的日志章节会重点讲但你在 Project 1 里就应该有这个意识FlushAllPages 不应随意中断其他操作的执行上下文刷盘前要确保这个页之前的所有日志记录已经落盘。我习惯在每个脏页写回前加断言检查它的 LSN 是否小于等于当前持久化的日志 LSN能提前暴露很多隐性问题。5.4 问题速查表症状大概率原因检查点跑一会儿性能骤降Pin 泄漏把帧钉死pin_count 是否归零是否有提前 return 漏 UnpinFetch 总是 missLRU-K 历史记录错误RecordAccess 是否在命中时调用时间戳是否更新并发测试随机挂替换器和 Buffer Pool 锁序不一致统一锁获取顺序replacer 内部是否单独加锁DeletePage 后还能 Fetch映射没清干净page_table_ 的 erase、replacer_ 的 Remove 是否都做了数据内容错乱Evict 选中了 pin 中的帧SetEvictable 在 Pin/Unpin 时是否正确切换这一章跑通之后我最大的体会是数据库的“内存管理”本质上是管理时间和顺序而不是管理缓存。时间决定了哪些页该留下顺序决定了哪些脏页能以什么次序落盘。如果你正在刷 15445我的建议是动手写 Project 1 之前先把课件里 Buffer Pool 那张状态转换图在自己脑子里画一遍——从 Fetch 到 Unpin从驱逐到写回每一步都问一句“这个状态归谁管”。画明白了代码只是把你的答案翻译成 C 而已。下一篇我会顺着脏页写回往下聊日志与恢复那是内存管理跟事务正确性交汇的地方也是 15445 真正的分水岭。