
前两周帮朋友排查一个 MySQL 实例现象是磁盘 IO 飙得很高慢查询暴涨业务量却没什么变化。一圈绕下来定位到根因特别朴素innodb_buffer_pool_size开得太小热索引页一直在被持续挤出内存读请求不断打到磁盘上。把缓冲池调大后IO 立刻降下来业务恢复平稳。每次遇到这种问题我都会感慨InnoDB 的内存结构如果只停留在缓冲池是用来缓存数据页的这个层面出状况时基本只能靠猜。这篇文章我想把这块内容完整梳理一遍Buffer Pool 怎么组织、三条链表分别干什么、Change Buffer 和自适应哈希索引在什么场景下才真正有价值、Log Buffer 多大合适、以及字典缓存和锁结构这些容易被忽略的后勤开销。每个部分都会配上实际会用的监控命令和参数方便直接抄。1. Buffer Pool一切数据访问的主战场1.1 页面与控制块的匹配关系InnoDB 存储和读写的最小单位是页默认 16KB也就是innodb_page_size。Buffer Pool 里放的就是从磁盘加载进来的数据页和索引页副本服务器要读某一行数据本质上是把这行所在的页加载到内存再从页里定位记录。很多人忽略了配套存在的一个东西控制块buffer control block。每个内存页都对应一个控制块里面记录这个页当前的状态、属于哪个表空间和页号、最近访问信息、脏页标记、链表指针等。控制块本身也有内存开销每个页大约要多出几百字节到 1KB 左右。这部分内存会计入 Buffer Pool 的总占用量所以你在算16KB * 页面数的时候实际会比这个大一圈。还有一点容易遗漏Buffer Pool 里不止有数据页和索引页Undo 页、系统页以及 Change Buffer 使用的页也都在里面。所以像缓存满了但磁盘还有大量空间这种说法其实不太准确更贴切的说法是Buffer Pool 里能被用户数据使用的页不够了。1.2 三条链表Free、LRU、Flush 各司其职InnoDB 维护了三套核心链表来管理缓冲页理解它们之间的关系比记一堆参数重要得多。Free List 是空闲页链表。新页面要从磁盘载入内存时InnoDB 会先从 Free List 里取一个空闲控制块和页如果 Free List 已经空了就要走淘汰流程。LRU List 是最近最少使用链表用来决定哪些页可以被淘汰。新加载的页放到 LRU 的特定位置页被访问后位置会移动接近链表尾部的页就是最久没被用到的优先被逐出。Flush List 是脏页链表按最早修改的 LSN 顺序排列。脏页是指内存页内容和磁盘页内容不一致的页后台刷盘线程Page Cleaner就是按 Flush List 的顺序把脏页写回磁盘的。这三条链表的关系要理清一个页可以同时在 LRU List 和 Flush List 上因为最近被访问和是脏页待刷盘是两个维度的事。但一个页不可能同时存在于 Free List 和 LRU List。淘汰一个脏页时需要先把它刷盘然后从 Flush List 摘下再从 LRU List 尾部移入 Free List。这也是为什么脏页太多会直接拖慢淘汰速度导致新的读请求可能要等待空闲页。1.3 冷热分区与一次全表扫描的教训如果 LRU 是简单的新页插头部老页从尾部淘汰那一次全表扫描会把几乎所有热页冲掉。扫描页虽然只访问一次却会占据链表头部把真正高频访问的页挤到尾部淘汰掉。这个场景在报表查询、批量导出任务里太常见了。InnoDB 的解决方案是把 LRU 拆成 young 区和 old 区。默认innodb_old_blocks_pct为 37意思是链表尾部约 37% 是 old 区。新加载的页不是直接进链表头部而是先进入 old 区。只有当页面在 old 区存活时间超过innodb_old_blocks_time默认 1000ms之后再次被访问才会被提升到 young 区头部。这个设计的妙处在于全表扫描的页通常只被访问一次在 old 区待的时间远不到 1000ms就算被扫到了也不会污染热区。遇到系统性的大批量扫描任务我一般会临时把innodb_old_blocks_time调大比如改成 2000 或 5000任务结束后再恢复默认值。注意这是全局参数要评估对在线业务的影响。1.4 多实例与 Chunk大缓冲池的并发设计单一大缓冲池在并发高时会有锁竞争InnoDB 用innodb_buffer_pool_instances把缓冲池切分成多个实例每个实例有自己独立的 Free、LRU、Flush 链表各实例之间互不干扰。实例数默认是 1当 Buffer Pool 总量比较大时某些版本会按平台自动调整为多个。实际配置时如果缓冲池超过 8GB我通常会拆分到 4 到 8 个实例。还有一个隐藏参数innodb_buffer_pool_chunk_size默认 128MB。Buffer Pool 的实际大小会被圆整到chunk_size * instances的整数倍所以你在 my.cnf 里写的数字不一定等于最终生效值。例如设置 6GBchunk 默认 128MB、实例数是 4实际分配时是按 128MB 对齐之后的结果。这个参数默认值就够用不建议乱动。Buffer Pool 支持在线调整大小MySQL 8.0 里可以对innodb_buffer_pool_size执行 SET GLOBAL 动态修改。但要注意大跨度伸缩涉及页拷贝和内存分配可能造成短暂卡顿必须在低峰期操作。配合innodb_buffer_pool_dump_at_shutdown和innodb_buffer_pool_load_at_startup重启后可以按上次关闭前记录的页号把热数据提前载入减少冷启动阶段的读放大。1.5 怎么判断 Buffer Pool 到底够不够最容易掌握的方法是看命中率。SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read%;对应指标是Innodb_buffer_pool_read_requests总的读请求次数和Innodb_buffer_pool_reads真正落到磁盘的读次数。命中率大致可以按这个公式估算命中率 (read_requests - reads) / read_requests * 100%更细的信息可以查information_schema.INNODB_BUFFER_POOL_STATSSELECT POOL_ID, POOL_SIZE, FREE_BUFFERS, DATABASE_PAGES, OLD_DATABASE_PAGES, MODIFIED_DATABASE_PAGES, PAGES_READ_REQUESTS, PAGES_READ FROM information_schema.INNODB_BUFFER_POOL_STATS;DATABASE_PAGES是当前装入的用户数据页数量FREE_BUFFERS是空闲页数量MODIFIED_DATABASE_PAGES是脏页数量。如果FREE_BUFFERS长期接近 0说明缓冲池经常在满负荷运转如果OLD_DATABASE_PAGES占DATABASE_PAGES的比例明显偏离 37% 左右说明扫描类访问很频繁。SHOW ENGINE INNODB STATUS里也能看到 Buffer Pool 的详细输出包含Pages made young、not young、youngs/s这类指标。年轻化比例高通常说明热数据在频繁换入换出这时候调大缓冲池往往能立竿见影。2. Change Buffer把随机写先攒起来的策略2.1 它到底解决了什么问题对二级索引的写操作有个天然痛点如果目标索引页不在 Buffer Pool 里理论上每次插入或删除都要先把二级索引页从磁盘读进内存再修改页。一张表有 5 个二级索引时插入一行理论上可能触发 5 次随机读IO 成本很高。Change Buffer 的思路是先把这类针对二级索引的变更缓存在内存里不立即去读那个索引页等这个页后续真的被读进 Buffer Pool 时再把攒下来的变更合并进去。用个不太严谨但好懂的类比不是每次买东西都立刻去银行取钱而是先记账等哪天顺路经过银行再一次性结清。Change Buffer 只对非唯一二级索引生效。唯一索引不行因为必须立即检查唯一性约束等不了。它支持的操作包括 INSERT、DELETEdelete mark以及 UPDATE 内部拆出来的 delete mark insert 两步。2.2 收益边界什么时候收益最大Change Buffer 的收益前提是目标索引页大概率不在内存里。所以它最适合这样的环境表有多个二级索引、写入负载高、数据总量明显超过 Buffer Pool、磁盘是传统机械盘或随机 IO 能力一般。反过来如果大部分索引页已经被 Buffer Pool 缓存住了Change Buffer 就没什么用反而多一层内存管理和合并开销。还有一种常见误区Change Buffer 是不是越大越好不是。它占用 Buffer Pool 的一部分容量如果开得太大会挤占数据页可用的缓存空间。默认innodb_change_buffer_max_size是 25表示最多占 Buffer Pool 的 25%取值范围 0 到 50。维护经验里还有一个容易被忽略的点Change Buffer 里的变更不会马上刷盘所以如果同一个索引页在很长时间内都没有被读到后台合并线程会积压不少操作。一旦某个大查询或分析任务恰好把那些页全读进来可能触发一瞬间的合并风暴IO 峰值非常高。如果线上已经出现这种规律性抖动可以考虑调小innodb_change_buffer_max_size或者把读密集型报表挪到业务低峰执行。2.3 监控与参数注意 discarded operations看 Change Buffer 状况最直接的是SHOW ENGINE INNODB STATUS里面有个INSERT BUFFER AND ADAPTIVE HASH INDEX区域不同版本字段名略有差异会输出类似Ibuf: size 1, free list len 3, seg size 2, 5 merges merged operations: insert 4, delete mark 1, delete 0 discarded operations: insert 0, delete mark 0, delete 0重点观察merged operations与discarded operations。合并操作说明变更被有效应用了discarded 则意味着有变更因为页面状态等原因被丢弃这会直接退化成随机读写。如果 discarded 持续增加就要考虑调整 Change Buffer 上限甚至排查是否存在大批量乱序写。3. 自适应哈希索引给 BTree 加的外挂3.1 它是怎么自适应的InnoDB 的聚簇索引和二级索引本质是 BTree定位一个字段通常要沿着根、枝、叶走好几层。自适应哈希索引Adaptive Hash IndexAHI是 InnoDB 根据实际访问模式自动在 BTree 上叠加的哈希索引针对等值查询给出近似 O(1) 的定位能力。它自适应体现在不是 DBA 手动创建的而是引擎观察到某些索引页被反复按相同模式访问后自动为这些页建立哈希映射。哈希表建立在 Buffer Pool 里的页之上不占用额外的数据页内存但自身管理结构会有少量内存开销。MySQL 8.0 里该功能默认开启。它能加速的场景主要是等值匹配比如主键点查、带有高选择性索引列的等值查询。对范围扫描、排序、LIKE 这类操作没有帮助因为它们本质上是顺序遍历哈希结构帮不上忙。3.2 收益与开销什么时候关闭反而更快AHI 在单线程或低并发点查场景下收益很明显。但高并发场景里哈希索引结构的访问需要加 latch如果并发线程数非常多latch 竞争可能超过它带来的访问收益。老版本里这是著名的瓶颈点MySQL 8.0 以后通过innodb_adaptive_hash_index_parts把哈希索引拆成多个分区来缓解竞争默认 8 个分区。我遇到过的真实案例某个高并发连接数上万的场景关闭 AHI 后吞吐量反而提升了几个百分点。原因是业务查询全是范围扫描AHI 根本帮不上忙latch 开销倒是实打实的。反过来如果业务大量是主键点查AHI 的价值就很高别轻易关。怎么判断值不值得开SHOW ENGINE INNODB STATUS里能看到 AHI 相关内存和 Hash table 统计Performance Schema 的 memory 表里也能查到具体内存使用。更实用的做法是直接做 A/B 对比在低峰期用SET GLOBAL innodb_adaptive_hash_index OFF关掉跑一段压测或观察高峰表现再决定是否保持关闭。注意这个参数是动态的但突然翻转会影响缓存中已有哈希结构的构建和销毁建议低峰期操作。4. Log Buffer事务提交链路上的内存阀门4.1 redo 从内存到磁盘走了多远InnoDB 使用 Write-Ahead Logging事务修改数据页时先把改动记成 redo log再把数据页落盘。Redo log 的源头是内存里的 Log Buffer。事务执行过程中min-transactionmtr产生的 redo 记录会先拷贝到 Log Buffer提交时才需要把 Log Buffer 内容写到 redo log 文件并落盘。innodb_flush_log_at_trx_commit决定了提交时到底刷到哪里值 1每次提交都写 redo log 并做 fsync最安全默认值不能随便改值 0提交时不主动写由后台每秒刷一次性能好但可能丢最近 1 秒数据值 2每次提交写入操作系统缓存但 fsync 由后台每秒执行数据库崩溃不丢操作系统崩溃可能丢MySQL 还引入了组提交group commit多个事务的提交可以攒成一批共享一次 fsync。这样即使每条提交都要求刷盘实际落盘次数也会大幅减少。4.2 Log Buffer 多大才够Log Buffer 默认 16MB相比 Buffer Pool 小得多但也别完全无视。看全局状态里的Innodb_log_waits如果持续大于 0说明事务产生的 redo 速度超过了写入 redo log 文件的速度Log Buffer 经常被撑满。撑满时 InnoDB 只能被迫提前写 log事务会被拖慢。典型场景是大事务批量更新、大量 LOB 字段写入、或者单事务跨了太多行。遇到这种情况把innodb_log_buffer_size调到 32MB、64MB 甚至 128MB 通常是安全的Log Buffer 占的内存远小于 Buffer Pool不会有明显副作用。日志文件大小、checkpoint 频率、脏页刷盘这些跟 Log Buffer 是联动的调日志缓冲时最好也看一眼 redo log 文件规模和检查点覆盖范围。5. 字典缓存与锁内存容易被忽略的后勤开销5.1 数据字典在 MySQL 8.0 里的变化MySQL 8.0 之前表结构等信息还有部分放在文件系统层8.0 之后整套数据字典都收纳进 InnoDB 存储字典表存在系统表空间里。内存中自然要维护对应的字典缓存表对象、列定义、索引元数据会被缓存在内存里方便快速访问。这部分内存通常不大但有一个边界场景要注意单实例表数量特别多几十万张表时字典缓存占用会变得可观。运维这类实例时内存规划不能只算 Buffer Pool。5.2 行锁结构如何吃内存InnoDB 的行锁不是一行一个标志位那么轻量。每次加锁都会在内存里分配锁结构记录锁、间隙锁、next-key lock 都有对应的内存对象而且持有期间不会释放。一个长事务锁住几万行、几十万行时内存占用会线性增长。典型的失控场景是执行一个大范围 UPDATE但 where 条件没走好索引导致 InnoDB 对大量行加锁。排查时用SELECT * FROM performance_schema.data_locks;能看到当前事务持有哪些锁。内存占用可以通过 Performance Schema 的 memory 表查SELECT EVENT_NAME, CURRENT_NUMBER_OF_BYTES_USED FROM performance_schema.memory_summary_global_by_event_name WHERE EVENT_NAME LIKE memory/innodb/lock%;我处理类似问题时核心思路是减小锁的覆盖范围优化 SQL 用对索引、长事务拆批、避免一个事务里做大量无关写操作。锁内存虽然不是一个可以直接设置上限的池子但它会直接挤压系统的可用内存不能放着不管。5.3 一张表理清 InnoDB 的内存占用内存区域默认形态关键参数常用观测手段Buffer Pool128MBinnodb_buffer_pool_size / instances / chunk_sizeINNODB_BUFFER_POOL_STATS、SHOW ENGINE INNODB STATUSChange Buffer最多占 Buffer Pool 25%innodb_change_buffer_max_sizeSHOW ENGINE INNODB STATUS 的 IBUF 段自适应哈希索引默认开启innodb_adaptive_hash_index / partsSHOW ENGINE INNODB STATUS 的 AHI 段Log Buffer16MBinnodb_log_buffer_sizeInnodb_log_waits 状态字典缓存动态增长无直接参数performance_schema memory锁结构动态增长无直接参数data_locks、memory/innodb/lock这里列的都是 InnoDB 自己管理的内存区域。MySQL server 层还有线程、表缓存、排序缓冲、连接缓冲等规划innodb_buffer_pool_size时千万别把机器物理内存全吃光要给系统和其它组件留出余量。6. 遇到问题时的排查顺序结合上面的结构我整理了一套自己的检查顺序遇到数据库慢了但是不知道跟内存有没有关系的情况可以照着走一遍。第一步先看磁盘读是不是异常偏高。Innodb_buffer_pool_reads如果持续增长且和Innodb_buffer_pool_read_requests的比值变大说明读穿透严重优先考虑 Buffer Pool 容量和热数据加载问题。第二步看脏页与刷盘节奏。查MODIFIED_DATABASE_PAGES和SHOW ENGINE INNODB STATUS里的 Flush List 相关指标。如果脏页长期堆积并且磁盘写压力大问题可能不在容量而在刷盘策略。第三步查 redo 相关的等待。SHOW GLOBAL STATUS LIKE Innodb_log_waits一旦持续放大别犹豫先评估 Log Buffer 是否过小。第四步查锁和长事务。performance_schema.data_locks和innodb_trx配合看有没有单个事务持锁时间过长锁覆盖范围过大。第五步兜底检查是否被扫描类查询冲击了缓存。此时回看Pages_made_young、Pages_not_young指标并结合慢查询日志确认是否有全表扫描任务周期性出现。这套流程基本能覆盖 80% 和 InnoDB 内存相关的性能问题。核心是带着每一块内存到底在解决什么问题的视角去定位而不是一慢就盲目调大缓冲池。参数改起来容易但只有搞清楚内存结构之间的关系改完之后才不至于按下葫芦浮起瓢。