ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL InnoDB内存结构全解析:从Buffer Pool到Log Buffer的优化实践

MySQL InnoDB内存结构全解析:从Buffer Pool到Log Buffer的优化实践

前两周帮朋友排查一个 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 里写的数字不一定等于最终生效值。例如设置 6GB,chunk 默认 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_STATS:

SELECT 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、DELETE(delete 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. 自适应哈希索引:给 B+Tree 加的"外挂"

3.1 它是怎么"自适应"的

InnoDB 的聚簇索引和二级索引本质是 B+Tree,定位一个字段通常要沿着根、枝、叶走好几层。自适应哈希索引(Adaptive Hash Index,AHI)是 InnoDB 根据实际访问模式自动在 B+Tree 上叠加的哈希索引,针对等值查询给出近似 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-transaction(mtr)产生的 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 STATUS
Change 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 内存相关的性能问题。核心是带着"每一块内存到底在解决什么问题"的视角去定位,而不是一慢就盲目调大缓冲池。参数改起来容易,但只有搞清楚内存结构之间的关系,改完之后才不至于按下葫芦浮起瓢。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进