数据库缓冲池系列写到第三篇,前两篇基本把缓冲池是什么、页在内存里怎么组织讲清楚了,这篇我想直接聊点实战型更强的东西:页面淘汰策略、并发访问控制,以及让我头疼过很多次的调优和排障。为什么把这三件事放在一篇里?因为实际跑库的时候,它们根本不是独立的——淘汰策略决定了缓存命中率,并发控制决定了高负载下的吞吐,而调优和排障恰恰是两个环节出问题时的最终归宿。
这篇更适合已经知道缓冲池大概是什么的读者。如果你完全零基础,建议先补一下“页、帧、缓冲池”这些概念,否则看到后面 LRU 改良和刷脏流程会有点蒙。有基础的可以直接跳到第 2 节往后看,里面的参数配置和排查思路是我在线上环境里实际验证过的。
1. 缓冲池到底在干什么:整体设计与核心机制
1.1 缓冲池的本质:内存与磁盘之间的“热缓存”
先把第一性原理摆出来:数据库的数据是存在磁盘上的,但磁盘随机 I/O 太慢了。机械硬盘一次随机读大概 5-10 毫秒,SSD 能到几十微秒,但和内存纳秒级的访问延迟比起来,仍然差了好几个数量级。如果每个 SQL 查询都直接去磁盘读页,那数据库的吞吐量基本就是个笑话,所以所有正经数据库都会在内存里留一块区域,把磁盘上的页缓存进来,这块区域就是缓冲池(Buffer Pool)。
可以把缓冲池理解成一个“热书架”。磁盘是资料库仓库,书成千上万本,但读者高频借阅的就那几十本。图书管理员(数据库)不可能每次都跑仓库翻书,于是把热门的书放到前台书架,前台书架就是缓冲池。读者来了,前台能查到就直接给,查不到才去仓库搬,搬来的新书也会放到前台,等下次有人用。
这个类比里最关键的指标就是“前台命中率”:一个查询请求需要的页,有多少比例是直接在前台书架找到的。命中率越高,磁盘 I/O 越少,系统越流畅。InnoDB 的默认页大小是 16KB,一个 64GB 的缓冲池大概能放 400 万个页,这 400 万个页就是你的“热书”容量。
1.2 三大核心数据结构:帧、页表、链表
缓冲池不是简简单单一块内存大饼,内部是有精细组织的。InnoDB 的实现里,缓冲池实际上由几组结构组成,理解这些结构你才能明白后面淘汰策略和并发控制的设计动机:
- 帧(Frame)数组:缓冲池物理上是一块连续内存,切成一个个帧,每个帧大小等于一个页(默认 16KB)。帧里除了存数据,还挂了一些元信息,比如这个帧装的是哪个表空间里的哪个页、脏没脏、被谁引用、在 LRU 链表里的位置。
- 页表(Page Table):本质上是一个哈希表,key 是“表空间 ID + 页号”,value 是指向对应帧的指针。每次要访问某个页,先查页表,如果 key 存在,说明页已经在缓冲池里,直接命中;否则就要去磁盘读。
- 链表(Lists):缓冲池里维护了几条链表。有空闲帧链表(free list),放着目前没用的帧;有 LRU 链表,记录了哪些页是热页、哪些页该被淘汰;还有脏页链表(flush list),记录哪些页被改过、等着刷回磁盘。
一次数据访问的完整路径是这样的:执行器要读某个页 → 查页表 → 命中,直接在帧上操作 → 未命中,从 free list 拿一个空闲帧 → 去磁盘读页到帧里 → 把帧挂到 LRU 链表 → 后续再访问这个页就直接命中。写路径类似,但会对帧打上脏标记并把它加入 flush list,等后台线程刷盘。
很多人第一次接触缓冲池时只关注了 LRU,忽略了页表和 free list 的作用。实际上页表决定了“查找”的效率,free list 决定了“新页”从哪里来,LRU 决定了“旧页”往哪里去,三条线配合才能保证数据库在任何时刻都有物理内存可用、都有办法快速定位一个页。
2. 页面淘汰策略:为什么朴素LRU在数据库里会翻车
2.1 朴素LRU的“污染”问题:一次全表扫描引发的血案
缓存空间有限,满了以后必须淘汰一些页,把位置让给新读进来的页。最经典、教科书级别的策略是 LRU(Least Recently Used,最近最少使用),原理很简单:最近被访问过的页放在链表头部,淘汰时从链表尾部丢。这个策略的逻辑很直觉——刚被用过的东西,短时间内大概率还会再用。
但数据库场景下,朴素 LRU 有个致命问题:顺序扫描污染。想象一下,你有一个几 TB 的库,日常线上业务访问的是最近几天的订单数据,这些热点数据大概只占几个 GB,缓冲池 64GB 完全够用,命中率能到 98% 以上。某天运维跑了一个全表大查询,把整张表从头到尾扫了一遍。按朴素 LRU 的规则,扫描过程中每个刚读进来的页都变成“最新访问”,直接被挪到链表头部。这张表如果直径几十 GB,一遍扫完,缓冲池里原本的热点订单数据全部被挤到了链表尾部,然后被一一淘汰。
等大查询跑完,线上业务瞬间变成“冷启动”,每个查询都打磁盘,原本 1 毫秒内返回的接口变成几百毫秒,数据库磁盘 I/O 直接拉满,连带着锁等待、连接堆积甚至雪崩。我在实际运维中看过太多次这种场景,很多人第一反应是“缓冲池不够大”,于是加内存,其实根子在于淘汰策略不够抗污染。
2.2 InnoDB改良LRU:中点插入与时间保护
MySQL InnoDB 的应对方式是改良版 LRU,核心思路是把 LRU 链表拆成两段:young 区和old 区。新读入的页不是放到链表最头部,而是先放到 young 区和 old 区的分界点,也就是 old 区头部。这个分界点默认在链表长度的 37% 位置,对应参数innodb_old_blocks_pct。
为什么是 37%?这个数值是从大量测试里搓出来的,既能保证新页有足够空间去“证明自己”,又不会让垃圾数据占据太多缓存。新页如果只是被读取一次,那它在 old 区待着,最终从 old 区尾部被淘汰;只有一种情况能让它进入 young 区——它被再次访问了,且两次访问的间隔超过了innodb_old_blocks_time(默认 1000 毫秒)。
这个 1000 毫秒的时间窗口很有讲究。全表扫描时,一个页被读进来后大概率会在短时间内又被扫描到,比如一次顺序扫描从头到尾,相邻页被访问的时间差通常远小于 1 秒。如果你把保护时间设成 1000 毫秒,这种“扫描式的重复访问”不会把页提升到 young 区,热点数据就保住了。反过来,如果时间设太短,比如 100 毫秒,SSD 上连续读的速度很快,页在 old 区还没待够就被误提升,污染问题又回来了。
实际配置上,如果业务里大查询比较多、全表扫描频繁,可以把innodb_old_blocks_time适当调大,2000 甚至 3000 毫秒都可以。但不要无限调大,因为有些页被提升后确实会带来长期缓存价值,时间窗太长会导致真正的热数据要等很久才能进入 young 区,冷数据反而占据更多缓存。
2.3 延伸策略:Clock、LFU与分区缓冲池
InnoDB 的改良 LRU 是很主流的一种方案,但不是唯一的。数据库领域还有一些常用策略值得了解,因为不同数据库引擎的倾向不同,理解它们的取舍逻辑,有助于你在多种数据库产品之间做技术判断。
Clock 算法(也叫二次机会算法)是朴素 LRU 的廉价近似。它用环形链表加一个访问位(reference bit)实现,指针像时钟一样循环扫描。扫描时如果访问位是 0,就淘汰该页;如果是 1,就清零并给这个页一次“复活”机会。它的优势是不用在做命中时调整链表节点位置,并发冲突少、开销低,适合大规模场景下做粗粒度近似。当年 PostgreSQL 早期版本的 buffer replacement 就采用过类似思路。
LFU 策略按“最近最不常使用”来淘汰,核心是记录每个页的访问频率。让更频繁被访问的页稳定保留在缓存里,思路看起来比 LRU 更合理,但有个问题叫“历史热点固化”:一个页可能某段时期特别火,后面再也没有人碰,LFU 会因为它以前火过而不肯淘汰它,导致缓存空间被废数据占着。所以现代 LFU 都需要做衰减,让最近频率的权重高于历史频率。
分区缓冲池是应对并发瓶颈和淘汰隔离的进阶思路。InnoDB 里的innodb_buffer_pool_instances就是干这个的。如果缓冲池特别大(比如 64GB),全库共用一条 LRU 链表,每次页面命中、淘汰都要抢一把全局锁,高并发下锁等待严重。拆成多个实例,每个实例有自己的 LRU、flush list 和页表分片,不同实例之间互不干扰,扩展性大幅提升。还有一个额外好处:一个实例里发生全表扫描污染,只影响它自己,其他实例的热数据不受牵连。
3. 高并发下的并发控制与后台刷脏
3.1 页表访问的并发模型:分桶锁与帧锁
缓冲池是共享资源,所有工作线程都在往里面读页、改页、淘汰页。如果不加控制,两个线程同时改一个页、或者一个线程正在读某个页另一个线程把它淘汰了,后果不堪设想。所以缓冲池内部有一套多层次并发保护机制。
第一层是页表(哈希表)的分桶锁。页表本质是哈希表,哈希表的每个桶(bucket)可以有自己的锁。两个线程如果查询的页落在不同桶里,查找过程完全并行;只有落在同一个桶时才会短暂等待。这比整张哈希表一把大锁要科学得多,基本上消除了页表查找层面的全局串行化。PostgreSQL 的 shared buffer 也用了类似策略,用多分区锁(partitioned lock)降低冲突。
第二层是帧锁(latch)。找到帧以后,对帧本身加锁,保证同一时刻只有一个线程可以修改帧里的内容。帧锁通常是读写锁:读操作加共享锁,多个线程可以同时读同一页;写操作加排他锁,必须等所有读锁释放。还有一个重要的“引用计数(pin)”机制——线程在访问一个页时会把帧的 pin count 加 1,哪怕这个页被 LRU 判定为可淘汰,只要 pin count 不为 0,就不能真正回收帧。这避免了“页面访问到一半被拆台”的竞态。
第三层其实已经超出缓冲池了,但和它强相关,就是B+Tree 的并发控制。虽然页级 latch 保证了单页操作安全,但一个索引结构跨多个页时,线程从一个页跳到另一个页可能遇到“页面分裂”“页面合并”这些结构变更,这时需要更上层协议(比如行锁、树锁或 LSN 校验)来配合。我在排查死锁问题时经常看到:大家只盯着行锁,忘了页级 latch 的获取顺序也会引发等待环路。InnoDB 里一个经典做法是保证 latch 的获取顺序和 B+Tree 的层级顺序一致(先上后下),避免反向加锁形成环路。
3.2 checkpoint与刷脏路径:崩溃恢复的生命线
内存页被修改后,有一个“脏页”的概念——它和磁盘上对应页的数据已经不一致了。脏页不能永远留在内存里,必须写回磁盘,否则一旦断电或进程崩溃,这些修改就丢了。但如果你每次修改都立刻写盘,一个普通 UPDATE 语句涉及多个页的多次修改,磁盘 I/O 频率会爆炸。所以数据库采用“延迟刷脏 + 日志先行”的组合拳。
具体来说:事务提交时,只需要把 redo log(重做日志)刷到磁盘,就保证崩溃后能从日志恢复;而脏页本身可以攒在缓冲池里,由后台线程按策略批量刷盘。这个由后台线程持续把脏页写回磁盘的过程就叫刷脏(flushing)。
这里有一个数据结构叫flush list(脏页链表),它按页第一次变脏的 LSN 顺序记录脏页。LSN 是日志序号,可以理解成“数据库变更的时间线坐标”。刷脏线程从 flush list 头部开始,按时间从早到晚把脏页写回磁盘。与此同时,系统会维护一个checkpoint 位置,表示“这个坐标之前的 dirty 页都已经安全落盘”。崩溃恢复时,数据库只需要从 checkpoint 位置往后重放 redo log,前面那些已经落盘的部分不需要再管。
InnoDB 的实际刷脏不是等缓存满了才动手。它有一组后台线程,每秒钟刷一定量的脏页,同时根据 redo log 的增长速度做动态调整:redo 生成越快,说明系统写入压力越大,刷脏速度也要跟着加快,否则 redo 很快会写满,导致系统进入“剧烈刷脏”状态,I/O 抖动非常严重。这个机制叫自适应刷脏(adaptive flushing)。
实操中我需要提醒一个问题:很多 DBA 喜欢把innodb_max_dirty_pages_pct调得很大,觉得这样能减少写盘次数。但这会让崩溃恢复时间变长,而且会在某些高峰触发一次性大量刷脏,造成磁盘 I/O 毛刺。我建议保持默认值(75%)附近,然后通过监控脏页比例变化趋势来判断压力,而不是盲目调这个参数。
3.3 预读机制:命中率之外的隐藏收益
预读是缓冲池读路径上一个非常容易忽略但又影响巨大的优化。它做的事情很简单:当数据库检测到当前读取模式是顺序的,就提前把后面还没被请求的页批量读入缓冲池。比如正在扫描某个大表的第一页,系统会算一下它大概率接下来要读第 2、3、4 页,于是提前读进来,避免后续每页都走一次磁盘 I/O 的往返延迟。
InnoDB 的线性预读有阈值控制,由innodb_read_ahead_threshold参数设置,默认 56,意思是同一个 extent(64 页)里连续被访问的页数达到 56 时,才触发预读。这个阈值在机械盘时代很重要,预读准一次能省很多次随机寻道,预读错一次就浪费了额外的磁盘带宽。到了 SSD 时代,随机 I/O 和顺序 I/O 的差距缩小,预读收益下降,而且错误预读会白白占用缓冲池空间。我的经验是:纯 SSD 环境下不用特别调这个参数,保持默认即可;但如果命中率异常低且磁盘读次数很高,可以查查预读统计,确认是不是预读率过低、读取过于分散导致预读失效。
预读还有一个副作用容易被忽略:预读进来的页如果最后没被用到,它们也在 LRU 链表里占了位置。好在 InnoDB 的预读页会进入 old 区(改良 LRU 的保护机制天然有效),不会冲击 young 区的热数据,这也是为什么我在第 2 节强调理解 LRU 结构能帮助你判断预读的伤害边界。
4. 缓冲池调优与排障实录
4.1 关键参数怎么定:实例数、大小、时间窗
调优缓冲池,最核心的参数就那么几个,我一个个说,每个都给出我的实操经验而不是干贴手册。
innodb_buffer_pool_size(总大小):这是最直观的参数。对专用数据库服务器,经验值是物理内存的 50%-70%。比如物理内存 128GB,缓冲池可以给到 64GB-96GB。但要注意,MySQL 不止缓冲池要内存,还有 redo log、binlog、临时表、连接线程、操作系统页缓存等,全加起来很容易顶满。我见过一台 256GB 的机器把缓冲池设成 200GB,结果操作系统开始换页,数据库反而变慢十倍。请求 CPU 使用前先算一笔总账:缓冲池 + 连接数*连接占用 + 各种日志缓冲 + 文件系统缓存余量,留出至少 10%-20% 的内存余量再定缓冲池大小。
innodb_buffer_pool_instances(实例数):大内存机器一定要拆。经验上每个实例不小于 1GB。比如缓冲池 64GB,拆成 8 个实例(每个 8GB)或 16 个实例(每个 4GB)都可以。注意:如果缓冲池小于 1GB,这个参数默认是不生效的。拆分的收益在并发高时非常明显,锁竞争少,性能更平滑。我一个线上库从单实例改成 8 实例后,高峰期 p99 延迟降低了 20% 以上,没有改任何别的参数。
innodb_old_blocks_time(时间窗):偏重“防扫描污染”的参数。线上如果 OLTP 为主,默认 1000 毫秒没问题;如果混合负载里有不少报表分析、全表扫描,调到 2000-3000 毫秒效果更好。我在一个数据仓库场景里用过 5000 毫秒,热点命中率反而提升了 3 个百分点。
innodb_flush_neighbors(刷脏邻居):这个参数决定刷脏时要不要把相邻页一起写。机械盘时代建议开启,因为顺序写能减少寻道时间;SSD 时代建议设置为 0 关闭,否则容易造成写放大。很多人在 SSD 上没动这个默认值,我看到过某实例写量翻倍的例子,排查到最后就是因为刷脏连带写了不必要的相邻页。
配置示例(MySQL 8.0 / InnoDB):
[mysqld] innodb_buffer_pool_size = 64G innodb_buffer_pool_instances = 8 innodb_old_blocks_time = 1000 innodb_old_blocks_pct = 37 innodb_max_dirty_pages_pct = 75 innodb_flush_neighbors = 04.2 日常监控指标:命中率、脏页率、free pages
调参之前先看数据。我平时看三个指标最多,都是用现成命令就能拿到。
缓冲池命中率(Buffer Pool Hit Rate):SHOW ENGINE INNODB STATUS\G里能看到一个百分比。这个命中率表示“读请求不需要从磁盘取页的比例”。低于 95% 需要警惕,持续低于 90% 基本可以说缓冲池不够用或者存在缓存污染。但注意,命中率不是越高越好,99% 以上也不见得是好事,很多时候说明热点数据集远小于缓冲池,其实还有余量去承载更多业务。
脏页比例(Modified db pages / Buffer pool size):通过 Infomation Schema 或SHOW ENGINE INNODB STATUS可查。脏页比例长期高(比如超过 50%)说明刷脏跟不上写压力,迟早会发生“强制刷脏”造成 I/O 尖刺。我看到脏页比例升高时,第一反应是去看 redo log 的写入速率,通常问题不在刷脏线程,而在写入负载本身。
Free buffers(空闲帧数量):缓冲池每帧状态分 free、clean、dirty 三种。如果 free buffers 数量持续为 0,说明缓冲池满负荷运转,靠 LRU 淘汰在腾位置。这个状态下命中率如果还很高,问题不大;如果命中率也不高,那就说明空间配置和访问模式不匹配,需要扩容或优化查询。
实际排查时我还会借助性能表:
-- 查看缓冲池整体状态 SELECT * FROM information_schema.INNODB_BUFFER_POOL_STATS\G -- 查看各实例的详细统计 SELECT * FROM information_schema.INNODB_BUFFER_PAGE_LRU ORDER BY POSITION LIMIT 10;INNODB_BUFFER_PAGE_LRU表能直接看到每个页在 LRU 链表里的位置、访问次数、是否是脏页。想定位某张表某类页是否污染了缓存,这个表是利器。不过生产环境谨慎全表扫这张表,数据量很大,建议加条件(比如只看 specific 表空间)。
4.3 热词引发的延伸:Windows分页缓冲池与非分页缓冲池内存问题排查
最近“分页缓冲池”和“非分页缓冲池”的讨论热度很高,尤其 Windows 系统用户反映这两类内存占用过高。这里需要澄清一下:它和数据库的缓冲池虽然中文名字都带“缓冲池”,但根本不是一个层面的东西。数据库缓冲池是数据库进程内部管理的数据页缓存;而 Windows 的分页缓冲池和非分页缓冲池是操作系统内核区域,属于系统级内存管理。
简单区分一下:分页缓冲池(Paged Pool)是可以被换出到磁盘的内核内存,比如部分文件系统元数据、注册表缓存、对象管理器结构;非分页缓冲池(NonPaged Pool)则是必须常驻物理内存的内核内存,因为中断处理例程在任意时刻都可能被调用,不能发生缺页中断,所以这些对象比如中断对象、DPC 队列、某些驱动分配的常驻内存都必须留在物理内存里。
热词里“非分页缓冲池占用很高怎么解决”是非常典型的 Windows 驱动排查问题。如果你在任务管理器的“性能 → 内存”页看到内核内存里非分页缓冲池占用持续飙升,比如超过几百 MB 甚至几个 GB,那几乎可以认定有内核级内存泄漏。我的排查步骤是这样的:
- 打开“任务管理器 → 性能 → 内存”,先看内核内存部分。如果非分页缓冲池数值持续增长,高度怀疑某个驱动或内核组件在分配内核池内存后没有释放。
- 使用 Windows 驱动工具包里的PoolMon(池监视器)工具,按 pool tag 统计哪些标签的内存占用在增长。这能精确到是哪个驱动程序模块申请的内存。比如
PoolMon /g可以按 tag 分组统计。 - 锁嫌疑犯。常见的泄漏源是网卡驱动、显卡驱动、存储控制器驱动、杀毒软件过滤驱动、某些带监控功能的第三方工具。逐个禁用可疑驱动或服务,观察非分页缓冲池是否停止增长。
- 更新硬件驱动到最新稳定版,尤其是芯片组和网卡驱动。我碰到过一个案例,某型号网卡驱动在 Win11 下导致非分页缓冲池每天涨 1.5GB,更新驱动后问题消失。
- 如果无法定位,先用
verifier(驱动验证器)或联系厂商获取诊断版本,但生产环境慎用重启大法——重启只能临时清空,不解决根源。
数据库服务器如果部署在 Windows 上且出现非分页缓冲池异常,会有个明显的表现:数据库进程本身内存占用正常,但系统整体内存不够,导致物理内存换页频繁,数据库读写的响应时间突然变差。这时先查内核池占用,而不是急着给数据库加内存,不然加多少都不够。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 缓冲池命中率从 98% 骤降到 70% | 全表扫描或一次性大查询污染缓存 | 调整innodb_old_blocks_time,对大查询做限流或改走备库 |
| 脏页比例长时间超过 50%,I/O 频繁抖动 | 刷脏跟不上写入速度,redo log 接近打满 | 检查写入负载,适当调高innodb_io_capacity,必要时扩容磁盘带宽 |
| free buffers 持续为 0 且命中率低 | 缓冲池配置过小,访问模式和容量不匹配 | 调大innodb_buffer_pool_size,同时检查是否存在无索引查询导致大量扫描 |
| 峰值时段出现大量 page allocation failure 类报错 | 缓冲池实例数过少,或内存碎片化严重 | 增加innodb_buffer_pool_instances,重启后重新分配内存池 |
| 系统层面非分页缓冲池占用异常升高 | 内核驱动或系统组件内存泄漏 | PoolMon 按 tag 定位,更新驱动,禁止可疑服务 |
| 缓冲池命中率正常但物理读很高 | 预读失效或访问模式过于分散 | 查看innodb_buffer_pool_read_ahead统计,检查查询是否走索引 |
5. 个人实操的一些心得
最后分享一个我自己踩过的坑。有一段时间负责一个电商系统的 MySQL 实例,32 核 128GB 内存,缓冲池配置了 64GB,但命中率始终徘徊在 88% 左右。按道理这个量级的热点数据根本用不了 64GB,怎么也不该这么低。我一开始以为是参数不对,试了各种 old_blocks_time 的调整,效果都不明显。
后来用INNODB_BUFFER_PAGE_LRU排查,发现问题根本不是缓存放不下,而是一个埋点表每 5 分钟被全表扫一次读取当天数据,这个表足够大,每次扫描都在把缓冲池里的订单热数据往外挤。我没动缓冲池大小,而是给这个埋点查询的 SQL 加了索引并改成只取增量数据。改完后命中率直接跳到 97% 以上,磁盘 I/O 降了四成,高峰期查询延迟肉眼可见地改善。
这个经历想说明的是:缓冲池调优很多时候不是内存不够,而是访问模式太差。参数只是工具,先读懂自己的负载再动手,比什么都重要。如果这篇文章能帮你少走一次全表扫描污染缓存的弯路,那就算没白写。