数据库基础体系 · 第 86/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

MySQL Buffer Pool 与内存结构:页缓存、刷脏、Change Buffer 和 AHI

在 InnoDB 中,一条 SQL 从磁盘读取数据、修改索引页,到事务提交和崩溃恢复,通常会经过多个内存结构:

SQL
 │
 ▼
Buffer Pool 中的数据页和索引页
 │
 ├── 读缓存:减少磁盘读取
 ├── 脏页:内存中的页已被修改,尚未写回数据文件
 ├── Change Buffer:暂存部分二级索引修改
 └── AHI:为部分重复索引查找建立的自适应哈希结构

这些结构解决的问题不同:

结构 主要作用 是否持久化 典型对象
Buffer Pool 缓存 InnoDB 页,并承载页修改 否,重启后重新加载 数据页、聚簇索引页、二级索引页
脏页 表示内存页与磁盘页不一致 否,修改最终要写回数据文件 被 INSERT、UPDATE、DELETE 修改的页
Change Buffer 延迟部分不在内存中的二级索引页修改 间接持久化 非唯一二级索引的部分变更
AHI 加速部分重复的等值索引查找 否,可重建 B+Tree 索引搜索路径的哈希辅助结构

理解这些组件,必须先明确 InnoDB 的基本单位:


一、页是 InnoDB 读写和缓存的基本单位

1. 页、行和索引记录不是同一个概念

InnoDB 的表和索引以 B+Tree 形式组织。B+Tree 的节点通常存放在固定大小的页中:

  • 叶子页保存数据记录或二级索引记录;
  • 非叶子页保存索引目录和子节点指针;
  • 页是磁盘文件和 Buffer Pool 之间的基本交换单位;
  • 一次读取通常读取一个页,而不是只读取一行。

默认情况下,InnoDB 页大小通常为 16 KiB。具体值由实例初始化时的 innodb_page_size 决定,不能像普通运行时参数一样随意修改。

例如,表有一个聚簇索引:

CREATE TABLE account (
    id BIGINT PRIMARY KEY,
    user_name VARCHAR(64) NOT NULL,
    balance DECIMAL(18, 2) NOT NULL
) ENGINE = InnoDB;

一次按主键查询:

SELECT balance
FROM account
WHERE id = 100;

逻辑上需要:

  1. 从聚簇索引根页开始;
  2. 经过若干非叶子页;
  3. 找到包含 id = 100 的叶子页;
  4. 从叶子页中的聚簇索引记录读取 balance

如果所需页已经在 Buffer Pool 中,查询可以避免磁盘 I/O。如果不在,则 InnoDB 读取包含目标记录的整个页。

2. Buffer Pool 缓存的不是“SQL 结果”

Buffer Pool 保存的是 InnoDB 页,例如:

  • 聚簇索引页;
  • 二级索引页;
  • Undo 页;
  • 系统表空间或独立表空间中的其他 InnoDB 页;
  • 部分 Change Buffer 相关页。

它不保存“某条 SQL 的结果集”。因此下面两条查询可能复用相同的索引页:

SELECT * FROM account WHERE id = 100;
SELECT balance FROM account WHERE id = 100;

但是否复用全部页,取决于访问路径、页是否仍在缓存中,以及查询所需的列是否位于相应的索引记录中。


二、Buffer Pool 的核心职责:缓存页并承载修改

1. Buffer Pool 的基本数据流

一个典型的数据页访问过程如下:

请求访问页 P
    │
    ├── P 在 Buffer Pool 中
    │       └── 直接使用内存中的 P
    │
    └── P 不在 Buffer Pool 中
            ├── 从表空间读取 P
            ├── 放入空闲页,或淘汰某个可淘汰页
            └── 返回 P

如果被淘汰的页是干净页,可以直接丢弃内存副本。

如果被淘汰的页是脏页,则不能直接丢弃,必须先将其写回表空间,或者等待其他刷脏流程完成。

2. Buffer Pool 中的几类状态

从概念上看,一个页可能处于以下状态:

不在内存
   │ 读取
   ▼
内存中的干净页
   │ 修改
   ▼
内存中的脏页
   │ 刷回
   ▼
内存中的干净页
   │ 淘汰
   ▼
不在内存

这几个状态要区分:

  • 干净页:内存副本与数据文件中的页一致;
  • 脏页:内存副本已经修改,但数据文件中的对应页还不是最新版本;
  • 刷脏:将脏页写回数据文件的过程;
  • 淘汰:从 Buffer Pool 中移除一个页,以便释放缓存空间。

刷脏不等于淘汰。一个脏页刷回后,仍然可以继续留在 Buffer Pool 中作为干净页。

3. LRU 不是简单的“最久未使用”

Buffer Pool 使用 LRU 类似的页管理策略,但 InnoDB 并不是一个简单的单链表 LRU。

常见实现中,Buffer Pool 的页列表分为:

  • young 子列表:近期或频繁访问的页;
  • old 子列表:新读入或不希望立即污染热点区的页。

新读入的页通常先进入 old 区域附近,而不是直接成为最热页。这样可以降低全表扫描对已有热点页的污染。

如果一个页在合适的条件下再次被访问,它可能被提升到 young 区域。一次完整扫描如果读取大量一次性使用的页,则这些页不应把长期热点页全部挤出缓存。

这解释了一个常见现象:

Buffer Pool 命中率高,不一定代表所有查询都快;命中率低,也不一定代表数据库一定有问题。

原因包括:

  • 查询可能命中的是低价值的扫描页;
  • 查询可能因为随机访问而产生大量页读取;
  • 一次查询可能扫描大量不重复使用的页;
  • 工作集可能本来就大于 Buffer Pool;
  • SQL 还可能受 CPU、锁、排序、网络和日志写入影响。

4. 多个 Buffer Pool 实例

当 Buffer Pool 较大时,InnoDB 可以划分多个实例:

innodb_buffer_pool_size = 64G
innodb_buffer_pool_instances = 8

这不是把数据复制八份,而是将缓存管理结构分区,降低多个线程访问同一管理结构时的竞争。

需要注意:

  • innodb_buffer_pool_instances 影响的是管理分区,不改变页的逻辑内容;
  • 实例数量过多会使每个实例过小;
  • 具体可用性、动态调整能力和推荐范围取决于 MySQL 版本及运行时约束;
  • innodb_buffer_pool_size 不是 mysqld 进程的全部内存预算。

三、一次查询如何使用 Buffer Pool

考虑以下表:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    KEY idx_customer_created (customer_id, created_at)
) ENGINE = InnoDB;

执行:

SELECT id, created_at, amount
FROM orders
WHERE customer_id = 42
ORDER BY created_at
LIMIT 20;

典型访问过程是:

  1. 读取 idx_customer_created 的根页;
  2. 沿非叶子页查找 customer_id = 42 的范围;
  3. 顺序读取二级索引叶子页;
  4. 从二级索引记录中取得主键 id
  5. 如果 amount 不在二级索引中,则根据主键回到聚簇索引;
  6. 读取包含这些主键记录的聚簇索引页。

第 5 步就是回表。它可能导致:

  • 二级索引页命中,但聚簇索引页不命中;
  • 访问多个分散的聚簇索引页;
  • 随机 I/O 或较多 Buffer Pool 页置换。

如果改为覆盖索引:

ALTER TABLE orders
    DROP INDEX idx_customer_created,
    ADD KEY idx_customer_created_amount
        (customer_id, created_at, amount);

查询可能只需要访问二级索引页,而不必回到聚簇索引。但索引更宽,会带来:

  • 更多磁盘空间;
  • 更多 Buffer Pool 占用;
  • INSERT、UPDATE、DELETE 时更多索引页维护;
  • 更高的刷脏和日志压力。

因此,Buffer Pool 优化不能脱离访问路径和索引设计单独讨论。


四、修改页、Redo Log 与刷脏

1. 修改通常先发生在内存页

假设事务执行:

START TRANSACTION;

UPDATE account
SET balance = balance - 10.00
WHERE id = 100;

COMMIT;

在简化模型中,过程如下:

  1. 找到 id = 100 所在的聚簇索引页;
  2. 如果该页不在 Buffer Pool,从表空间读取;
  3. 在内存页中修改 balance
  4. 将这次页修改生成 Redo Log;
  5. 根据事务提交配置处理 Redo Log 的持久化;
  6. 数据页本身可以稍后再刷回表空间。

因此,事务提交并不意味着包含这行数据的整个数据页已经写入数据文件。

2. 为什么可以先提交、后刷数据页

InnoDB 使用 Write-Ahead Logging,简称 WAL。它要求:

在将某个脏页写入数据文件之前,与该页修改对应的 Redo Log 必须已经安全写入日志文件。

定义:

  • page LSN:页最近一次修改对应的日志序列号;
  • flushed LSN:已经刷到持久介质或达到相应持久性边界的 Redo Log 位置;
  • checkpoint LSN:数据文件已经能够反映到的恢复边界。

写脏页时,需要满足近似条件:

page_lsn <= flushed_lsn

否则,数据库可能先把数据页写入磁盘,却没有可靠保存对应的恢复日志,破坏崩溃恢复前提。

3. 提交和持久性的边界

innodb_flush_log_at_trx_commit 控制事务提交时 Redo Log 的处理方式。常见语义如下:

提交时行为 典型持久性取舍
1 每次提交都将日志写入并刷到操作系统/存储设备所要求的边界 持久性最强,通常开销更高
2 每次提交写入操作系统缓存,定期刷到设备 操作系统或 mysqld 崩溃时的风险边界不同
0 提交时不立即写日志,定期由后台线程处理 性能取舍更激进,可能丢失更多已提交事务

精确的故障结果还取决于:

  • 操作系统;
  • 文件系统;
  • 存储设备是否真正遵守 flush;
  • sync_binlog
  • 是否启用二进制日志;
  • 复制和组提交配置。

因此,“事务已经提交”与“数据页已经写入表空间”不是同一个事件,“客户端收到成功”与“跨所有故障类型绝不丢失”也不是同一个保证。

4. 脏页如何被刷回

脏页主要通过以下路径被写回:

  1. Buffer Pool 后台刷脏线程;
  2. 自适应刷脏;
  3. 空间不足或页淘汰前的强制刷脏;
  4. 检查点推进;
  5. 数据库关闭或其他维护过程。

刷脏的目标不是简单地“把所有脏页尽快写完”,而是在以下目标之间平衡:

  • 保持足够的空闲页;
  • 控制 Redo Log 的 checkpoint age;
  • 避免大量随机写;
  • 降低读写相互阻塞;
  • 避免接近日志容量上限时突然爆发刷盘。

可以用近似关系理解检查点压力:

checkpoint_age = current_lsn - checkpoint_lsn

其中:

  • current_lsn 是当前产生 Redo Log 的位置;
  • checkpoint_lsn 是数据页已经推进到的恢复边界;
  • 两者差值代表仍需通过日志恢复的修改量。

checkpoint_age 接近可用 Redo Log 容量时,InnoDB 必须加快刷脏,否则无法继续无限产生新日志。此时可能看到:

  • 写入延迟升高;
  • 事务提交变慢;
  • I/O 利用率升高;
  • 应用出现周期性抖动。

因此,单纯增大 Buffer Pool 不一定解决写入瓶颈。更大的缓存可能容纳更多脏页,但如果 Redo Log、存储写入能力或刷脏速度没有相应匹配,压力可能只是延后出现。


五、一个完整的脏页算例

假设:

  • Buffer Pool 中有页 P
  • P 的当前页 LSN 为 1000;
  • 事务 T1 修改 P,产生 Redo Log 到 LSN 1100;
  • 事务 T1 提交;
  • 此时数据文件中的 P 仍是旧版本。

状态变化如下:

初始:
Buffer Pool.P.page_lsn = 1000
磁盘.P.page_lsn        = 1000
checkpoint_lsn         = 1000

T1 修改:
Buffer Pool.P.page_lsn = 1100
磁盘.P.page_lsn        = 1000
P 变为脏页

T1 提交:
Redo Log 至少需要达到相应持久性边界
Buffer Pool.P 仍可能保持脏状态

后台刷脏:
先确认 Redo Log 已安全到达足够位置
再写入数据文件 P

刷脏完成:
Buffer Pool.P.page_lsn = 1100
磁盘.P.page_lsn        = 1100
P 变为干净页
checkpoint_lsn 可以推进

如果 mysqld 在刷脏之前崩溃,只要 Redo Log 满足持久性要求,恢复过程就可以根据日志把数据文件中的旧页重做到 LSN 1100 附近。

反例:不能跳过 WAL 前提

如果数据页已经写入了 LSN 1100 的内容,但对应 Redo Log 尚未安全保存,之后发生断电,则恢复时可能找不到完成该页修改所需的日志。

这正是 InnoDB 不允许违反 WAL 顺序的原因。WAL 不是“优化技巧”,而是崩溃恢复正确性的基础。


六、Change Buffer:延迟部分二级索引页修改

1. Change Buffer 解决什么问题

假设一个事务向表中插入一行:

INSERT INTO orders(id, customer_id, created_at, amount)
VALUES (10001, 42, '2025-01-01 10:00:00', 99.00);

这次插入至少可能修改:

  1. 聚簇索引页;
  2. idx_customer_created 二级索引页。

如果目标二级索引叶子页当前不在 Buffer Pool,直接修改它需要先把整个索引页读入内存。这会产生一次随机读。

对于某些二级索引修改,InnoDB 可以把变更先记录到 Change Buffer,而不立即读取目标索引页。等目标页以后因为查询或后台合并被读入时,再将这些变更合并进去。

简化数据流:

修改非唯一二级索引页,目标页不在 Buffer Pool
        │
        ├── 直接读入并修改目标页
        │
        └── 写入 Change Buffer
                │
                ├── 后续目标页被读入
                └── 后台触发合并

它的核心收益是:

用一次较便宜的变更记录,延迟一次可能昂贵的随机页读取。

2. 哪些索引修改可以被缓冲

Change Buffer 主要用于:

  • 非唯一二级索引;
  • 目标索引页当前不在 Buffer Pool;
  • 可安全延迟处理的索引变更。

它不能简单理解成“所有索引写入都可以延迟”。

通常不能按这种方式处理的包括:

  • 聚簇索引修改;
  • 唯一二级索引需要进行唯一性检查的情况;
  • 目标页已经在 Buffer Pool 中时,通常直接修改该页;
  • 不满足 Change Buffer 适用条件的索引操作。

唯一索引需要确认是否存在冲突。例如:

CREATE UNIQUE INDEX uk_user_email ON user_account(email);

插入新邮箱时,数据库必须确认索引中没有相同键值。若目标唯一索引页不在内存,不能仅凭“以后再合并”就完成唯一性判断,否则无法立即正确处理重复键错误。

3. Change Buffer 不是事务提交队列

Change Buffer 不等于:

  • 应用层消息队列;
  • 事务提交后异步执行的普通任务;
  • 可以无限增长的写缓存;
  • 代替 Redo Log 的持久化机制。

它是 InnoDB 内部用于部分二级索引页变更的结构,并且自身也需要通过 InnoDB 的页和日志机制得到保护。

事务的可见性、锁和提交语义仍然由 InnoDB 事务系统保证。Change Buffer 只是改变了某些索引页物理修改发生的时间。

4. Change Buffer 的合并过程

假设二级索引叶子页 S 不在 Buffer Pool:

初始:
S 不在 Buffer Pool
Change Buffer 中没有针对 S 的变更

事务插入:
S 仍不在 Buffer Pool
Change Buffer 记录:
    insert secondary-key(K), primary-key(PK)

之后查询 S 所在索引范围:
读取 S 到 Buffer Pool
将 Change Buffer 中属于 S 的变更合并到 S
再使用合并后的页完成索引访问

也可能由后台线程在合适时机合并,而不是等到用户查询恰好访问该页。

合并会产生实际工作:

  • 读取目标索引页;
  • 应用缓冲的插入、删除标记或其他支持的变更;
  • 生成相应的页修改和日志;
  • 可能产生额外刷脏压力。

因此,Change Buffer 是“延迟随机读”,不是“消除工作量”。

5. 一个具体的代价算例

假设某张表有大量写入,但二级索引很少被查询:

每次插入:
    聚簇索引页:经常访问,直接修改
    非唯一二级索引页:大多不在 Buffer Pool

启用 Change Buffer 后,许多二级索引修改可以先进入 Change Buffer。写入阶段的随机读可能减少。

但之后如果执行一次覆盖范围很大的查询:

SELECT COUNT(*)
FROM orders
WHERE customer_id BETWEEN 1 AND 100000;

查询可能需要读取大量二级索引页。读取这些页时,历史 Change Buffer 记录需要被合并,导致:

  • 首次访问延迟变高;
  • 后台合并和前台查询竞争 I/O;
  • 页修改数量增加;
  • 脏页和刷脏压力上升。

因此,Change Buffer 适合“写多、二级索引页不常被立即读取”的工作负载;对于二级索引很热、读写都频繁的工作负载,收益可能较小,甚至引入合并开销。

6. 相关参数和观测

可以查看相关配置:

SHOW VARIABLES LIKE 'innodb_change_buffer%';
SHOW VARIABLES LIKE 'innodb_buffer_pool%';

不同版本中,Change Buffer 相关配置的可调整性和弃用状态可能变化。以 MySQL 8.4 为例,应以当前版本变量说明为准;部分 Change Buffer 配置已经被标记为逐步淘汰方向,不能把旧版本经验直接当成长期 API。

可以查看 InnoDB 状态:

SHOW ENGINE INNODB STATUS\G

输出中通常可以看到类似以下区域:

INSERT BUFFER AND ADAPTIVE HASH INDEX
----------------
Ibuf: size ...
merged operations:
 merged recs ...
 discarded operations:
 ...
Hash table size ...
Node heap has ...

这里的 INSERT BUFFER 是历史名称。现代 InnoDB 语义中,它对应 Change Buffer 相关机制,而不是只处理 INSERT。

也可以查看 InnoDB Metrics:

SELECT NAME, COUNT
FROM information_schema.INNODB_METRICS
WHERE NAME LIKE 'ibuf%';

是否存在某个具体指标名、指标是否默认启用,应以实际版本输出为准,不能假设所有发行版都有完全相同的指标集合。


七、如何设计一个 Change Buffer 实验

下面的实验边界是:

  • MySQL 8.4;
  • 存储引擎为 InnoDB;
  • 单机实例;
  • 一个事务连接执行写入;
  • 不把实验结果当成生产性能结论。

先创建非唯一二级索引:

CREATE TABLE cb_test (
    id BIGINT NOT NULL PRIMARY KEY,
    category_id INT NOT NULL,
    payload VARBINARY(200) NOT NULL,
    KEY idx_category_id (category_id)
) ENGINE = InnoDB;

插入数据:

INSERT INTO cb_test(id, category_id, payload)
VALUES
    (1, 10, RANDOM_BYTES(200)),
    (2, 20, RANDOM_BYTES(200)),
    (3, 30, RANDOM_BYTES(200));

实际使用时可通过应用程序或存储过程批量插入更多行。观察前后指标:

SHOW ENGINE INNODB STATUS\G;

SELECT NAME, COUNT
FROM information_schema.INNODB_METRICS
WHERE NAME LIKE 'ibuf%';

然后执行索引范围查询:

SELECT COUNT(*)
FROM cb_test
WHERE category_id BETWEEN 1 AND 100000;

需要注意,这个实验不能保证一定观察到显著的 Change Buffer 合并,因为是否使用 Change Buffer 还受以下因素影响:

  • 目标索引页是否已经在 Buffer Pool 中;
  • 页是否满足可缓冲条件;
  • 索引大小和数据分布;
  • 后台线程是否已经提前合并;
  • 运行期间是否发生了其他访问;
  • 当前版本的实现细节和配置。

这个实验适合验证“可能的机制路径”,不适合证明固定的性能倍数。


八、AHI:Adaptive Hash Index 是什么

1. AHI 不是用户创建的 Hash Index

AHI,即 Adaptive Hash Index,自适应哈希索引,是 InnoDB 根据访问模式自动构建的内存辅助结构。

它不是下面这种用户定义索引:

CREATE INDEX ... USING HASH;

也不改变表的持久化索引结构。用户看到的主键索引和二级索引仍然是 B+Tree。

AHI 的作用是:

重复的 B+Tree 等值查找
        │
        ├── 普通路径:从根页逐级比较
        │
        └── 命中 AHI:通过哈希结构快速定位索引记录或页内位置

它只能在 InnoDB 观察到适合哈希化的访问模式后,针对部分索引搜索路径建立辅助入口。

2. AHI 的构建是自适应的

AHI 不会在建表时扫描全部索引并创建一份完整哈希索引。典型过程是:

  1. 查询反复访问某个 B+Tree 索引路径;
  2. InnoDB 识别出该路径适合哈希查找;
  3. 在内存中创建对应的 AHI 结构;
  4. 后续相似的等值查找尝试使用 AHI;
  5. 索引页发生变化、被淘汰或结构不再适用时,相关 AHI 条目需要失效或调整。

因此,重启后 AHI 需要重新建立。它不是持久化数据的一部分,不能依赖它完成恢复。

3. AHI 适合什么查询

AHI 更适合重复的等值查找,例如:

SELECT *
FROM account
WHERE id = ?;

或者:

SELECT *
FROM orders
WHERE customer_id = ?;

但这里的“适合”不是保证。是否使用 AHI 取决于访问模式和实现条件。

AHI 对以下场景通常帮助有限:

  • 大范围扫描;
  • 主要是范围条件的查询;
  • 查询条件变化很大、几乎不重复;
  • 索引页频繁修改;
  • 哈希冲突或并发维护成本较高;
  • 查询瓶颈其实在回表、锁等待或磁盘写入。

例如:

SELECT *
FROM orders
WHERE customer_id BETWEEN 1 AND 100000;

这是范围访问,主要收益来自 B+Tree 的有序叶子页遍历,而不是把整个范围变成一次哈希查找。

4. AHI 的内存和并发代价

AHI 使用额外内存,并且要与索引页生命周期保持一致。索引页被修改、分裂、合并或淘汰时,相关哈希条目也需要维护。

在读多、重复等值查找明显的负载下,AHI 可能减少 B+Tree 搜索成本。

在写入频繁、索引页变化多、并发竞争激烈的负载下,AHI 的维护代价可能抵消收益。此时可以通过配置关闭它:

SET GLOBAL innodb_adaptive_hash_index = OFF;

是否允许动态修改、修改后的生效方式以及变量状态,应以当前版本为准。关闭 AHI 不会删除持久化索引,也不会破坏数据;它只是不再使用该内存辅助结构,相关查询回到普通 B+Tree 路径。

配置文件中也可以设置:

innodb_adaptive_hash_index = OFF

生产环境不应只因为“AHI 占内存”就盲目关闭,也不应因为默认开启就认为一定有收益。应结合访问模式、锁竞争、CPU、延迟和 InnoDB 指标验证。


九、Change Buffer 与 AHI 的根本区别

二者都可能出现在 SHOW ENGINE INNODB STATUS 的相关输出中,但职责完全不同。

对比项 Change Buffer AHI
主要解决 二级索引修改导致的随机读 重复索引查找的搜索成本
服务方向 写入路径优化,并延迟部分工作 读取路径优化
是否修改逻辑索引内容 暂存尚未合并的修改 不改变逻辑索引,只提供辅助入口
是否持久化 通过 InnoDB 页和日志机制保护
是否可能增加后续成本 会,目标页读取时需要合并 会,索引变化时需要维护
主要适用条件 非唯一二级索引、目标页不在内存 重复的适合哈希化的等值查找
不能替代 聚簇索引修改、唯一性检查 正确的 B+Tree 索引设计

一个常见误解是:

Change Buffer 和 AHI 都是“把索引放到内存里”。

这不准确:

  • Change Buffer 暂存的是部分二级索引变更,不是完整索引;
  • AHI 是 B+Tree 之上的哈希辅助结构,不是完整的持久化哈希索引;
  • 二者都不能代替 Buffer Pool;
  • 二者都不能消除磁盘、Redo Log、刷脏和索引维护的总成本。

十、Buffer Pool、Change Buffer 和 AHI 的内存关系

1. innodb_buffer_pool_size 不是 mysqld 总内存

InnoDB 的内存预算至少需要区分:

mysqld 总内存
├── InnoDB Buffer Pool
│   ├── 数据页和索引页
│   ├── 脏页
│   ├── Change Buffer 相关页
│   └── 页管理元数据
├── AHI
├── Redo Log Buffer
├── 数据字典和表定义缓存
├── 锁、事务、Undo 相关内存
├── 连接级内存
│   ├── sort_buffer_size
│   ├── join_buffer_size
│   ├── read_buffer_size
│   └── read_rnd_buffer_size
└── Performance Schema、线程栈、插件和其他开销

因此,下面的计算是不完整的:

可用物理内存 - innodb_buffer_pool_size = 所有剩余内存

连接级缓冲区通常按连接或按操作分配。高并发、复杂排序和大结果集可能使它们叠加消耗大量内存。

2. Buffer Pool 过大和过小的表现

Buffer Pool 过小可能表现为:

  • Innodb_buffer_pool_reads 增长较快;
  • Innodb_buffer_pool_read_requests 很高但实际读盘也高;
  • 热点页频繁被淘汰;
  • 随机读延迟高;
  • 工作集较大时吞吐不稳定。

Buffer Pool 过大也可能有问题:

  • 操作系统页缓存和其他组件没有足够内存;
  • mysqld 在高并发下发生内存压力;
  • 容器或虚拟机触发 OOM;
  • 更大的缓存容纳更多脏页,但存储写入速度没有提升;
  • 重启后的预热时间更长。

“尽量把内存都给 Buffer Pool”只能作为粗略经验,不能替代完整的内存预算和压力验证。


十一、查看 Buffer Pool 的实际状态

1. 查看全局容量和命中相关计数

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';

常见字段包括:

  • Innodb_buffer_pool_pages_total:Buffer Pool 页总数;
  • Innodb_buffer_pool_pages_free:空闲页数量;
  • Innodb_buffer_pool_pages_data:包含数据的页数量;
  • Innodb_buffer_pool_pages_dirty:脏页数量;
  • Innodb_buffer_pool_read_requests:逻辑读请求;
  • Innodb_buffer_pool_reads:无法从 Buffer Pool 满足、需要读取的请求;
  • Innodb_buffer_pool_write_requests:对 Buffer Pool 的写请求;
  • Innodb_buffer_pool_pages_written:写出的页数量。

可以计算一个粗略的逻辑读命中率:

命中率 ≈ 1 - Innodb_buffer_pool_reads
             / Innodb_buffer_pool_read_requests

例如:

read_requests = 1,000,000
physical_reads = 10,000

命中率 ≈ 1 - 10,000 / 1,000,000
       = 99%

但这个数字只能用于趋势观察。它没有告诉你:

  • 哪类 SQL 发生了物理读;
  • 物理读是否集中在一个延迟很高的磁盘;
  • 命中的页是否真正有用;
  • 查询是否因为回表读取了额外页;
  • 当前是否有锁等待或刷脏阻塞。

2. 查看各 Buffer Pool 实例

SELECT
    POOL_ID,
    POOL_SIZE,
    FREE_BUFFERS,
    DATABASE_PAGES,
    OLD_DATABASE_PAGES,
    MODIFIED_DATABASE_PAGES,
    PAGES_READ,
    PAGES_CREATED,
    PAGES_WRITTEN,
    READ_REQUESTS,
    PENDING_READS,
    PENDING_WRITES
FROM information_schema.INNODB_BUFFER_POOL_STATS;

字段的基本含义:

  • POOL_ID:实例编号;
  • POOL_SIZE:该实例的页数;
  • FREE_BUFFERS:空闲页;
  • DATABASE_PAGES:当前承载数据的页;
  • OLD_DATABASE_PAGES:old 区域相关页;
  • MODIFIED_DATABASE_PAGES:脏页;
  • PAGES_READ:从存储读取的页;
  • PAGES_WRITTEN:写回存储的页;
  • PENDING_READSPENDING_WRITES:等待处理的读写请求。

例如,如果多个实例的 MODIFIED_DATABASE_PAGES 分布极不均匀,可能说明访问和修改并不均衡。但不能只凭一次采样判断异常,应观察一段时间内的变化速度。

3. 观察刷脏和日志压力

SHOW ENGINE INNODB STATUS\G

重点关注:

  • BUFFER POOL AND MEMORY
  • LOG
  • ROW OPERATIONS
  • INSERT BUFFER AND ADAPTIVE HASH INDEX
  • pending flush、pending I/O;
  • checkpoint 相关位置;
  • 每秒读写页数量。

如果发现:

脏页持续上升
Redo 生成速度高
checkpoint 推进速度跟不上

应优先判断是:

  1. 写入量超过存储设备持续写入能力;
  2. Redo Log 容量相对工作负载不足;
  3. 刷脏策略或 I/O 配置不匹配;
  4. 大事务积累了大量修改;
  5. Change Buffer 合并带来额外写入;
  6. 存储系统发生延迟抖动。

不应把所有问题都归因于 Buffer Pool 太小。


十二、一个可验证的刷脏观察实验

仍以 InnoDB 和单机实例为边界。先创建一个会产生修改的表:

CREATE TABLE bp_dirty_test (
    id BIGINT NOT NULL PRIMARY KEY,
    value INT NOT NULL,
    padding VARBINARY(500) NOT NULL
) ENGINE = InnoDB;

批量插入一些数据后,在一个事务中更新:

START TRANSACTION;

UPDATE bp_dirty_test
SET value = value + 1
WHERE id BETWEEN 1 AND 10000;

-- 此时事务尚未提交

此时通常已经有部分内存页被修改并标记为脏页,但事务是否可见、日志是否刷盘,仍受事务状态和配置影响。

查看状态:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty';
SHOW ENGINE INNODB STATUS\G

然后提交:

COMMIT;

再观察:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_written';

预期理解是:

  • COMMIT 不要求所有数据页立即写回表空间;
  • 脏页数量可能暂时仍然存在;
  • 后台刷脏后,pages_written 逐步增加;
  • 实际数值取决于数据量、页分布、刷脏线程、I/O 能力和并发负载。

如果在实验中只更新少量行,却观察到很多页被写出,不一定是错误,因为:

  • 一行所在的整个页可能被标记为脏;
  • 同一页可能包含多个被修改记录;
  • 后台线程可能同时刷出其他脏页;
  • 二级索引、Undo 和其他内部页也可能参与写入。

十三、常见误解与失败表现

误解一:Buffer Pool 命中率 100% 就没有性能问题

反例:

SELECT *
FROM large_table
WHERE non_indexed_column = 'x';

如果表扫描页已经在 Buffer Pool 中,这条 SQL 可能几乎没有物理读,但仍然会消耗大量 CPU,并读取大量页。

此外,命中率高时也可能存在:

  • 锁等待;
  • MDL 等待;
  • Redo Log 刷盘延迟;
  • 回表过多;
  • 排序溢出;
  • AHI 或 Buffer Pool 管理竞争。

误解二:事务提交后数据页一定已经落盘

提交通常首先关注 Redo Log 的持久化边界,而不是立即刷出所有脏数据页。这样可以把大量随机数据页写入转化为更可控的后台刷脏。

但这不代表可以忽略持久性配置。对于需要较强崩溃持久性的系统,必须结合:

innodb_flush_log_at_trx_commit = 1
sync_binlog = 1

以及存储设备的真实 flush 行为进行验证。二进制日志和 InnoDB Redo Log 的持久化边界不一致时,还可能影响复制一致性和故障切换。

误解三:Change Buffer 可以缓冲所有写入

Change Buffer 不处理聚簇索引页,也不能跳过唯一性检查。一个包含多个索引的 INSERT,可能同时发生:

聚簇索引页:直接修改或读入后修改
唯一二级索引:需要即时维护和校验
非唯一二级索引:部分条件下可进入 Change Buffer

因此,表上二级索引越多,写入成本通常越高;Change Buffer 只可能优化其中一部分路径。

误解四:AHI 是完整的内存索引

AHI 不是全表索引,也不是持久化结构。它:

  • 只覆盖部分适合的访问模式;
  • 可能随着页修改而失效;
  • 重启后需要重新形成;
  • 不能替代正确的主键和二级索引;
  • 不能把范围扫描变成 O(1) 查询。

误解五:增加 Buffer Pool 就能解决刷脏延迟

如果瓶颈是存储设备的持续写入能力,扩大 Buffer Pool 只能增加缓冲空间。若 Redo Log 产生速度大于刷脏和检查点推进速度,最终仍会受到日志容量或检查点压力限制。

应同时观察:

脏页数量及增长速度
Redo 生成速度
checkpoint age
每秒写页数
I/O 延迟
事务提交延迟

十四、生产问题的诊断路径

1. 先区分读压力和写压力

如果表现为查询读盘多:

Innodb_buffer_pool_reads 持续增长
PAGES_READ 持续增长

重点检查:

  • 工作集是否超过 Buffer Pool;
  • SQL 是否进行了大范围扫描;
  • 是否存在低选择性索引;
  • 是否发生大量回表;
  • 热点页是否被扫描污染;
  • 操作系统和存储设备是否有读取延迟。

如果表现为写入和提交变慢:

脏页持续增长
PAGES_WRITTEN 很高
checkpoint 推进困难

重点检查:

  • Redo Log 产生速度;
  • 磁盘持续写能力;
  • 大事务;
  • 二级索引数量;
  • Change Buffer 合并;
  • 刷脏线程和 I/O 调度;
  • 复制、Binlog 和提交刷盘配置。

2. 再区分“内存不足”和“缓存命中不足”

操作系统层面需要观察:

free -h
vmstat 1
iostat -x 1

这些命令的解释边界是:

  • free -h:查看总体内存、缓存和可用内存;
  • vmstat 1:查看内存回收、交换和系统活动;
  • iostat -x 1:查看设备利用率、队列和 I/O 延迟。

如果发生 swap、容器 OOM 或系统回收压力,不能继续无条件增大 innodb_buffer_pool_size

如果系统内存充足但物理读很多,则应进一步检查工作集和访问路径,而不是只看 mysqld 是否“占满了内存”。

3. 判断是否与 Change Buffer 有关

不能只看到 ibuf 指标增长就认定它有益。至少要同时观察:

  • 写入阶段的随机读是否减少;
  • Change Buffer 是否持续积压;
  • 二级索引读取时是否出现合并压力;
  • 磁盘写入和脏页是否因此增加;
  • 关闭或调整后,在相同工作负载下延迟是否改善。

任何配置变更都应在接近生产数据分布的环境中进行,而不是用小表得出结论。

4. 判断是否与 AHI 有关

可以从以下现象寻找线索:

  • 重复主键或唯一键等值查询的 CPU 开销;
  • SHOW ENGINE INNODB STATUS 中 AHI 相关信息;
  • 高并发时互斥或读写竞争;
  • 关闭 AHI 后的查询延迟和 CPU 变化;
  • 工作负载是等值查找还是范围扫描。

关闭 AHI 的风险通常不是数据错误,而是原本受益于 AHI 的等值查询可能退化。不过修改前仍应确认当前版本的动态行为,并在可回滚的条件下验证。


十五、参数调整时的边界

innodb_buffer_pool_size

适合解决:

  • 热点数据和索引无法容纳;
  • 物理读过多;
  • 访问工作集明确且内存充足。

不直接解决:

  • 没有索引的扫描;
  • 回表设计不合理;
  • 磁盘写入能力不足;
  • 锁等待;
  • 连接级内存过高;
  • Redo Log 或 Binlog 持久化延迟。

innodb_buffer_pool_instances

适合在较大 Buffer Pool 下减少管理竞争。它不是“实例越多越快”,过多实例会导致每个实例过小、管理成本增加,并可能削弱局部性。

innodb_adaptive_hash_index

适合通过压测验证:

  • 关闭后 CPU 或延迟是否改善;
  • 开启后等值查询是否受益;
  • 写入和并发维护是否成为代价。

不要只用一条查询或短时间测试判断。

innodb_change_buffering

它影响哪些 Change Buffer 操作允许被缓冲。由于相关能力和参数在新版本中存在演进和弃用方向,调整时应核对当前 MySQL 版本文档和 SHOW VARIABLES 实际输出。

即使参数允许开启,也要确认业务负载是否符合适用条件:

非唯一二级索引较多
+ 写入频繁
+ 索引页经常不在 Buffer Pool
+ 这些索引不是立即被大量读取

若索引页很热,目标页本来就在 Buffer Pool 中,Change Buffer 的潜在收益就会降低。


十六、把四个概念放到一次 INSERT 中

最后用一个包含主键、唯一二级索引和非唯一二级索引的表总结:

CREATE TABLE customer_order (
    id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    request_id VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    UNIQUE KEY uk_request_id (request_id),
    KEY idx_customer_status (customer_id, status)
) ENGINE = InnoDB;

执行:

START TRANSACTION;

INSERT INTO customer_order
    (id, customer_id, request_id, status, amount)
VALUES
    (1, 100, 'req-0001', 1, 50.00);

COMMIT;

可能发生的路径是:

1. 查找并修改聚簇索引
   └── 必须保证主键 id 的正确性

2. 查找并维护唯一二级索引 uk_request_id
   └── 必须检查 request_id 是否重复
   └── 不能简单地延迟唯一性判断

3. 维护非唯一二级索引 idx_customer_status
   ├── 目标页在 Buffer Pool:直接修改该页
   └── 目标页不在 Buffer Pool:可能进入 Change Buffer

4. 被修改的内存页成为脏页

5. 生成 Redo Log

6. COMMIT 根据日志持久化配置处理提交

7. 后台线程稍后刷出脏数据页

8. 如果某个索引访问模式高度重复:
   └── AHI 可能为后续等值搜索建立辅助入口

这里的关键关系是:

  • Buffer Pool 是页的主要内存驻留位置;
  • 脏页是 Buffer Pool 中已经被修改但尚未写回的页;
  • Change Buffer 只延迟部分二级索引页修改;
  • AHI 不保存业务数据的独立持久化副本,只辅助部分索引查找;
  • Redo Log 负责让“先修改内存、后刷数据页”仍然能够在崩溃后恢复;
  • 提交、刷脏、Change Buffer 合并和 AHI 构建是不同时间尺度上的事件。

掌握这些边界后,看到“读盘高”“脏页高”“提交慢”“索引写入重”“AHI 占用”时,才能把现象对应到正确的数据流,而不是把所有问题都归结为“Buffer Pool 不够大”。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。