花间相见头像
【计算基础|MySQL18】Buffer Pool 与内存管理封面图

【计算基础|MySQL18】Buffer Pool 与内存管理

【计算基础|MySQL18】Buffer Pool 与内存管理

上一篇讲了三大日志,知道了 redo 先写内存再异步刷盘。那"内存里那块缓存数据的地方"到底是什么?就是 Buffer Pool。你以为 MySQL 每次查询都去读磁盘,其实 99% 的请求命中的是内存里的缓存页。Buffer Pool 命中率差一个百分点,QPS 可能腰斩;一次忘了带 WHERE 的全表 UPDATE,能把你辛苦缓存的热点页全挤出去——这就是缓存污染。

这篇把 Buffer Pool 的内部结构讲透:三个链表怎么分工、LRU 为什么要拆成年轻/老年区、脏页什么时候刷、Change Buffer 凭什么只给二级索引加速、双写缓冲怎么防止"页写一半断电"。所有状态在 Docker MySQL 8.0.46 上真实读取。

环境:Docker MySQL 8.0.46,innodb_buffer_pool_size=128MB(默认),通过 SHOW ENGINE INNODB STATUS 读取运行时结构。


一、为什么需要 Buffer Pool

磁盘一次随机 IO 是 ms 级(约 10⁻³ 秒),内存访问是 ns 级(约 10⁻⁹ 秒),差了整整 6 个数量级。如果每次读数据都走磁盘,数据库根本扛不住并发。

InnoDB 的策略很直接:把刚访问过的数据页缓存在内存里,以后再要就读内存,不再碰磁盘。而且不光读,写也是先改内存里的页(变成脏页),靠 redo log 保证不丢,后台慢慢刷盘。所以——

🔴 InnoDB 的读和写,全部先经过 Buffer Pool。它是 InnoDB 性能的命门。

mysql> SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
+-------------------------+-----------+
| Variable_name           | Value     |
+-------------------------+-----------+
| innodb_buffer_pool_size | 134217728 |   -- 128MB(默认)
+-------------------------+-----------+

⚠️ 生产建议:这个值默认才 128MB,生产库一般设成物理内存的 50%~70%( dedicated MySQL 机器)。设太小缓存命中率低,设太大可能挤压操作系统和连接内存。


二、Buffer Pool 的结构:三个链表

Buffer Pool 以**页(默认 16KB)**为单位缓存,数据页、索引页都往里放。内部用几条链表管这些页:

Buffer Pool(128MB,共 8192 个 16KB 页)

Free List
空闲页链表
(还没用的页)

LRU List
缓存页链表
(按访问热度排序)

Flush List
脏页链表
(改了还没刷盘的页)

链表管什么什么时候动
Free List还没被使用的空闲页要加载新页时从这里拿
LRU List已经缓存的页,按访问热度排序命中/加载页时调整顺序
Flush List被修改过、还没刷盘的脏页事务改页时入队,刷盘后出队

本机实跑 SHOW ENGINE INNODB STATUS 的 BUFFER POOL AND MEMORY 段:

BUFFER POOL AND MEMORY
----------------------
Buffer pool size   8192          ← 总页数(×16KB ≈ 128MB)
Free buffers       1024          ← Free List 上的空闲页数
Database pages     7153          ← LRU List 上已缓存的页数
Old database pages 2620          ← LRU 老年区的页数
Modified db pages  0             ← Flush List 上的脏页数
Pages made young 9105, not young 762185   ← 被提升到年轻区/没被提升的次数
Buffer pool hit rate 1000 / 1000           ← 命中率(千分比,这里是 100%)

✅ 成功标志:Buffer pool hit rate 应该长期维持在 990/1000 以上;如果掉到 900/1000 以下,说明缓存不够、大量读磁盘,要考虑加内存或调大 buffer pool。


三、LRU 的优化:中点插入 + 年轻/老年区

3.1 普通 LRU 为什么不行

操作系统课本里的 LRU(最近最少使用):新页插到队头,访问到的页提到队头,队列满了淘汰队尾。这套在数据库里有个致命问题——缓存污染。

设想你跑了一个全表扫描,要读 10 万个页。按普通 LRU,这些页全插到队头,把本来热门的业务页挤到队尾淘汰掉了。扫描完之后,系统又回到原来的热点查询,结果热点页全被淘汰了,又得重新从磁盘加载。一次大查询把整个缓存击穿。

3.2 InnoDB 的改进:分段 LRU

InnoDB 把 LRU 链表切成两段:

渲染错误: Mermaid 渲染失败: Parse error on line 3: ... Y2["..."] --- Y3[""] end subgra -----------------------^ Expecting 'TAGEND', 'STR', 'MD_STR', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'SQE'
  • young 区(前 5/8):真正的热数据,频繁访问。
  • old 区(后 3/8):冷数据。参数 innodb_old_blocks_pct=37 正好是 37%。

新页不插到队头,而是插到 old 区的头部(中点位置)。这样全表扫描读进来的页先落在冷区,就算被淘汰也只淘汰冷区的尾巴,碰不到年轻区的热点页。

还有第二道保险:一个页第一次被读到 old 区时,并不会立刻晋升到 young 区。要等 innodb_old_blocks_time=1000(1 秒)之后再次被访问,才晋升。全表扫描的页通常只读一次、读过去就不再碰,所以即使落进 old 区也不会占用宝贵的年轻区。

mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_pct';       -- 37(old 区占比)
mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_time';     -- 1000(毫秒)

🔴 一句话:中点插入挡住"大查询直接冲进热区",1 秒二次访问门槛挡住"只访问一次的页占热区"。两个机制配合防缓存污染。


四、脏页什么时候刷盘

页被修改后就成了脏页,待在 Flush List 里。什么时候刷回磁盘?

触发时机说明
redo log 写满推进 checkpoint,强制刷最老的脏页(否则 redo 没法覆盖)
Buffer Pool 不够用需要空闲页时,淘汰 LRU 尾部的脏页并刷盘
后台空闲page cleaner 线程在系统闲时主动刷
MySQL 正常关闭关机前把脏页全刷干净

刷盘由 page cleaner 线程负责(MySQL 5.6+ 可配多个 innodb_page_cleaners)。脏页比例有个水位线:

mysql> SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct';
+-----------------------------+-------+
| innodb_max_dirty_pages_pct  | 90.0  |   -- 脏页超过 90% 就积极刷盘
+-----------------------------+-------+

双写缓冲(Double Write Buffer):防止"页写一半断电"

刷脏页有个危险:一个 16KB 的页刷到一半机器断电,磁盘上这个页就坏了(torn page / partial write)。redo log 重放也救不回来——因为 redo 是"对页的增量修改",前提是页本身是完整的。

解法是双写缓冲:

崩溃恢复

脏页

先写共享表空间的
doublewrite buffer (2MB, 128页)

再写真正的数据文件

从 doublewrite 复制完整页
再重放 redo

先把页完整地写进共享表空间里一块连续的 doublewrite 区域,再写真正的数据文件。万一数据文件写一半断电,恢复时从 doublewrite 那块完整副本把页恢复成完整的,再重放 redo。

⚠️ 版本差异:MySQL 8.0.20 之前是这种集中式 doublewrite buffer(在 ibdata 里);8.0.20 开始废弃传统方式,改成每个表空间一个 .dblwr 文件。原理不变,都是先落一份完整副本再写数据文件。


五、Change Buffer:给非唯一二级索引的写加速

5.1 它解决什么问题

改一条记录时,对应的聚簇索引页一般已经在 Buffer Pool 里了(因为你是按主键查出来改的)。但二级索引页呢?二级索引通常有好几个,修改可能涉及其中某些页,而这些页很可能不在内存里。

如果每次改二级索引都要从磁盘把那个页读进来(随机 IO),写性能会很差。Change Buffer 的思路是:页不在内存时,先不读盘,把"我要对这个索引页做什么修改"记在 Change Buffer 里;等以后这个页因为别的查询被读进内存时,再把记录的修改合并(merge)进去。

在

不在

UPDATE/INSERT/DELETE
二级索引

目标索引页
在 Buffer Pool?

直接修改该页

记到 Change Buffer
(不读磁盘)

以后该页被读入时
merge 合并修改

5.2 为什么只对"非唯一"二级索引

  • 唯一索引不行:插一条记录必须立刻检查唯一性,而唯一性检查必须读到那个索引页——躲不开随机读,没法缓冲。
  • 聚簇索引不需要:聚簇索引按主键顺序写,新记录通常落在当前页附近,本身就是顺序写,没什么可缓冲的。

所以 Change Buffer 只服务于非唯一二级索引。

mysql> SHOW VARIABLES LIKE 'innodb_change_buffer_max_size';
+------------------------------+-------+
| innodb_change_buffer_max_size | 25    |   -- 最多占 Buffer Pool 的 25%
+------------------------------+-------+

🔴 适用场景判断:写多读少、非唯一二级索引多的表(比如日志、订单)收益大;读多写少的表,merge 时反而要额外读页合并,可能得不偿失。innodb_change_buffering 可控制缓冲哪些操作(all/inserts/deletes/purges/none)。


六、预读与自适应哈希索引

6.1 预读(Read-Ahead)

InnoDB 预判"你接下来会读哪些页",提前异步从磁盘读进来:

类型触发条件默认
线性预读顺序访问一个区(extent,1MB=64 页)里的页超过阈值(innodb_read_ahead_threshold=56),预读下一个区开
随机预读一个区里有较多页已在 Buffer Pool 且被访问,就把整区剩下的页读进来默认关(8.0 已移除,效果不稳定)

⚠️ 预读是把双刃剑:预读的页如果最后没用上,既白读磁盘又挤占 Buffer Pool(又是一种缓存污染)。

6.2 自适应哈希索引(AHI)

B+ 树找一条数据要走 3~4 层(3~4 次页访问)。InnoDB 发现某些页被频繁做等值查询时,会自动在内存里给这些页建一个哈希索引,把等值查询从 O(tree height) 变成 O(1)。

mysql> SHOW VARIABLES LIKE 'innodb_adaptive_hash_index';
+----------------------------+-------+
| innodb_adaptive_hash_index | ON    |   -- 默认开
+----------------------------+-------+

本机 SHOW ENGINE INNODB STATUS 里能看到 AHI 已经在干活:

INSERT BUFFER AND ADAPTIVE HASH INDEX
Ibuf: size 1, free list len 0, seg size 2, 0 merges
Hash table size 34679, node heap has 1 buffer(s)
...

⚠️ AHI 是自动的、不用你建,但它在高并发写入时可能成为瓶颈(哈希表要加锁保护),这种场景下可以考虑关掉。


七、其他内存结构与监控

除了 Buffer Pool,MySQL 还有一批每个会话/全局的内存区:

内存区作用注意
sort_buffer_sizeORDER BY 排序每个线程一份,连接数 × 它别太大
join_buffer_size无索引 join同上,每线程
tmp_table_size / max_heap_table_size内存临时表超了会落磁盘
table_open_cache表定义缓存全局

监控 Buffer Pool 主要靠 SHOW ENGINE INNODB STATUS 的 BUFFER POOL AND MEMORY 段(前面贴过),重点盯:

  • Buffer pool hit rate:应长期 > 990/1000;
  • Modified db pages(脏页数):对应脏页比例;
  • Free buffers:长期很低说明缓存紧张。

调优建议

  1. innodb_buffer_pool_size 设物理内存的 50%~70%。
  2. 内存很大(>10GB)时用 innodb_buffer_pool_instances 切成多个实例,每个实例独立 LRU、减少锁竞争,每个 instance 至少 1GB。
  3. 命中率低于 99% 要排查是不是 buffer pool 太小、或者有没有大查询污染。
  4. 脏页比例长期过高会导致刷盘跟不上、写入抖动。

总结:你真正需要记住的 7 件事

  1. Buffer Pool 是 InnoDB 的内存缓存,读写都先走它;生产建议设物理内存 50%~70%,单位是 16KB 的页。
  2. 内部三条链表分工:Free List(空闲页)、LRU List(缓存页按热度)、Flush List(脏页)。
  3. InnoDB 的 LRU 分年轻区(5/8)和老年区(3/8),新页中点插入,且 1 秒内二次访问才晋升年轻区——防全表扫描击穿热点缓存。
  4. 脏页在 redo 写满、Buffer Pool 不够、后台空闲、正常关闭时刷盘;双写缓冲防止"页写一半断电"。
  5. Change Buffer 只加速非唯一二级索引:页不在内存时先记录修改,等页被读入再 merge;唯一索引必须读页查重,缓冲不了。
  6. 预读可能带来缓存污染;AHI 自动给高频等值查询建哈希索引加速,高并发写时可能成瓶颈。
  7. 监控盯 SHOW ENGINE INNODB STATUS 里的命中率(>99%)、脏页数、空闲页数。

验证清单

  • 能说出 Buffer Pool 三条链表各自管什么,并解释脏页为什么在 Flush List 上
  • 能画出分段 LRU:年轻区/老年区比例、新页插在哪、二次访问门槛是多少
  • 能解释一次全表扫描为什么会击穿普通 LRU,InnoDB 两个机制怎么防
  • 能说清脏页的四种刷盘时机,以及双写缓冲解决什么问题(torn page)
  • 能解释 Change Buffer 原理,并回答"为什么唯一索引用不上 Change Buffer"
  • 知道 innodb_buffer_pool_size 生产设多大、多实例时每个 instance 至少多大
  • 会用 SHOW ENGINE INNODB STATUS 看 Buffer pool hit rate,并判断健康值

参考资源

  • MySQL 8.0 Reference Manual — The InnoDB Buffer Pool: https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html
  • MySQL 8.0 Reference Manual — Change Buffer: https://dev.mysql.com/doc/refman/8.0/en/innodb-change-buffer.html
  • MySQL 8.0 Reference Manual — Doublewrite Buffer: https://dev.mysql.com/doc/refman/8.0/en/innodb-doublewrite-buffer.html
  • MySQL 8.0 Reference Manual — Adaptive Hash Index: https://dev.mysql.com/doc/refman/8.0/en/innodb-adaptive-hash.html
  • MySQL 8.0 Reference Manual — Buffer Pool Configuration: https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool-resizing.html

下一篇《MySQL 架构与高可用入门》跳出单机,讲数据库怎么"成对活着":主从复制三个线程各干什么活、异步复制和半同步复制差在哪、为什么主库写得好好的从库却迟迟追不上(主从延迟)、读写分离怎么落地,以及 MGR、分库分表这些高可用与扩展方案的入门全景。所有主从状态都用 docker-compose 真实起一主一从跑出来。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Lvyizhuo/article/details/169266913

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--