一句话结论(30s)
Change Buffer 的本质是把非唯一二级索引的修改先缓存在内存、等时机成熟再批量刷入磁盘索引页,从而把多次随机 IO 合并成一次顺序写。关键设计是它只服务非唯一索引——因为唯一索引插入必须读索引页做唯一性检查,天然屏蔽了缓存路径;核心权衡是「写入后立即查询」会变成反模式,查询触发的 Merge 反而比直接插入索引页多了一次 Merge 逻辑开销。
核心原理(2min)
主键插入沿 B+Tree 顺序写入、接近顺序 IO,但二级索引条目要插入 B+Tree 中间位置、目标页可能不在 BufferPool,随机 IO 成为瓶颈。Change Buffer 是 BufferPool 的一部分,用一棵 B+Tree(键为 (space_id, page_no, counter))按目标页组织 INSERT / DELETE_MARK / PURGE 三类变更,让同一页的多条变更在 Merge 时一次 IO 应用。Merge 有三类时机:被动 Merge(SELECT 读某页时先应用变更再返回)、主动 Merge(Master Thread 每 1 秒/10 秒批量合并)、被动 Merge(DROP/ALTER 表时批量刷)。当 Change Buffer 达到 innodb_change_buffer_max_size(默认 25%)上限时,后续写入绕过它直接原地插入索引页。
底层深入(5-10min)
一、Change Buffer 解决什么问题?
InnoDB 的设计核心是将随机 IO 转化为顺序 IO。对于主键(聚簇索引)的插入,数据按主键顺序写入 B+Tree 叶子页,本质上接近顺序 IO。但 二级索引(Secondary Index)的维护完全是另一回事。
考虑此场景:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
order_no VARCHAR(32),
status TINYINT,
KEY idx_user_id (user_id),
KEY idx_status (status)
);
INSERT INTO orders (user_id, order_no, status) VALUES (999, 'ORD001', 1);
user_id=999 的索引条目需要插入到 idx_user_id B+Tree 的某个中间位置——这个页可能不在 BufferPool 中,需要一次随机磁盘 IO。如果有大量 INSERT 分散到不同二级索引的不同位置,随机 IO 会成为瓶颈。
Change Buffer 的解决方案:把二级索引的修改先缓存在内存(Change Buffer = BufferPool 的一部分),等时机成熟时批量刷入磁盘索引页——将多次随机 IO 合并为一次顺序写。
💭 想一想:为什么”缓存起来等会儿再写”反而比”马上写”快?——因为马上写意味着每次插入都要把目标索引页从磁盘随机读进来、改完再写回;而缓存后,同一页的多条变更攒在一起,等页被读入时一次性应用,多次”随机读+写”被合并成一次”批量写”,随机 IO 就变成了接近顺序的 IO。
「目标页不在 Buffer Pool」正是触发缓存的信号——btr_cur_search_to_nth_level() 里,如果叶子页没取到(block == nullptr),就转而调用 ibuf_insert() 把操作缓存起来:
// btr/btr0cur.cc —— 目标页不在 Buffer Pool 时,改走 Change Buffer
if (block == nullptr) {
/* This must be a search to perform an insert/delete
mark/ delete; try using the insert/delete buffer */
ut_ad(height == 0);
ut_ad(cursor->thr);
switch (btr_op) {
case BTR_INSERT_OP:
case BTR_INSERT_IGNORE_UNIQUE_OP:
ut_ad(fetch == Page_fetch::IF_IN_POOL);
ut_ad(!dict_index_is_spatial(index));
if (ibuf_insert(IBUF_OP_INSERT, tuple, index, page_id, page_size,
cursor->thr)) {
cursor->flag = BTR_CUR_INSERT_TO_IBUF;
goto func_exit;
}
break;
// ……(BTR_DELMARK_OP → IBUF_OP_DELETE_MARK、BTR_DELETE_OP → IBUF_OP_DELETE)……
}
// ……(缓存失败则回退为直接读页插入)……
}
分析:这段代码把「随机 IO 优化」落到了实处——只要目标二级索引页不在内存,就不去磁盘把它读进来,而是把这条修改塞进 Change Buffer 立即返回。后续某个时刻该页因别的访问被读入时,再一次性把缓存的多条变更 Merge 进去,把”多次随机读+写”换成”一次批量写”。
二、为什么仅限于非唯一索引?
这是 Change Buffer 机制最核心的约束,也是面试高频考点。
2.1 唯一索引的完整性检查
对唯一索引插入值 X 时,InnoDB 必须判断 X 是否已存在。这个判断只能通过 读取索引页 来完成——无法在 Change Buffer 中完成,因为 Change Buffer 不存储其他行的数据,只知道哪些值被插入了。
// row/row0ins.cc —— 唯一二级索引插入前必须读索引页做唯一性检查
n_unique = dict_index_get_n_unique(index);
if (dict_index_is_unique(index) &&
(cursor.low_match >= n_unique || cursor.up_match >= n_unique)) {
mtr_commit(&mtr);
// ……(重新开启 mtr)……
err = row_ins_scan_sec_index_for_duplicate(flags, index, entry, thr, check,
&mtr, offsets_heap);
mtr_commit(&mtr);
switch (err) {
case DB_SUCCESS:
break;
case DB_DUPLICATE_KEY:
// ……(对未提交索引降级为 DB_SUCCESS)……
[[fallthrough]];
default:
if (dict_index_is_spatial(index)) {
rtr_clean_rtr_info(&rtr_info, true);
}
return err;
}
// ……(加 S 锁防并发插入重复,再重新定位游标继续插入)……
而是否值得走 Change Buffer 的最终判断在 ibuf_should_try(),其中 !dict_index_is_unique(index) 就是那道硬闸门:
// include/ibuf0ibuf.ic —— 决定某次二级索引修改是否值得进 Change Buffer
static inline bool ibuf_should_try(
dict_index_t *index, /*!< in: index where to insert */
ulint ignore_sec_unique) /*!< in: if != 0, we should
ignore UNIQUE constraint on
a secondary index when we
decide */
{
return (innodb_change_buffering != IBUF_USE_NONE && ibuf->max_size != 0 &&
index->space != dict_sys_t::s_dict_space_id &&
!index->is_clustered() && !dict_index_is_spatial(index) &&
!dict_index_has_desc(index) &&
index->table->quiesce == QUIESCE_NONE &&
(ignore_sec_unique || !dict_index_is_unique(index)) &&
srv_force_recovery < SRV_FORCE_NO_IBUF_MERGE);
}
分析:唯一索引必须先读索引页调用 row_ins_scan_sec_index_for_duplicate() 做存在性判断,这一步天然就”读到了页”,所以再缓存就没有意义,ibuf_should_try 直接以 !dict_index_is_unique(index) 把它挡在 Change Buffer 之外。同一个判断还顺带排除了聚簇索引、空间索引、降序索引等不适合缓存的场景。
结论:唯一索引的插入/更新必然触发读索引页,屏蔽了 Change Buffer 的优化路径。非唯一索引不需要这个检查,所以可以直接缓存变更。
💭 想一想:为什么”做一次唯一性检查”就足以废掉整个优化?——因为检查”值 X 是否存在”只能读索引页完成,而 Change Buffer 里只存了”插入了哪些值”,没有其他行的数据,无法就地判断冲突。既然无论如何都要把页读进来,缓存就失去了”避免读页”的意义,
ibuf_should_try干脆一刀切:非唯一才进。
2.2 Change Buffer 缓存的三种操作类型
| 操作 | 缓存内容 | IBUF 操作码 |
|---|---|---|
| INSERT | 被插入的值 + 索引页号 | IBUF_OP_INSERT |
| DELETE_MARK | 标记删除的记录(不立即物理删除) | IBUF_OP_DELETE_MARK |
| DELETE (PURGE 物理删除) | 彻底删除的记录 | IBUF_OP_DELETE |
三种操作码在源码里是一个磁盘持久化的枚举,值不能改(已写进 ibuf 树记录里):
// include/ibuf0ibuf.h —— 可缓存的操作类型(值会落盘,禁止改动)
/* Possible operations buffered in the insert/whatever buffer. See
ibuf_insert(). DO NOT CHANGE THE VALUES OF THESE, THEY ARE STORED ON DISK. */
typedef enum {
IBUF_OP_INSERT = 0,
IBUF_OP_DELETE_MARK = 1,
IBUF_OP_DELETE = 2,
/* Number of different operation types. */
IBUF_OP_COUNT = 3
} ibuf_op_t;
分析:物理删除对应的操作码是 IBUF_OP_DELETE(对应 PURGE 阶段),不是单独的 PURGE 码——因为 Change Buffer 只记录”操作类型 + 键值”,物理删除和 delete-mark 都落到 IBUF_OP_DELETE 这一个动作上。
UPDATE 操作在二级索引上被分解为 DELETE_MARK + INSERT(见 InnoDB 源码 row_upd_sec_index_entry()),所以也受益于 Change Buffer。
三、Merge 的三种时机
Change Buffer 中的缓存在以下三种时机合并到实际索引页:
3.1 被动 Merge:读取操作触发
当有 SELECT 需要读取某个二级索引页时,InnoDB 检查 Change Buffer 中是否有该页的待合并记录。如果有,先将 Change Buffer 中的变更应用到该页,再返回查询结果。
-- 此 SELECT 触发 idx_user_id 相关页的 change buffer merge
SELECT * FROM orders WHERE user_id = 777;
当某个二级索引页被读入 Buffer Pool 时,ibuf_merge_or_delete_for_page() 会被调用,先按 (space_id, page_no) 在 Change Buffer 里定位该页的待合并记录,再逐条应用到页上:
// ibuf0ibuf.cc —— 页被读入后,合并该页在 Change Buffer 里的所有待处理操作
void ibuf_merge_or_delete_for_page(buf_block_t *block, const page_id_t &page_id,
const page_size_t *page_size,
bool update_ibuf_bitmap) {
// ……(声明 pcur / search_tuple 等)……
if (srv_force_recovery >= SRV_FORCE_NO_IBUF_MERGE ||
trx_sys_hdr_page(page_id) || fsp_is_system_temporary(page_id.space())) {
return;
}
// ……(跳过固定地址页 / 表空间描述页)……
heap = mem_heap_create(512, UT_LOCATION_HERE);
search_tuple =
ibuf_search_tuple_build(page_id.space(), page_id.page_no(), heap);
// ……(打开 ibuf 树游标,逐条取出该 (space_id, page_no) 的记录并应用到 block 上)……
}
分析:被动 Merge 的触发点就是”页被读入”,而 Merge 本身还要先去 Change Buffer 的 B+Tree 里按目标页做一次查找,再回放变更——这正是下文”写入后立即查询”多出来的那部分开销。
这就是 “写入后立即查询”成为反模式 的原因:Change Buffer 缓存了写入,但立即查询时又要 Merge 回来——相当于”缓存了等于没缓存”还多了 Merge 开销。
💭 想一想:同样是”读一次写一次”,为什么走了 Change Buffer 反而更亏?——因为总 IO 没省下(INSERT 终究要写、SELECT 终究要读),却多付了”查 Change Buffer、回放变更”这套 Merge 逻辑的开销。缓存的价值只在”攒够一批、少读几次页”时才兑现,一插入就查等于把价值清零还倒贴成本。
3.2 主动 Merge:后台 Master Thread
InnoDB Master Thread 每隔 1 秒(活跃状态)或 10 秒(空闲状态)检查 Change Buffer 大小,当满足条件时触发批量 Merge。Merge 时按索引页分组,同一个目标页的多条变更合并为一次 IO 操作。
3.3 被动 Merge:表关闭或收缩
执行 DROP TABLE 或 ALTER TABLE 缩表空间时,Change Buffer 中该表的所有待合并记录被批量 Merge 到索引页。
特殊情况:当 INSERT 加载大量数据导致 Change Buffer 占满时(达到 innodb_change_buffer_max_size),后续写入直接走 索引页原地插入,等同于 Change Buffer 被绕过。
四、配置与监控
-- 配置 Change Buffer 占 BufferPool 的比例(默认 25%)
SET GLOBAL innodb_change_buffer_max_size = 25; -- 单位:百分数
-- 开启/关闭 Change Buffer 的操作类型(默认 all)
SET GLOBAL innodb_change_buffering = all;
-- 可选值:none | inserts | deletes | changes (update+delete_mark) | purges | all
如果业务以写入为主、读取很少(如日志表、流水表),建议 innodb_change_buffer_max_size 调大至 40-50。相反,读密集型业务(立即查询)应该关闭 Change Buffer,避免 Merge 开销:
SET GLOBAL innodb_change_buffering = none; -- 纯读业务
监控 Merge 状态
-- 查看 Change Buffer 占用
SELECT NAME, COUNT, MAX_COUNT, COMMENT
FROM information_schema.innodb_metrics
WHERE NAME LIKE '%ibuf%';
-- SHOW ENGINE INNODB STATUS 中的 INSERT BUFFER AND ADAPTIVE HASH INDEX 段
-- Ibuf: size 1, free list len 0, seg size 2, 15 merges
-- merged operations:
-- insert 10, delete mark 3, delete 1
-- discarded operations:
-- insert 0, delete mark 0, delete 0
关键指标:
size:Change Buffer 中待合并的条目数merges:已完成的 Merge 操作次数discarded operations:因表空间收缩而丢弃的变更(表示该 Change Buffer 记录对应的表已被删除)
五、写入后立即查询的反模式
-- 反模式:插入后立即按二级索引查询
INSERT INTO orders (user_id, status) VALUES (888, 1);
SELECT * FROM orders WHERE user_id = 888; -- 触发 idx_user_id 的 Change Buffer Merge
执行分析:
- INSERT 将 user_id=888 的变更写入 Change Buffer(无 IO)
- SELECT 需要读取 idx_user_id 的某个页 → 触发 该页的 Change Buffer Merge
- Merge 操作:从磁盘读索引页 → 应用 Change Buffer 中的变更 → 再返回查询结果
- 总 IO:读索引页 1 次 + 写索引页 1 次(Dirty Page 后续刷盘)
如果没有 Change Buffer,INSERT 时直接插入索引页(读 + 写),后续 SELECT 直接读取。总 IO 基本相同,但 Change Buffer 方案多了一次 Merge 逻辑开销。
5.1 如何识别此反模式?
-- 查看 Change Buffer merge 频率
SHOW ENGINE INNODB STATUS\G
-- 关注 "INSERT BUFFER AND ADAPTIVE HASH INDEX" 段的 merges 增长趋势
-- 如果 merges 增速接近写入速率,说明大量 Merge 正在发生
5.2 最佳实践场景
Change Buffer 最适合以下模式:
| 场景 | Change Buffer 效果 | 说明 |
|---|---|---|
| 日志表(写多读极少) | 极佳 | 插入直接命中 Change Buffer,日终批量查询时一次性 Merge |
| 订单流水表 | 良好 | 写入频繁但只在日结对账时读取 |
| 用户行为埋点 | 极佳 | 写入量极大但几乎不按二级索引查询 |
| 订单实时查询 | 差 | 写入后立即可查出,应关闭该表的 Change Buffer |
| 好友关系表 | 中等 | 新增好友后可能即时通信 |
六、Change Buffer 的内部结构
Change Buffer 本质上是一棵 B+Tree,存储在系统表空间(ibdata1)中。B+Tree 的键是 (space_id, page_no, counter),值是操作类型 + 记录数据。
Change Buffer B+Tree:
Key: (space_id=10, page_no=372, counter=1)
Value: (IBUF_OP_INSERT, <记录二进制>)
这条记录的字段布局由一组常量定义(ibuf/ibuf0ibuf.cc):前三个字段依次是 space_id、marker 字节、page_no,第四个字段是 metadata(内含 counter / op / flags),从第五个字段开始才是用户索引的实际键值:
// ibuf0ibuf.cc —— ibuf 记录字段布局
/** in the pre-4.1 format, the page number. later, the space_id */
constexpr uint32_t IBUF_REC_FIELD_SPACE = 0;
/** starting with 4.1, a marker consisting of 1 byte that is 0 */
constexpr uint32_t IBUF_REC_FIELD_MARKER = 1;
/** starting with 4.1, the page number */
constexpr uint32_t IBUF_REC_FIELD_PAGE = 2;
/** the metadata field */
constexpr uint32_t IBUF_REC_FIELD_METADATA = 3;
/** first user field */
constexpr uint32_t IBUF_REC_FIELD_USER = 4;
metadata 字段内部又拆成 counter / 操作类型 / flags 三段:
// ibuf0ibuf.cc —— metadata 字段内的偏移
/** Operation counter */
constexpr uint32_t IBUF_REC_OFFSET_COUNTER = 0;
/** Type of operation */
constexpr uint32_t IBUF_REC_OFFSET_TYPE = 2;
/** Additional flags */
constexpr uint32_t IBUF_REC_OFFSET_FLAGS = 3;
构建 ibuf 记录时,ibuf_entry_build() 先把 space_id、page_no 写入第 1、3 字段,再把 counter 和操作类型写进 metadata:
// ibuf0ibuf.cc —— ibuf_entry_build:拼出 (space_id, page_no, counter, op)
/* 1) Space Id */
field = dtuple_get_nth_field(tuple, IBUF_REC_FIELD_SPACE);
buf = static_cast<byte *>(mem_heap_alloc(heap, 4));
mach_write_to_4(buf, space_id);
dfield_set_data(field, buf, 4);
dfield_set_type(field, &fake_type);
// ……(2) marker 字节置 0)……
/* 3) Page number */
field = dtuple_get_nth_field(tuple, IBUF_REC_FIELD_PAGE);
buf = static_cast<byte *>(mem_heap_alloc(heap, 4));
mach_write_to_4(buf, page_no);
dfield_set_data(field, buf, 4);
dfield_set_type(field, &fake_type);
// ……(4) metadata:写 counter / op / flags)……
mach_write_to_2(ti + IBUF_REC_OFFSET_COUNTER, counter);
ti[IBUF_REC_OFFSET_TYPE] = (byte)op;
// ……(5+) 复制用户索引字段 + 类型信息)……
整个 Change Buffer 的运行时状态由 ibuf_t 维护,size 与 max_size 就是触发收缩(contract)和”写满绕过”的判断依据:
// include/ibuf0ibuf.ic —— ibuf_t 控制结构
struct ibuf_t {
ulint size; /*!< current size of the ibuf index
tree, in pages */
ulint max_size; /*!< recommended maximum size of the
ibuf index tree, in pages */
// ……(seg_size / empty / free_list_len / height 省略)……
dict_index_t *index; /*!< insert buffer index */
std::atomic<ulint> n_merges; /*!< number of pages merged */
std::atomic<ulint> n_merged_ops[IBUF_OP_COUNT];
/*!< number of operations of each type
merged to index pages */
std::atomic<ulint> n_discarded_ops[IBUF_OP_COUNT];
/*!< number of operations of each type
discarded without merging due to the
tablespace being deleted or the
index being dropped */
};
分析:counter 之所以存在,是因为同一 (space_id, page_no) 下可能有多条操作(如 “INSERT x、DELETE_MARK x、INSERT x”),必须按加入顺序排布,Merge 时才能重放出一致的最终状态。ibuf_entry_build 里的 mach_write_to_4(buf, space_id) / mach_write_to_4(buf, page_no) 就是把这几个字段按二进制序拼成 B+Tree 的键——所以 Change Buffer 是按”目标页”组织的,同一页的多条变更天然聚在一起,Merge 时一次 IO 全部应用。
一个隐含的设计选择:Change Buffer 不是按照”操作顺序”而是按照”目标页号”组织。这样 Merge 时,同一页的所有变更可以一次性应用——这正是批量 IO 合并的基础。代价是:Write-Ahead 语义需要与 Redo Log 协调——Change Buffer 的变更也必须受 Redo Log 保护,崩溃恢复时才能保证不丢失。
💭 想一想:Change Buffer 里的变更会不会因为只存在内存而”掉”了?——不会。因为 Change Buffer 是 BufferPool 的一部分、本身有页,其变更同样受 Redo Log 的 Write-Ahead 保护,崩溃恢复时 Redo 能把它重放回来。所以它换来的是”延迟写”,不是”不写”,持久性没打折,只省了随机 IO。
章末提问
追问 1:Change Buffer 为什么只对非唯一二级索引生效?
回答思路:结论先行——因为唯一索引必须读索引页做唯一性检查,这一步天然就”读到了页”,缓存省读页的价值没了。因为判断 X 是否已存在只能靠读索引页,Change Buffer 里只存了”插入了哪些值”、没有其他行数据,无法就地判重;既然无论如何都要读页,ibuf_should_try 就用 !dict_index_is_unique(index) 把它挡在缓存之外。
追问 2:为什么”写入后立即查询”是反模式?
回答思路:结论先行——因为它让 Change Buffer 缓存的价值归零,还多付了一次 Merge 开销。因为插入被缓存在 Change Buffer 里,紧接着的查询要读目标索引页,会触发被动 Merge:读索引页 → 应用缓存变更 → 再返回;总 IO 跟直接插入索引页差不多,却多了 Merge 逻辑,等于”缓存了等于没缓存”。写多读少的日志/流水类业务才是它的主场。
追问 3:Change Buffer 的变更在什么时机被合并到索引页?
回答思路:结论先行——三类时机:被动 Merge(读页时)、主动 Merge(后台 Master Thread)、以及表 DROP/ALTER 时批量刷。因为被动 Merge 由查询读入某索引页触发,先应用该页的缓存变更再返回;主动 Merge 由 Master Thread 每 1 秒/10 秒按页批量合并;DROP/ALTER 时把该表全部待合并记录刷掉。三类合起来保证变更最终都会落到索引页、不会一直悬着。