Skip to content
Go back

MySQL Change Buffer:非唯一二级索引的写入加速器

一句话结论(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 TABLEALTER 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

关键指标:

五、写入后立即查询的反模式

-- 反模式:插入后立即按二级索引查询
INSERT INTO orders (user_id, status) VALUES (888, 1);
SELECT * FROM orders WHERE user_id = 888;  -- 触发 idx_user_id 的 Change Buffer Merge

执行分析

  1. INSERT 将 user_id=888 的变更写入 Change Buffer(无 IO)
  2. SELECT 需要读取 idx_user_id 的某个页 → 触发 该页的 Change Buffer Merge
  3. Merge 操作:从磁盘读索引页 → 应用 Change Buffer 中的变更 → 再返回查询结果
  4. 总 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 维护,sizemax_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 时把该表全部待合并记录刷掉。三类合起来保证变更最终都会落到索引页、不会一直悬着。


Share this post on:

Previous Post
MySQL JOIN优化——NLJ、BNL、BKA与索引失效全景
Next Post
MySQL Undo Log 详解:回滚段、MVCC 版本链与长事务之殇