ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL Change Buffer详解:原理、调优与生产实践

MySQL Change Buffer详解:原理、调优与生产实践 很多做 MySQL 性能分析的同学都遇到过这样一个画面buffer pool 明明已经把热数据都装进去了磁盘 I/O 却依然像流水一样往外淌。排查半天有人告诉你“去查查 Change Buffer”你翻开手册看到“用于缓存二级索引的变更”这么一行字还是不知道它跟你眼前的慢查询有什么关系。这篇会从一条 INSERT 语句怎么触达磁盘讲起把 MySQL 的 Change Buffer 彻底拆开它为什么出现、内部怎么存、什么时候合并、参数怎么调、生产环境有哪些坑。不管是 DBA、后端开发还是刚接触 InnoDB 存储引擎的运维同学看完都能把它真正用起来。1. 先从“给二级索引写数据为什么慢”说起1.1 二级索引写入的随机 I/O 困境InnoDB 表的数据用聚簇索引主键索引组织行数据就挂在主键这棵 B 树的叶子节点上二级索引则是完全独立的 B 树叶子节点存索引列值和主键值。任何一次 INSERT、UPDATE 或 DELETE都要求聚簇索引和这张表上所有包含被修改列的二级索引同步变更。聚簇索引在多数业务里主键是自增 ID新数据基本是顺序追加页面命中率很高写路径几乎全部在 buffer pool 内完成。二级索引却完全相反如果索引列是 UUID、随机字符串这类离散值新记录对应的叶子页每次都可能不同而这些页绝大多数时候根本不在 buffer pool 里。InnoDB 在这种情况下的传统做法是先从磁盘把目标索引页读进内存在内存里修改后再标成脏页等后续刷盘。本来只是一次逻辑写却被放大成了一笔磁盘随机读再加一笔磁盘随机写。机械盘时代一次随机读是 10ms 量级就算换成性能不错的 NVMe SSD随机读也要几十微秒而内存操作是纳秒级。写请求一旦密集磁盘就成了绕不开的瓶颈。展开说说放大效应一张表如果建有 3 个二级索引每次 INSERT 在最坏情况下要为每个索引都读一次不存在的页、写一次随机位置。批量导数和高频流水写入场景里这个开销会被成百倍放大这也是为什么有些写入型业务在加完索引后性能直接垮掉。1.2 Change Buffer 的核心思想先记账后合并Change Buffer 的思路其实很像我们平时的工作习惯与其为改一个小数专门跑一趟银行不如把要改的内容记下来攒一批再统一处理。InnoDB 遇到二级索引页不在内存时不会立刻去磁盘读页而是把这次变更先写进一个专用的缓冲区并按“目标索引页”分类存放。等这个索引页将来因为查询、扫描或其他 DML 从磁盘被读进内存时InnoDB 再把该页积攒的所有变更一次性合并进去合并完成后该页就相当于在内存里正常执行了这些操作。这个设计换来两个实打实的收益第一写路径上省掉了磁盘随机读原本要先读页再改现在只追加一条紧凑的变更记录第二合并时把针对同一个页的多条变更聚合成一次批量修改多次随机写变成了一次写。换句话说Change Buffer 拿“顺序的、紧凑的缓冲写”换掉了“随机的、昂贵的索引页读写”这也是它最核心的价值。需要立刻说明的是Change Buffer 只是把“修改索引页”这件事延后了它不会让数据在崩溃时丢失。被缓冲的变更同时会以 redo log 的形式持久化保证实例异常退出后这些变更仍然可以恢复。1.3 从 Insert Buffer 到 Change Buffer设计逻辑哪里变了如果你搜过老资料会看到一个叫 Insert Buffer 的概念。它是 Change Buffer 的前身最早在很老的 InnoDB 版本里就出现了当时只支持缓存 INSERT 操作对二级索引的修改。随着业务复杂度上来大家发现 UPDATE、DELETE 同样会让二级索引页陷入随机写泥潭于是 MySQL 5.5 把这些操作的缓冲能力也加上了参数也从 innodb_insert_buffer 变成了 innodb_change_buffering。这里有个容易被误解的点这里说的 insert、update、delete 不是指“事务操作的类型”而是指“操作落到二级索引上产生的变更类型”。比如一条 UPDATE 改了一个非索引列那它不影响二级索引也就不会产生变更缓冲如果改的是索引列二级索引可能要先删除旧键再插入新键这种变更同样可以被缓冲。到现在Change Buffer 负责四类变更INSERT 产生的索引插入、UPDATE/DELETE 产生的 delete-marking标记删除、真正的删除清理 purge以及更新产生的索引键变更。它能覆盖的大前提是目标索引不是唯一索引。唯一性校验要求当场读索引页这个机制稍后详细说。2. Change Buffer 到底怎么工作机制、结构与触发时机2.1 什么操作能进 Change Buffer什么操作必须实时落盘理解 Change Buffer 最快的方式是把“能不能进缓冲”当成一条流水线来判断。InnoDB 每收到一条针对二级索引页的变更会按顺序过下面几个条件目标页是二级索引页聚簇索引页不参与。目标索引不是唯一索引UNIQUE 索引必须实时读页做冲突校验。目标页当前不在 buffer pool 中已经在内存里的页直接修改就好。变更类型属于当前 innodb_change_buffering 配置允许的范围。表没有显式关闭 change buffering。满足以上条件变更进入缓冲任何一条不满足InnoDB 都会老老实实走原有路径读页、改页、脏页。生产里最常见的两个“绕行”场景一个是唯一索引另一个是 buffer pool 里已经存在的热页——只要页本来就在内存直接改就行没必要再绕一道缓冲。这里要纠正一个常见误区很多人以为 Change Buffer 是“所有写操作都先进缓冲、后台慢慢刷”。不是的它的入口条件很严格本质上是给“那些想读页又读不起页的随机写”一个替代方案。它的名字里有 Buffer但它并不是一个简单的先写后刷队列更像一个带条件的延迟执行计划。2.2 内存与磁盘ibuf 树的存储形态Change Buffer 的内部实现是一棵 B 树我们一般叫它 ibuf 树。它的根页固定在系统表空间里树上的每条记录不是完整索引数据而是一种转义后的缓冲条目大体包含目标表空间 ID、目标索引页号、操作类型以及变更内容。所有变更会按目标页号聚合排列这样合并时同一个索引页的数据是连续的一段方便批量应用。在内存侧ibuf 树同样占用 buffer pool 的空间。你可以把 Change Buffer 理解为“buffer pool 里划出一块区域用来缓冲对不在内存的索引页的变更”它内部的页也遵循正常的脏页刷盘机制。考虑到这点就不难理解innodb_change_buffer_max_size 设成 25% 并不是说 Change Buffer 占了整个 buffer pool 的 25%而是它能使用的上限比例。另外一个值得记在文档里的细节是ibuf 树对缓冲条目做了大量压缩。它并不是一字不改地保存一条完整二级索引记录而是规范化成一个最小信息集。普通二级索引插入记录在 Change Buffer 里通常只需要占用很小的空间这也是为什么它能以很小的内存和磁盘开销承接海量离散写。相比之下如果让你把那些随机的索引页全部塞进 buffer pool成本会高好几个数量级。2.3 Merge 的触发被动读、后台线程与空间压力Change Buffer 里的记录终究要合并回去否则磁盘上的索引页永远都是旧的。合并触发时机大体有下面几类被动合并最常见也最容易被忽视某个二级索引页因为点查、范围扫描、UPDATE 或 DELETE 被真正读取时InnoDB 先把该页所有待合并变更应用进去再返回结果。也就是说“读”一定会把欠账连带还掉。后台主动合并后台线程会周期性地扫描 ibuf 树挑选一些索引页做 merge避免积压过大。空间压力合并Change Buffer 占用达到 innodb_change_buffer_max_size 上限时会主动合并一些占用较大的页来腾空间。实例关闭及相关资源释放场景需要释放内存或做清理时也会触发合并。理解“被动合并”特别重要因为它是 Change Buffer 最大的副作用来源。你白天跑报表扫到某张二级索引页猝不及防把它积压的变更全合并掉单次读的数据量可能瞬间放大几十倍。这不是 bug而是设计上“先记账”的成本只是很多人没预料到。掌握触发时机是后续调优和排障的前提。3. 改参数前先看懂配置与监控指标3.1 innodb_change_buffering五档位怎么选innodb_change_buffering 是控制哪些操作可以被缓冲的核心参数MySQL 5.5 之后支持这几个取值all默认值缓冲插入、delete-marking、purge 和更新产生的键变更也就是全部。inserts只缓冲插入操作。deletes只缓冲 delete-marking 操作。changes缓冲 inserts 和 delete-marking不缓冲 purge。purges只缓冲后台清理产生的变更。none完全不缓冲。线上怎么选我的经验是不确定就先保持 all大多数通用场景下 InnoDB 的默认行为是经过充分权衡的。但如果你的业务是典型的读多写少、二级索引点查频繁可以考虑调整为 inserts 或 changes减少 merge 对读路径的干扰。deletes 和 purges 单独选的情况在真实业务里很少见别为了调参而调参。这里要留个心眼参数改完会立即对新的变更生效但没法用一条命令告诉你“它现在好还是不好”必须结合下一节的监控手段做前后对比。生产环境改这个参数在 MySQL 8.0 里是动态的可以 SET GLOBAL 直接调不需要重启实例风险比想象中低。3.2 innodb_change_buffer_max_size25% 不是金标准这个参数控制 Change Buffer 占 buffer pool 的上限比例默认 25%范围是 0~50%。很多人把它理解成“越大越好”实际上不是。什么时候该调大写入量极大、二级索引又多而且业务能承受读延迟偶发抖动。比如离线导入任务、日志类异步写入我会把 max_size 上调到 40% 甚至 50%前提是 buffer pool 本身够大、内存还有余量。什么时候该调小内存紧张、读路径敏感、二级索引的页频繁被点查命中这种场景把它压到 10% 甚至 0 都合理。还有一个关键认知25% 是“上限”不是“目标”。绝大多数系统里 Change Buffer 的实际占用远低于这个上限。你真正该关注的是实际占用率和 merge 频率的匹配关系。只有当你从监控里看到 Change Buffer 长期接近满负荷、频繁触发空间压力合并时才值得去动这个数字。否则调整它只是在制造新的不确定性。3.3 用状态变量和 metrics 判断 Change Buffer 的健康度判断 Change Buffer 工作得好不好不要靠猜。MySQL 提供了不少观测口我把最常用的整理在下面。最传统的是 SHOW STATUS 里的 Innodb_ibuf 前缀变量Innodb_ibuf_inserts写入 Change Buffer 的操作次数。Innodb_ibuf_merged合并时实际处理的记录条数。Innodb_ibuf_merges合并次数。Innodb_ibuf_size当前 Change Buffer 使用的页数。判断逻辑很简单如果 Innodb_ibuf_inserts 很高而 merges 相对低说明写入被有效缓冲、合并成本可控如果 merges 很高但每次合并的记录条数很少说明很多缓冲数据是被读操作“挤兑”出去的读路径正在承受额外负担。SHOW ENGINE INNODB STATUS 里有一段 INSERT BUFFER AND ADAPTIVE HASH INDEX可以看到类似下面的输出------------------------------------- INSERT BUFFER AND ADAPTIVE HASH INDEX ------------------------------------- Ibuf: size 1, free list len 0, seg size 2, 0 merges merged operations: insert 0, delete mark 0, delete 0 discarded operations: insert 0, delete mark 0, delete 0重点看 size、merges 和 merged operations 三个数值。MySQL 8.0 里还可以通过 information_schema.innodb_metrics 查更细粒度的指标常见的包括 change_buffer_pages_merged、change_buffer_pages_evicted 这类计数可以用下面这种 SQL 一次性拉出来SELECT NAME, COUNT_RESET, STATUS FROM information_schema.innodb_metrics WHERE NAME LIKE change_buffer% OR NAME LIKE ibuf%;顺便提一句如果有条件结合系统层面的 iostat 看效果会更直观。开启 change buffering 前后磁盘写入量和随机写占比应该有可观察的变化。如果这两个数据纹丝不动说明你的业务其实没怎么吃到这个机制的福利可以考虑把资源让给别处。4. 生产环境实测那些年踩过的 Change Buffer 的坑4.1 白天点查突然飚 P99原来是 merge 在捣乱我碰到过印象最深的一个案例是这样的某订单流水表每天凌晨跑批任务批量更新几十万行状态白天则是大量的按订单号点查。系统原本一直很稳某次加了一个新二级索引之后白天点查的 P99 从 15ms 直接飙到 200ms后端告警不断。排查的时候iostat 显示读 I/O 显著上升SHOW ENGINE INNODB STATUS 里 merges 数据猛增。进一步分析发现凌晨批量更新大量打到了新索引的叶子页这些页不在内存里变更全部进了 Change Buffer白天点查一命中这些页InnoDB 必须先把积压的变更全部 merge 完才能响应单次查询背后背上了一大批记录的合并开销。这种场景的解法有几种。一是把该表的 innodb_change_buffering 改成 inserts让 update 产生的键变更实时落盘避免积压二是在批跑任务结束后主动触发一次合并比如做一次 SELECT COUNT(*) 把相关索引页扫一遍把账提前还掉三是优化读取路径把点查改成覆盖索引查询减少对二级索引页的直接访问。没有绝对最优得看你业务更在意写吞吐还是读延迟。这个案例也引出一个通用经验读和写都密集的表要给 Change Buffer 设定“缓冲但不积压”的边界否则它会把读写矛盾悄悄转移到读路径上。尤其是在报表系统和在线交易系统共存的环境里这类问题非常典型。4.2 唯一索引为什么不走 Change Buffer唯一索引是个很容易被忽略的机制边界。二级索引如果带 UNIQUE 约束InnoDB 必须在写入时立即确认键值是否已经存在因此必须实时读取目标索引页完成 uniqueness check根本没法先记进缓冲。所以你会发现一个规律一张表上唯一索引越多能走 Change Buffer 的机会就越少这个机制能起到的降随机 I/O 作用就越有限。批量导入场景如果大量触发唯一检查性能瓶颈大概率不在 Change Buffer 使没使上力而是唯一检查本身带来的读放大。真正要警惕的是“绕开”这种想法。有人为了吃到 Change Buffer 收益试图把唯一索引改成普通索引、把唯一校验交给应用层。这在某些内部系统、对脏数据容忍度高的场景可能是一种取舍但对金融、订单这类数据严格性要求很高的业务我强烈不建议这么干。与其冒险不如评估索引设计的合理性或者从业务侧把写入的 ID 生成方式改成有序值从源头上降低二级索引的随机触达。4.3 崩溃恢复、克隆与主从场景下的特殊表现Change Buffer 是受 redo log 保护的。缓冲记录在被写进 ibuf 树内存页的同时也会写一条相应的 redo 记录所以实例即使突然崩溃已经缓冲但尚未 merge 的变更也能在恢复后重新被找到。这个设计保证了“缓冲”不等于“丢失”但代价是崩溃恢复阶段 InnoDB 要重放这些变更启动后的一段时间里 merge 操作会比平时活跃这是正常现象。如果你用物理备份或克隆插件搭建新实例新实例的 Change Buffer 会经历一个重新积累的过程。开放流量后最初一段时间会出现明显的“补课式 merge”——旧业务高峰期被写进缓冲的那些页在新实例里会被读操作一个个触发合并。我见过不少人在搭建完新实例后立刻跑全量扫描验证数据结果 I/O 直接被打满其实就是这个机制在集中补作业。还有一个容易被忽视的是主从差异。主库和从库的读写模式通常不一样如果从库承担了较多的复杂查询、报表扫描它对 Change Buffer 的敏感度跟主库完全不同。调参时最好分别评估甚至可以让从库单独设置更小的 change buffer 上限。同一套 my.cnf 复制到所有实例在这类参数上并不是好主意。5. 按业务场景给结论调大、调小还是直接关掉5.1 场景速查表为了让你拿去就能用我把常见业务场景和对应的处理建议整理成一张表业务特点建议配置原因写多读少、二级索引多max_size 调大到 30%~50%写路径省随机 I/O 收益明显读多写少、点查为主innodb_change_bufferingnone 或 max_size 调小到 10%避免 merge 拖累读路径离线批量导入临时调大 max_size任务结束后主动 merge 一次大流量导入时缓冲收益最大唯一索引密集保持默认不要指望缓冲唯一检查绕不开实时读页内存紧张调小 max_size优先把 buffer pool 让给热数据注意这张表是经验值不是铁律。每个系统负载不同最终还是要靠监控数据说话。建议先小范围验证再逐步灰度到全量。5.2 调整后怎么验证收益改完参数不能拍拍屁股就走。我的验证套路一般是这样先抓一组基线数据包括 Innodb_ibuf_inserts、Innodb_ibuf_merges、Innodb_ibuf_size、磁盘 iostat 的随机读写量以及业务链路里最敏感的 P99 延迟再动态改参数观察至少一个业务完整周期最后对比基线和周期后的数据。这里特别提醒不要只看平均延迟要看峰值和 P99。Change Buffer 的副作用体现在偶发的 merge 上平均值会被大流量稀释只有高百分位能反映出抖动。如果你发现改动后平均延迟没变化但 P99 明显变好那已经说明它在读路径上帮了忙。如果多次调整后指标都没什么变化那就别纠结了。这个机制不是为你的业务设计的保持默认值、把精力放到索引设计和 SQL 优化上往往收益更大。技术选型里最贵的成本永远是“为了调而调”的时间尤其是这种收益比较难量化的内核级参数。5.3 最后送一个我自己常用的验证小技巧最后分享一个我近几年一直在用的小技巧判断一张表到底有没有从 Change Buffer 里受益可以在低峰期做一次覆盖扫描扫目标索引或直接 SELECT COUNT(*)避免大规模回表观察扫描期间的磁盘读和 merge 计数变化。如果扫完之后 Innodb_ibuf_merges 明显上升但同时磁盘写没有爆发式增长说明这张表的随机写确实被缓冲住了机制在高效运转。反之如果扫描过程中系统 I/O 瞬间打满、merge 计数暴涨说明此前积压了大量待合并变更你的优化方向就应该偏向读路径而不是继续调 Change Buffer 参数。这个技巧不需要任何额外工具一个查询加两个状态变量就能完成适合拿来在变更前后做快速验证。等你在两三个系统上跑过这套流程对 Change Buffer 的理解就不再是手册里的那行字而是真能用于判断问题、指导取舍的实战手感了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进