InnoDB 的 Buffer Pool:MySQL 的性能其实全押在这块内存上

举报
yd_254019812 发表于 2026/11/03 13:46:10 2026/11/03
【摘要】 一条 SQL 慢,90% 的情况可以归到一句话:它需要的页不在内存里。 索引、执行计划、锁、事务,最后都要落到 16 KB 的页上,而页在不在 Buffer Pool 里,决定了这次查询是 0.1 ms 还是 10 ms。 结论先说 | 问题 | 答案 | |---|---| | Buffer Pool 到底装了什么 | 数据页、索引页、undo 页、change buffer、自适应哈希索...

一条 SQL 慢,90% 的情况可以归到一句话:它需要的页不在内存里。

索引、执行计划、锁、事务,最后都要落到 16 KB 的页上,而页在不在 Buffer Pool 里,决定了这次查询是 0.1 ms 还是 10 ms。

结论先说

| 问题 | 答案 |

|---|---|

| Buffer Pool 到底装了什么 | 数据页、索引页、undo 页、change buffer、自适应哈希索引——全是 16 KB 的页 |

| 命中率多少算正常 | OLTP 场景 > 99%;低于 99% 说明内存不够,别急着加索引 |

| 为什么白天很稳、晚上突然抖 | 脏页比例撞红线 + redo 空间吃紧,刷脏阻塞了用户线程 |

| 内存给了 32 GB 还慢 | 可能 instances/chunk_size 配错,或者被全表扫描把热数据挤出 LRU |

| 加完索引为什么还卡 | 大表 DDL 要扫全表,会把 Buffer Pool 冲一遍,热数据全丢 |

下面从页讲起,一路推到刷脏和抖动。


一、InnoDB 的基本单位:16 KB 的页

InnoDB 管理磁盘的最小单位是 page,默认 16 KB(innodb_page_size,可选 4K/8K/16K/32K/64K,建库后不可改)。

一个索引页的大致布局:

┌──────────────────────────────────────────────┐
│ File Header (38B)     页号、页类型、LSN、校验和 │
├──────────────────────────────────────────────┤
│ Page Header (56B)     槽数、记录数、堆顶、层级   │
├──────────────────────────────────────────────┤
│ Infimum / Supremum    两条虚拟边界记录          │
├──────────────────────────────────────────────┤
│ User Records          真实行记录(单向链表)     │
├──────────────────────────────────────────────┤
│ Free Space            未分配空间(向中间生长)   │
├──────────────────────────────────────────────┤
│ Page Directory        稀疏槽位,每 4~8 条一组   │
├──────────────────────────────────────────────┤
│ File Trailer (8B)     校验和 + LSN 低 32 位     │
└──────────────────────────────────────────────┘

两个关键点:

  1. 页内记录是单向链表,但页目录是稀疏索引。页内查找先二分页目录(槽),再在槽内顺序遍历——这就是为什么单页内记录不能太多,16 KB 页在行宽 1 KB 时大约只放十几行。
  2. 页是有 LSN 的。每次修改都会把当前 LSN 写进页头,崩溃恢复时靠它判断这个页是否需要重放 redo。

Buffer Pool 就是这么一个"页的缓存",键是 (space_id, page_no),值是一个 16 KB 的帧(frame)加一个控制块。


二、Buffer Pool 的内部结构:三个链表 + 一个哈希表

                    ┌─────────────┐
   磁盘读入的页  →   │  free list  │  空闲帧
                    └──────┬──────┘
                           │ 取一帧装载页
                    ┌──────▼──────────────────┐
                    │        LRU list          │
                    │  young (5/8) │ old (3/8) │  ← 所有页都在这
                    └──────┬──────────────────┘
                           │ 页被修改
                    ┌──────▼──────┐
                    │ flush list  │  脏页(按 oldest_modification 排序)
                    └─────────────┘

            哈希表:page hash,(space_id, page_no) → 帧指针,O(1) 定位

| 结构 | 作用 | 出问题时的症状 |

|---|---|---|

| free list | 尚未使用的空闲帧 | 空了就要淘汰,Innodb_buffer_pool_wait_free 开始涨 |

| LRU list | 所有已加载的页,分 young/old 两代 | 全表扫描会导致热数据被挤出,命中率断崖 |

| flush list | 脏页(内存与磁盘不一致) | 太长说明刷脏跟不上,checkpoint 被迫推进 |

| page hash | 页定位 | —— |

LRU 的改良:分代 + 停留时间

原生 LRU 有个致命问题:一条全表扫描的 SQL 会把热数据全部挤出去(经典的"缓存污染")。

InnoDB 的做法是把 LRU 切成两段,新页只插入 old 区头部(midpoint insertion):

LRU 尾部 ←── old 区 (3/8) ──┬── young 区 (5/8) ──→ LRU 头部
   (最冷,优先淘汰)        │        (最热)
                        插入点

配合 innodb_old_blocks_time(默认 1000 ms):一个页插入 old 区后,1 秒内被再次访问也不会晋升到 young 区。

这一条直接决定了:全表扫描扫过的页会在 old 区里被快速淘汰,不会污染 young 区的热数据。所以大表扫一遍,热数据基本还在——但如果你把 innodb_old_blocks_time 设成 0,这个保护就失效了。

内存怎么分

innodb_buffer_pool_size        = 24G      # 专用机器给物理内存的 60%~75%
innodb_buffer_pool_instances   = 8        # 大内存多实例,减少 mutex 争用
innodb_buffer_pool_chunk_size  = 128M     # 分配粒度

三个参数有强约束:最终大小会被向上取整到 chunk_size × instances 的整数倍。

比如 size=10G, instances=8, chunk=128M:128M × 8 = 1G,10G 正好是 10 倍,没问题。但如果 size=10G, instances=16, chunk=256M,每份 4G,实际会分配 12G——你以为给了 10G,实际占了 12G,容器里很容易因此被 OOM Kill。

另外,Buffer Pool 之外还有一块常被忽略的内存:

| 组件 | 默认值 | 说明 |

|---|---|---|

| change buffer | Buffer Pool 的 25% | innodb_change_buffer_max_size |

| log buffer | 16 MB | innodb_log_buffer_size |

| 自适应哈希索引 | 自动 | innodb_adaptive_hash_index,高并发下可能是争用源 |

所以"Buffer Pool 24G"在一台 32G 的机器上其实不够,还要留连接、排序缓冲、临时表、OS page cache 的空间。


三、命中率怎么算,多少算健康

SELECT
  ROUND(100 * (1 - reads / read_requests), 2) AS hit_rate_pct,
  reads, read_requests
FROM (
  SELECT
    (SELECT variable_value FROM performance_schema.global_status
      WHERE variable_name = 'Innodb_buffer_pool_reads')          AS reads,
    (SELECT variable_value FROM performance_schema.global_status
      WHERE variable_name = 'Innodb_buffer_pool_read_requests')   AS read_requests
) t;

口径解释:

  • read_requests:逻辑读次数(每次访问一个页算一次,不管在不在内存)
  • reads:物理读次数(必须从磁盘读的页数)

判读标准:

| 命中率 | 判断 | 动作 |

|---|---|---|

| > 99.5% | 健康 | 无需处理 |

| 99% ~ 99.5% | 轻微不足 | 观察是否周期性掉坑(一般是刷脏或大查询) |

| < 99% | 内存明显不够 | 先加 innodb_buffer_pool_size,再看是不是有全表扫描 |

| < 95% | 严重不足 | 大概率是内存配小了,或数据集远大于内存 |

⚠️ 注意:这个指标是实例启动以来的累计值,跑了几天的实例会把它平滑掉。要看瞬时值,隔 10 秒采两次做差,或者用 mysqladmin extended-status -r。

再看空闲页:

SHOW ENGINE INNODB STATUS\G
-- 关注 BUFFER POOL AND MEMORY 段落:
-- Free buffers        剩余空闲页
-- Database pages      数据页数量
-- Modified db pages   脏页数量(超过 75% 就该警惕)
-- Pending reads/writes 正在进行的 IO
-- Buffer pool hit rate 最近一段的命中率(这里是瞬时值,更有参考性)

Innodb_buffer_pool_wait_free 只要不为 0,就说明曾经出现过"要淘汰页但一个可淘汰的都没有、只能等刷脏"的情况——这是最直接的抖动信号。


四、Change Buffer:二级索引的写合并

这是 InnoDB 里最反直觉的一个组件。

场景:给一张 5000 万行的表建了二级索引 idx_city,然后插入一条 city='杭州' 的记录。这条记录要插到二级索引的某个叶子页上,但那个页不在内存里。

普通做法:把页从磁盘读进来 → 修改 → 标记脏。一次随机读。

InnoDB 的做法:如果这个索引不是唯一索引,就把这次修改记到 change buffer 里(也是一棵 B+ 树,存在表空间里),不读页。等以后这个页因为其他原因被读进内存时,再把 change buffer 里的修改 merge 进去。

| 条件 | 能否使用 change buffer |

|---|---|

| 普通二级索引 | ✅ 可以 |

| 唯一二级索引 | ❌ 不行(必须读页判重) |

| 聚簇索引(主键) | ❌ 不行 |

| 页已在 Buffer Pool 中 | 不需要(直接改内存) |

这解释了两个常见现象:

  1. 唯一索引比普通索引写入慢:不是"判重"这个动作慢,而是每次写都要把页读进来,随机 IO 数量级差别。
  2. 批量导入时 change buffer 会涨:SHOW ENGINE INNODB STATUS 里的 Ibuf: size 1, free list len ... 就是在看它。如果 change buffer 满了(默认占 Buffer Pool 25%),写性能会掉。
innodb_change_buffer_max_size = 25   # 写多读少的场景可调到 50

反过来,如果业务是写少读多(比如配置表),把它调小甚至关掉,可以省下内存给数据页。


五、刷脏:抖动的真正来源

5.1 脏页是怎么产生的

UPDATE t SET c = c + 1 WHERE id = 1;
    ↓
1. 页若不在内存 → 从磁盘读入(随机读)
2. 直接改内存里的页 → 页变"脏",挂到 flush list
3. 同时写 redo log(顺序写,很快返回)

注意第 2 步:修改内存页是极快的,写 redo 是顺序的,两者都不慢。 真正的成本被推迟到了"脏页刷盘"。

这就是 WAL(Write-Ahead Logging)的核心交易:用顺序写换掉随机写,把随机写推迟到后台批量做。

5.2 什么时候必须刷

| 触发条件 | 说明 |

|---|---|

| redo 空间不足 | redo 是循环写的,checkpoint 不推进就没地方写了 → 强制刷脏(最危险) |

| 脏页比例超阈值 | innodb_max_dirty_pages_pct(默认 90%)+ 低水位 _lwm(默认 10%) |

| LRU 尾部需要腾页 | 要淘汰的页是脏页 → 必须先刷才能复用 |

| 正常后台刷脏 | 自适应刷脏按 innodb_io_capacity 控制速率 |

5.3 为什么会"抖"

刷脏本身是后台线程干的,正常不影响用户请求。但有两个坑:

坑一:LRU 尾部扫描被阻塞。

当 free list 空了、要淘汰的页又恰好是脏页时,用户线程必须等这一页刷完才能拿到帧。这就是 Innodb_buffer_pool_wait_free 的来源,表现为偶发的、几十到几百毫秒的长尾,而且往往集中在业务高峰。

坑二:redo 空间不够导致的"急刹车"。

MySQL 8.0.30 之后用 innodb_redo_log_capacity(默认 100 MB)替代了 innodb_log_file_size × innodb_log_files_in_group。默认的 100 MB 对写密集的业务非常小:

写入速率 20 MB/s  → 100 MB 只够撑 5 秒

一旦 redo 快满,InnoDB 会同步阻塞所有用户线程去刷脏推进 checkpoint——这就是那种"整库卡住 2 秒又自己好了"的现象。

innodb_redo_log_capacity = 4G          # 写密集至少给到"峰值写入 × 30 秒"
innodb_io_capacity       = 2000        # SSD 可以给 5000~10000
innodb_io_capacity_max   = 4000        # 通常是 io_capacity 的 2 倍
innodb_max_dirty_pages_pct      = 75   # 别用默认 90
innodb_max_dirty_pages_pct_lwm  = 10

innodb_io_capacity 是最容易被忽略的参数。 它告诉 InnoDB "这块盘每秒能扛多少 IO",默认 200 是给机械盘的值。跑在 NVMe 上却留着 200,等于让后台刷脏按自行车的速度干活——脏页越积越多,最后撞红线。


六、预热:重启后的第一小时最慢

Buffer Pool 是内存,实例重启就空了。冷启动后命中率会从 0 慢慢爬,爬升期间所有查询都是物理读。

两个办法:

办法一:官方 dump/load(推荐)

innodb_buffer_pool_dump_at_shutdown = ON    # 关机时把页号列表写到 ib_buffer_pool
innodb_buffer_pool_load_at_startup  = ON    # 启动时按列表预读
innodb_buffer_pool_dump_pct         = 100   # 8.0 支持只 dump 最热的一部分

注意:dump 的只是页号清单,不是数据本身。启动后仍需按清单把页读进来,这段时间 IO 会很重,建议放在低峰重启。

办法二:手动预热

-- 把主键索引全扫一遍,强制加载进 Buffer Pool
SELECT COUNT(*) FROM orders FORCE INDEX (PRIMARY);

对大表来说这一句可能要跑几分钟,但比让线上查询一篇一篇去缺页要划算。


七、几个由此推导出来的结论

1)为什么 UUID 主键会拖慢写入

UUID v4 是随机的,插入位置随机分布在 B+ 树各处 → 每次插入都可能命中一个不在内存的页 → 物理读 + 页分裂 + 脏页散落。这就是上一篇《为什么一张 MySQL 表到千万行就变慢》里说的"页分裂"的成本来源。自增主键则永远写最右页,几乎全部命中内存。

2)为什么加索引会让线上变慢

ALTER TABLE ADD INDEX 要扫全表建索引,过程中大量页被读入 Buffer Pool。虽然有 innodb_old_blocks_time 保护,但扫描产生的脏页会挤占 flush list,导致正常的刷脏节奏被打乱。大表 DDL 建议用 pt-online-schema-change 或 gh-ost,并且避开高峰。

3)为什么"内存加了一倍,性能没变"

先确认三件事:命中率是否本来就 > 99.5%(那就不是内存问题);innodb_buffer_pool_instances 是否太多导致每个实例太小;瓶颈是否其实在 CPU 或锁等待(SHOW ENGINE INNODB STATUS 里 ROW OPERATIONS 段的 queries inside InnoDB 和锁等待时间)。


八、一分钟体检清单

-- 1. 命中率(瞬时:隔 10 秒采两次)
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

-- 2. Buffer Pool 概况
SHOW ENGINE INNODB STATUS\G   -- BUFFER POOL AND MEMORY 段

-- 3. 是否有等空闲页
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_wait_free';

-- 4. 脏页比例
SELECT
  (SELECT variable_value FROM performance_schema.global_status
     WHERE variable_name='Innodb_buffer_pool_pages_dirty') /
  (SELECT variable_value FROM performance_schema.global_status
     WHERE variable_name='Innodb_buffer_pool_pages_total') AS dirty_ratio;

-- 5. change buffer 占用
SHOW GLOBAL STATUS LIKE 'Innodb_ibuf%';

| 指标 | 红线 | 处理 |

|---|---|---|

| 命中率 | < 99% | 加 buffer_pool_size |

| wait_free | > 0 且持续增长 | 调大 io_capacity,或加内存 |

| 脏页比例 | > 75% | 同上;检查是否有大事务批量写 |

| Innodb_ibuf_merges 增长极快 | —— | 写密集场景正常,关注 ibuf 是否满 |


九、小结

InnoDB 的性能模型可以压缩成一句话:

所有操作都在内存的页上完成;磁盘只负责两件事——把页读进来,把脏页写回去。

读的性能取决于"页在不在",写的性能取决于"刷脏跟不跟得上"。

理解这一点之后,很多"玄学"就有解释了:冷启动慢是页不在;长尾抖动是刷脏阻塞;唯一索引写慢是要读页;UUID 主键慢是页散落。剩下的全是参数。


系列内链

  • 前置:[05-数据库与MySQL/01-为什么一张MySQL表到千万行就变慢](01-为什么一张MySQL表到千万行就变慢.md)
  • 关联:[05-数据库与MySQL/06-explain执行计划详解](06-explain执行计划详解.md)——执行计划决定了要访问哪些页
  • 关联:[05-数据库与MySQL/02-redo-log-undo-log与binlog](02-redo-log-undo-log与binlog.md)——脏页刷盘为什么必须等 redo 先落盘
  • 关联:[10-Linux运维与性能/03-磁盘IO分析iostat与iotop](../10-Linux运维与性能/03-磁盘IO分析iostat与iotop.md)——iowait 高的时候,先看是不是刷脏
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。