MySQL / InnoDB 存储引擎系列 · 第八篇

往一个不在内存里的二级索引页插数据,InnoDB 图省事的办法是什么?

第三篇讲过 Buffer Pool 怎么决定谁该留在内存里。这篇讲一个更直接的问题:如果要改的那个二级索引页压根不在内存里呢?老实的做法是先把它从磁盘读进来,改完,总有一天再刷回去——但如果这是一次随机写入(比如按用户 ID 建的索引,插入顺序跟用户 ID 毫无关系),每次插入都可能对应一次随机磁盘读,非常昂贵。change buffer 就是用来省掉这次读的。

本机真实实测:重启拿到一个空的 Buffer Pool,往一个已有 50 万行、二级索引值随机分布的表里插入 1 万行,change buffer 立刻用真实数字证明了自己在干活。

1 → 44 页
插入 1 万行随机数据后,change buffer 自身占用的页数
161 → 9166
一次索引全扫描之后,累计合并的插入记录数

change buffer 是什么

它自己也是系统表空间里的一棵 B+树——不是内存结构,是磁盘上真实存在的一棵索引,专门用来"寄存"那些还没来得及应用的修改。

缓冲条件

只对非唯一二级索引生效

聚簇索引和唯一索引都不参与——唯一索引插入必须立刻读页检查有没有冲突,没法拖后。

寄存位置

按 (space_id, page_no) 建索引

缓冲的修改记录本身按"属于哪个表空间的哪一页"来组织,方便日后按页查找、按页合并。

合并时机

页被读入内存的那一刻

不管这个页是因为什么原因被读进来的——一次查询、一次后台预读——只要它进了 Buffer Pool,之前攒下的修改就会立刻应用上去。

storage/innobase/include/ibuf0ibuf.h · mysql-server @ mysql-9.7.1, L78 The purpose of the insert buffer is to reduce random disk access. When we wish to insert a record into a non-unique secondary index and the B-tree leaf page where the record belongs to is not in the buffer pool, we insert the record into the insert buffer B-tree, indexed by (space_id, page_no). When the page is eventually read into the buffer pool, we look up the insert buffer B-tree for any modifications to the page, and apply these upon the completion of the read operation. This is called the insert buffer merge.

"insert buffer"是历史遗留名字——现在正式叫 change buffer,因为它不只缓冲插入,也缓冲二级索引记录的删除标记(delete-mark)和真正的物理删除(purge)。源码里对应的三种操作类型和 SHOW ENGINE INNODB STATUS 里看到的字段名完全对得上:

storage/innobase/include/ibuf0ibuf.h typedef enum { IBUF_OP_INSERT = 0, IBUF_OP_DELETE_MARK = 1, IBUF_OP_DELETE = 2, } ibuf_op_t;

真实实验:插入 1 万行随机数据,change buffer 真的在干活

orders(id, user_id, note),已有 50 万行、user_id 随机分布在 0~200 万之间,二级索引 idx_user_id 是非唯一索引。重启拿到一个真正冷的 Buffer Pool 之后,插入 1 万行新的随机 user_id

真实实测 -- 重启后,基线状态 Ibuf: size 1, free list len 0, seg size 2, 0 merges merged operations: insert 0, delete mark 0, delete 0 -- 插入 1 万行随机 user_id(没有做任何查询,没有主动读页) INSERT INTO orders (user_id, note) SELECT FLOOR(RAND()*2000000), ... ; -- 插入完成后,change buffer 自己长大了 Ibuf: size 44, free list len 26, seg size 71, 51 merges merged operations: insert 161, delete mark 0, delete 0 -- 做一次覆盖整个 user_id 范围的索引扫描,强制把所有相关页读进内存 SELECT COUNT(*) FROM orders FORCE INDEX(idx_user_id) WHERE user_id BETWEEN 0 AND 2000000; -- 扫描把攒下的修改几乎全部合并掉了 Ibuf: size 1, free list len 69, seg size 71, 850 merges merged operations: insert 9166, delete mark 0, delete 0

Ibuf: size 就是 change buffer 这棵 B+树当前占用的页数——插入前是 1(接近空),插入 1 万行随机数据后涨到 44,说明有大量修改被暂存在这里,而不是立刻写回各自的目标页。merged operations: insert 是累计已经真正合并到目标页的记录数——扫描前只有 161,扫描后跳到 9166,涨了 9000 多,而 change buffer 自身又缩回了 1 页(free list len 从 26 涨到 69,说明大量曾经被占用的页被释放归还了)。

有个细节值得说清楚:插入阶段就已经有 51 次合并、161 条记录被合并了,不是全部 1 万条插入都进了 change buffer。这是正常的——插入过程中如果目标页因为别的原因(比如同一批插入连续命中了同一页、或者页分裂后新页恰好在内存里)已经在 Buffer Pool 里,就会直接写,不用缓冲;change buffer 只在"目标页确实不在内存里"这个条件成立时才起作用。

演示:一条记录从缓冲到合并的完整过程

用一个最小场景复现四种情况:聚簇索引直接写、二级索引命中已在内存的页直接写、二级索引命中冷页被缓冲、唯一索引即使目标页是冷的也不能缓冲。

page P1(热)、P2/P3(冷)、P4(冷,唯一索引)

页状态(Buffer Pool)

change buffer(尚未合并的记录)

不是没有代价

change buffer 省下的是"立刻读盘"这一步,不是把工作量变没。

配置项真实默认值含义
innodb_change_bufferingallinsert/delete-mark/purge 全部可以缓冲
innodb_change_buffer_max_size5change buffer 最多占 Buffer Pool 的 5%(源码常量 CHANGE_BUFFER_DEFAULT_SIZE=5,与实测值完全一致)

这 5% 的上限意味着 change buffer 不能无限攒下去——攒满了照样得合并。而且合并这件事最终还是要发生,只是被推迟并且很可能被合并成一批:同一个页如果攒了好几条修改,等它真的被读进内存时,是一次性把所有攒下的修改都应用上,而不是当初插入时的那么多次零散读盘。对写多读少、二级索引值分布又足够随机(比如上一篇提到的"选择性高"的列)的场景,这个"延后 + 合并"能省下大量原本会打在磁盘上的随机 I/O。

反过来想:如果二级索引是单调递增的(比如按时间戳建的索引),新插入总是落在最后一页,而最后一页几乎总是在内存里(第三篇讲过,活跃页会一直被访问、一直是热的)——这种场景下 change buffer 基本用不上,因为条件"目标页不在内存里"很少成立。change buffer 帮的是那些插入位置东一下西一下、真会命中冷页的场景,这也是为什么第七篇提到"选择性高的列适合放索引前面"和这里是同一类权衡的另一面:值分布越随机,查询越受益,但写入的随机 I/O 压力也越大——change buffer 正是用来缓解后者的。

参考与说明

  • 本文的核心实验在本机真实运行的 MySQL 9.7.1 上完成:重启获得真正冷的 Buffer Pool 后(与第三篇同样的手法,清除 ib_buffer_pool 转储文件),对一张 50 万行、二级索引值随机分布的表做真实插入和真实索引扫描,SHOW ENGINE INNODB STATUSINSERT BUFFER AND ADAPTIVE HASH INDEX 段落均为真实抓取,未做任何删改。
  • 源码引用(ibuf0ibuf.h 的模块说明注释、ibuf_op_t/ibuf_use_t 枚举、CHANGE_BUFFER_DEFAULT_SIZE 常量、"Does not do it if the index is clustered or unique"的排除规则)取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 完全一致。
  • 演示动画是源码规则的简化建模(4 个页、4 条修改),用来把"缓冲 vs 直接写 vs 合并"这三种走向的判定条件讲清楚,不是对 change buffer 内部真实 B+树结构的还原;真实 change buffer 里的记录本身也是按页组织、按需合并的,但物理存储格式比这里复杂得多。
  • 没有涉及:change buffer 位图页(每个数据页在 change buffer 里对应的可用空间标记)的具体结构、change buffer 达到 5% 上限后的强制合并策略、崩溃恢复时 change buffer 本身如何参与 redo(它自己也是受 redo log 保护的普通 InnoDB 页)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电