☰
数据库缓冲池进阶:置换策略、并发控制与检查点调优
2026/10/23 13:06:20 网站建设 项目流程

1. 从一份压测报告说起:为什么大家都在追着缓冲池跑

做数据库内核和底层基础设施的同行应该都有这种经历:某个业务模块上线前压测,内存充足、磁盘也是 NVMe SSD,但 TPS 就是上不去。CPU 不高,IO 也不满,锁等待看着也不夸张,最后翻 perf 一看,热点全聚在缓冲池的页面查找和淘汰路径上。尤其是像“数据库缓冲池(3)”这样做到第三期的系列,前两期聊完了基本架构、页面生命周期、LRU 链表维护,按理说该到收尾阶段了,但这个阶段才是真正的考验:并发控制、检查点、刷盘策略、以及压测中各种诡异现象的排查,每一项都能让人头皮发麻。

如果你还停留在“缓冲池无非就是内存里放一堆页,满了就淘汰”的理解层面,那我们今天要聊的内容可能会推翻不少固有认知。这套机制放在单线程模型里确实简单,但一旦进入多线程、多缓冲区实例、多检查点并发执行的真实场景,所有“看起来没问题”的设计都会开始露馅。这篇文章我会沿着一条实战主线来拆:页面置换策略的取舍、并发访问的加锁粒度、检查点与日志的配合,最后用一个我在某公司订单系统上踩过的坑来串联全部知识点。

适合谁来读?如果你是做数据库内核开发、中间件调优、存储引擎相关工作,这篇文章可以直接当作排查手册用;如果你只是日常使用数据库,但想看明白参数背后的原理,也能从中找到不少能落地的调优思路。我会尽量少堆术语,多讲“当时我做的时候是怎么想的,出了问题怎么定位的”。

2. 页面置换策略:不是说用 LRU 就一定安全

2.1 为什么第三期还要重提淘汰策略

前两期我们讲过最基本的 LRU(Least Recently Used)链表的插入和移动逻辑,简单来说就是:页面被访问一次,就往链表头部挪一次;缓冲池满了,就从尾部开始淘汰。这套逻辑单独看没有任何毛病,但放到真实负载里就完全不是一回事了。比如某订单系统里有一条高频查询,每次扫描一张大表的尾部数据,同时又有一个低频交易任务在读取同一张表的头部数据,这时候 LRU 的表现会让人抓狂:高频查询不停地把页面往链表头提,低频任务刚读进来的冷页很快被挤到尾部淘汰,下次再读又得从磁盘加载,形成循环。

我在实际中见过最典型的问题,是一次大范围的全表扫描直接把整个缓冲池打穿,导致所有在线事务的命中率从 98% 掉到 60% 左右,RT 翻了好几倍。事后分析才发现,全表扫描本身就是顺序读,页面读完这一次之后短期内大概率不会再被访问,如果还把它当成热页放在链表头,就是在拿宝贵的内存资源做慈善。

2.2 从 LRU 到 LRU-K、2Q 的演进思路

所以很多系统在 LRU 之上加了一层“准入门槛”。最简单的做法是类似 2Q 的两段式设计:第一次读入的页面先进一个 FIFO 队列,只有被再次访问到,才升级进真正的 LRU 队列。这个阈值过滤效果非常明显,全表扫描的页面通常只进 FIFO 队列,被快速淘汰,不会冲击核心热数据。另一个方向是 LRU-K,也就是记录每个页面最近 K 次访问的时间戳,当访问频率达到 K 次之后才认为它是“值得缓存”的,这里面最常取的是 K=2。

我当时在另一个项目里实测过 2Q 的改造效果:同样的全表扫描压测场景,日间在线业务的命中率从 72% 提升到 93%,这还是在缓冲池大小不变的前提下做到的。所以说,淘汰策略本身不复杂,复杂的是识别哪些页面才是真正值得留的。你可以在自己的代码里先实现一个简单的“二次准入”标记字段,再逐步过渡到真正的多队列结构,这个改造路径比较平滑,风险也好控制。

注意:任何淘汰策略都只是概率模型,没有银弹。设计目标不是让命中率无限接近 100%,而是在可控内存成本下,把短时热数据和长时热数据都照顾到。

2.3 性能观测的起点:命中率、淘汰率与扫描占比

聊完策略,还是要回到观测。很多同仁遇到性能问题第一步就看命中率,但我建议把这三个指标放在一起看:命中率、淘汰率、扫描占比。只看命中率有一个问题是它太宏观了,如果某个业务的扫描行为把所有冷页面都刷过一遍,命中率数字可能很好,但真正的热数据竞争力反而下降了。淘汰率代表单位时间内从尾链表摘走的页面数量,扫描占比代表顺序扫描对缓冲池的访问比例。

我把这三个指标定义成一张小表,每次压测或者线上问题排查都先拉出来看一眼:

指标定义关注点
命中率访问逻辑页时在缓冲池中命中的比例整体缓存效益,但容易被大扫描稀释
淘汰率单位时间内 LRU 尾部弹出的页面数反映淘汰压力,与脏页刷盘强相关
扫描占比顺序扫描请求占总请求的比例判断是否发生了扫描冲击缓存的现象

如果命中率没掉但淘汰率异常高,那说明内部可能出现了频繁的“伪命中”,也就是页面刚淘汰出去又被读回来,这个现象在日志里表现为大量物理读,而且这些读还是重复的。遇到这种情况,优先检查是不是存在周期性任务在做大范围扫描,或者你的 LRU 链表头尾移动逻辑在并发场景下出现了偏差。

3. 缓冲池并发控制:锁粒度决定一切

3.1 哈希表锁与页面 Latch

缓冲池本质上是一个哈希表加若干链表的结构。哈希表负责根据页号定位到缓冲帧(buffer frame),链表负责维护淘汰顺序和脏页顺序。并发访问时,这俩结构一旦被多个线程同时读写,就会出现数据竞争。最简单粗暴的做法是整个哈希表加一把大锁,但读过几年代码你就知道,这种方案在几十个线程时就会把性能拉垮,因为所有访问都被串行化了。

业内主流的做法是哈希桶锁(bucket lock)或者分段锁(segment lock)。如果你用的是 C++ 实现,最常见的方案是每个哈希桶一把读写锁,读路径加共享锁,写路径加排他锁。这样不同桶之间的并发访问完全不会互相阻塞。但这里有个细节很多人会忽略:从哈希桶拿到缓冲帧指针,再到把帧上的 Latch 加上,中间不是原子操作。如果两个线程同时找到同一个缓冲帧,一个在读,一个要淘汰,就可能出现“读到了正在被淘汰的页面”。

我当时处理这个问题的方式是:帧上加一个状态字段,用原子变量表示 FIXED、IN_LRU、FREE 等状态,淘汰线程必须先抢到帧上的排他 Latch 才能把状态改成 FREE,否则就换一个候选页。这里要特别提醒一下,不要试图通过“在哈希桶里再查一次”来规避竞态,因为淘汰过程中页面可能已经从哈希表摘除了。

3.2 页面访问计数与并发安全

还有一个绕不开的点是页面访问计数(pin count)。缓冲池里的页面被线程使用时,需要 pin 住防止被淘汰;用完后 unpin。这个计数在高并发下极易出问题。最常见的一个 bug 是 unpin 过头,计数变成负数,导致页面永远无法被淘汰,最后缓冲池被不可回收的页面占满,表现为内存持续上涨但缓存命中却越来越差。

我见过一个案例,某团队定位这种问题定位了两天,最后用调试模式开启帧内容定期转储,才发现是某个查询路径在异常返回时漏了 unpin,而错误处理分支又把同一个 pin 释放了两次。这个排查过程相当痛苦。所以工程上建议把 pin/unpin 封装成 RAII 对象,避免裸调,同时在 debug 构建里加引用计数溢出检测。如果跟进到线上,建议给缓冲池加一个“当前不可淘汰页面数”的监控指标,一旦这个值出现异常波动,优先检查所有异常返回分支。

3.3 多缓冲池实例与 NUMA 亲和

现在的服务器动不动就是几十上百核,单个缓冲池的大锁和单条 LRU 链表会成为瓶颈。很多系统因此做了分区设计:把缓冲池拆成多个 instance,每个 instance 有自己的哈希表、LRU 链表和刷盘线程,页号通过哈希映射到特定 instance。这样多线程可以并行操作不同的 instance,理论上扩展性接近线性。

但分区之后又引来一个新坑——跨实例访问。某些业务场景下,两个高频表的数据可能被哈希到不同 instance,事务 A 先在 instance 1 读页面 X,再去 instance 2 读页面 Y,本来没问题,但如果页面 X 和 Y 在业务上总是被成对访问,就会带来额外的跨 buffer pool 锁开销。更严重的是 NUMA 架构下,内存访问是有距离远近的:线程在 Node 0 上申请到的缓冲帧,如果被 Node 1 上的线程频繁访问,性能会明显下降。

我的经验是:在分区数量不多(一般是物理核数除以 8 左右)时,先把高频表的访问尽量打散到不同分区;而当分区数量较多时,反而要接受一定程度的跨分区访问,不要刻意追求所有操作都在一个分区内完成,否则会把大量 CPU 时间浪费在等待和锁迁移上。这类取舍没有完美解,只能靠压测数据说话。

4. 检查点、日志与刷盘:缓冲池的“落盘三部曲”

4.1 为什么不能等缓冲池满了再刷

缓冲池里的页面都是先写内存,再异步落盘的。如果系统崩溃,内存里还没落盘的数据就要靠日志重放来恢复。所以日志系统与缓冲池的交互,比大多数人想象的更紧密。每次页面被修改,都会生成一条日志记录,而日志记录什么时候刷盘,直接决定了崩溃恢复的粒度。

这里有一个典型误区:以为把缓冲池设置得足够大,就能减少刷盘频率。其实不然,缓冲池越大,意味着崩溃后需要重放的日志越多,恢复就越慢。检查点机制就是为了解决这个矛盾而存在的:定期把当前缓冲池里的脏页同步到磁盘,并记录一个 LSN(日志序列号),表示这个位置之前的日志都已经生效,崩溃恢复时只需要从检查点之后开始重放。

我在某个项目里就看到过这种配置:缓冲池开到 256GB,但 checkpoint 间隔设得特别长,好不容易系统跑得稳定了,一次意外断电直接恢复了一个多小时才起来。事后复盘,问题就出在检查点周期和缓冲池容量完全不匹配,日志累积量太大。

4.2 检查点类型选择:全量、增量、模糊,适用场景各不同

常见的检查点策略无非全量检查点(把所有脏页刷盘)、增量检查点(只刷上次检查点以来新产生的脏页)、模糊检查点(不要求精确边界,只记录一个 LSN 水位,后续边刷边推进)。全量最简单,但对系统的影响时间长,通常只出现在正常关机流程里;增量检查点把大任务拆成小块,适合持续运行的系统;模糊检查点最复杂,需要跟踪每个脏页的最小恢复 LSN,但灵活度最高。

我个人的建议是,首选增量检查点。让它在后台线程里持续运行,始终把脏页数量控制在一个阈值以下,同时动态调整刷盘速度。这个阈值不能拍脑袋定,要结合日志生成速率和磁盘写入带宽来估算。比如日志生成速率为 50MB/s,磁盘峰值写入能力为 400MB/s,那理论上每一秒可以刷掉约 350MB 内存脏页。如果脏页平均大小按 16KB 算,可能 1 秒内要完成大约 22000 个页面的刷盘。考虑到刷盘本身不是顺序写(要写回不同数据文件的位置),实际吞吐要打六到七折。按这个逻辑反过来设置检查点频率和脏页高水位,就不会出现日志积压到失控的情况。

4.3 刷盘线程与淘汰线程的协作关系

刷盘是由单独的后台线程完成的,但它和淘汰线程之间很容易互相干扰。淘汰线程把页面从 LRU 尾部摘走时,如果这个页面是脏的,必须等待它刷盘完成才能释放;刷盘线程如果优先处理尾部页面,就可以减少淘汰线程的等待时间。很多系统因此给了刷盘线程一个“优先从 LRU 尾部扫脏页”的策略,这样可以兼顾淘汰路径的顺畅和后写(write-behind)的效率。

实际操作中,还可以给脏页做一个分组管理:按 LSN 排序,尽量连续刷一批页面。这样做的好处是减少随机写,让磁盘 IO 模式更接近顺序写。但要注意,页面在数据文件中的物理位置并不一定和 LSN 顺序一致,所以“按 LSN 排序刷盘”和“按物理块顺序刷盘”是两种优化方向,一般只能顾一头。对于 SSD 来说,随机写的惩罚没那么大,按 LSN 排序更利于恢复效率;对于传统机械盘,物理顺序更重要。我建议先跑一下两种策略的基准测试,再决定默认方式。

5. 真实案例复盘:某订单系统的缓冲池调优全过程

5.1 现象:高峰期 TPS 下降,但 IO 和 CPU 都没有打满

这个案例来自我参与过的一个模拟项目,背景是一套订单查询系统,底层存储使用类数据库内核的单机版本。每年大促前都会做压测,某一年压测时发现一个奇怪现象:TPS 在并发数超过 200 之后不再增长,甚至略有下降;IO 使用率只有 60%,CPU 总体也就 55% 左右,一切看起来都还有余量,但吞吐就是上不去。

我当时第一反应是锁竞争,赶紧开了线程转储,发现大量线程阻塞在一个缓冲池实例的 LRU 锁上。进一步看,该实例的淘汰路径上有一个锁的持有时间异常长,平均每次持锁超过 40ms。这个时间足够做完好几轮磁盘读取了,只能说明锁的粒度或者使用方式出了问题。

5.2 定位过程:通过脏页比例和淘汰等待时间下手

我们加了两类监控:一是“LRU 尾页平均等待刷盘完成时间”,二是“脏页比例超过 70% 的持续时间”。没等多久,监空就给出了明确信号:LRU 尾部有大量脏页堆积,淘汰线程经常要等刷盘线程完成之后才能摘走页面。脏页比例长期在 75% 以上,而配置的刷盘线程数只有 2 个,明显不足。

于是做了两步调整:刷盘线程数先翻倍到 4 个,同时把检查点增量触发阈值从“累计 64GB 脏页”降到“累计 16GB 脏页”,让刷盘压力均匀分布到整个时间轴,而不是集中在检查点触发后的一波冲击里。这步操作之后,LRU 尾部等待时间从 40ms 降到了 5ms 左右,TPS 恢复了正常增长。

5.3 进一步优化:冷热数据分离与后台扫描节流

问题解决之后,我们想看看还有没有“白捡的性能”。当时业务有一个特点:每天凌晨会跑一次全量统计,扫描几十张大表,这个任务和在线交易是同一个缓冲池。我们当时还没有做 2Q 改造,只是临时加了一个“后台扫描限速”参数:后台扫描读取的页面不计入 LRU 热端,而是直接走冷端队列,并且控制它每秒最多只能读多少个页面。就这样一个很小的改动,让日间高峰期的命中率又提高了 4 个百分点。

如果要追溯本质,它其实就是我们前文提到的“准入门槛”思想的一个简化版本:不让低价值页面污染主缓存。从那次之后,我在评估缓冲池改造时就不会只盯着命中率一个数值,而是看整条链路上的等待分布。磁盘很快、CPU 很多,不代表缓冲池内部就一定是畅通的。

经验总结:缓冲池调优的核心不是“加大内存”,而是“让合适的数据以合适的时机留在内存里,并且让后台刷盘永远跑在淘汰的前面”。

结尾

做了这么多年数据库底层开发,我个人最大的体会是:缓冲池的性能问题从来不是单一参数造成的,它更像是内存分配、并发控制、日志处理和磁盘 IO 四股力量互相拉扯的结果。今天这次第三期的分享,重点放在了三块:页面置换策略的进阶设计、锁粒度与并发安全的拆解、检查点和刷盘路径的协作关系。这些都是代码里能直接落地的东西,而且每一点背后都有真实压测数据的支撑。

如果你正在调试自己的缓冲池,建议先加好前文提到的三维监控指标,再动手改策略。另外一个小技巧:调优时一次只动一个变量,改完跑一轮对比压测,不要把多个改动混在一起,否则出了问题你根本不知道是哪个改动导致的。缓冲池这个模块沉淀了数据库领域几十年的经验,值得慢慢打磨,希望这篇内容能帮你少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询