第三篇讲过 Buffer Pool 怎么决定谁该留在内存里。这篇讲一个更直接的问题:如果要改的那个二级索引页压根不在内存里呢?老实的做法是先把它从磁盘读进来,改完,总有一天再刷回去——但如果这是一次随机写入(比如按用户 ID 建的索引,插入顺序跟用户 ID 毫无关系),每次插入都可能对应一次随机磁盘读,非常昂贵。change buffer 就是用来省掉这次读的。
本机真实实测:重启拿到一个空的 Buffer Pool,往一个已有 50 万行、二级索引值随机分布的表里插入 1 万行,change buffer 立刻用真实数字证明了自己在干活。
它自己也是系统表空间里的一棵 B+树——不是内存结构,是磁盘上真实存在的一棵索引,专门用来"寄存"那些还没来得及应用的修改。
聚簇索引和唯一索引都不参与——唯一索引插入必须立刻读页检查有没有冲突,没法拖后。
缓冲的修改记录本身按"属于哪个表空间的哪一页"来组织,方便日后按页查找、按页合并。
不管这个页是因为什么原因被读进来的——一次查询、一次后台预读——只要它进了 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;
表 orders(id, user_id, note),已有 50 万行、user_id 随机分布在 0~200 万之间,二级索引 idx_user_id 是非唯一索引。重启拿到一个真正冷的 Buffer Pool 之后,插入 1 万行新的随机 user_id。
Ibuf: size 就是 change buffer 这棵 B+树当前占用的页数——插入前是 1(接近空),插入 1 万行随机数据后涨到 44,说明有大量修改被暂存在这里,而不是立刻写回各自的目标页。merged operations: insert 是累计已经真正合并到目标页的记录数——扫描前只有 161,扫描后跳到 9166,涨了 9000 多,而 change buffer 自身又缩回了 1 页(free list len 从 26 涨到 69,说明大量曾经被占用的页被释放归还了)。
用一个最小场景复现四种情况:聚簇索引直接写、二级索引命中已在内存的页直接写、二级索引命中冷页被缓冲、唯一索引即使目标页是冷的也不能缓冲。
change buffer 省下的是"立刻读盘"这一步,不是把工作量变没。
| 配置项 | 真实默认值 | 含义 |
|---|---|---|
| innodb_change_buffering | all | insert/delete-mark/purge 全部可以缓冲 |
| innodb_change_buffer_max_size | 5 | change buffer 最多占 Buffer Pool 的 5%(源码常量 CHANGE_BUFFER_DEFAULT_SIZE=5,与实测值完全一致) |
这 5% 的上限意味着 change buffer 不能无限攒下去——攒满了照样得合并。而且合并这件事最终还是要发生,只是被推迟并且很可能被合并成一批:同一个页如果攒了好几条修改,等它真的被读进内存时,是一次性把所有攒下的修改都应用上,而不是当初插入时的那么多次零散读盘。对写多读少、二级索引值分布又足够随机(比如上一篇提到的"选择性高"的列)的场景,这个"延后 + 合并"能省下大量原本会打在磁盘上的随机 I/O。