ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据库缓冲池实战:页面淘汰、并发控制与调优排障

数据库缓冲池实战:页面淘汰、并发控制与调优排障 数据库缓冲池系列写到第三篇前两篇基本把缓冲池是什么、页在内存里怎么组织讲清楚了这篇我想直接聊点实战型更强的东西页面淘汰策略、并发访问控制以及让我头疼过很多次的调优和排障。为什么把这三件事放在一篇里因为实际跑库的时候它们根本不是独立的——淘汰策略决定了缓存命中率并发控制决定了高负载下的吞吐而调优和排障恰恰是两个环节出问题时的最终归宿。这篇更适合已经知道缓冲池大概是什么的读者。如果你完全零基础建议先补一下“页、帧、缓冲池”这些概念否则看到后面 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的“污染”问题一次全表扫描引发的血案缓存空间有限满了以后必须淘汰一些页把位置让给新读进来的页。最经典、教科书级别的策略是 LRULeast 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就不能真正回收帧。这避免了“页面访问到一半被拆台”的竞态。第三层其实已经超出缓冲池了但和它强相关就是BTree 的并发控制。虽然页级 latch 保证了单页操作安全但一个索引结构跨多个页时线程从一个页跳到另一个页可能遇到“页面分裂”“页面合并”这些结构变更这时需要更上层协议比如行锁、树锁或 LSN 校验来配合。我在排查死锁问题时经常看到大家只盯着行锁忘了页级 latch 的获取顺序也会引发等待环路。InnoDB 里一个经典做法是保证 latch 的获取顺序和 BTree 的层级顺序一致先上后下避免反向加锁形成环路。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意思是同一个 extent64 页里连续被访问的页数达到 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 RateSHOW 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 降了四成高峰期查询延迟肉眼可见地改善。这个经历想说明的是缓冲池调优很多时候不是内存不够而是访问模式太差。参数只是工具先读懂自己的负载再动手比什么都重要。如果这篇文章能帮你少走一次全表扫描污染缓存的弯路那就算没白写。
返回列表