1. 二级索引写入慢的根源:一场随机I/O的“围剿”
1.1 一次典型的“索引多反而慢”现场
前段时间有个同事跑来找我,说他的表只有两百多万行,按主键更新很快,可每次更新某个状态字段,一条SQL要跑几百毫秒。我让他把表的索引结构列出来,他给我看了六个二级索引。说实话,看到这个表结构我基本已经猜到答案了——问题不在SQL,也不在数据量,而在二级索引的写入路径上。
MySQL InnoDB的数据是按页存储的,默认每页16KB,索引用的还是B+树。当你更新一行数据时,业务上只改了一个字段,但InnoDB要做的事远不止这一行本身:聚簇索引里那一行要改,每一个包含该字段的二级索引条目也要同步修改。六个二级索引,就意味着数据可能散落在六个不同的B+树里,分布在不同的数据页上。只要这些页不在缓冲池里,更新前就得先从磁盘把它们读出来,每读一页就是一次随机I/O。
1.2 为什么聚簇索引的写入很少“喊疼”
那为什么按主键的写入很少抱怨慢?因为聚簇索引的写入天然有局部性。自增主键插入的数据不断追加在B+树右侧,新页面刚被创建出来就在缓冲池里热着;即使刷出去了,下一批插入大概率又连续落在邻近页面上。而二级索引完全不是这样。
拿状态字段举例。如果状态值只有“待支付”“已支付”“已发货”几种,二级索引B+树上的条目是按键值排序的,修改一个条目,命中的页面在整棵树上东一个西一个,跟你业务里插入的顺序毫无关系。你更新一万行散落在不同数据块上的记录,就要随机去读那一片片凉透了的索引页——在机械盘上这是灾难,即便在SSD上,多出来的读延迟也实实在在。
所以本质上,二级索引维护的痛点就是随机读取代价太高,而缓冲池帮不上忙:它只对“热页”有效,二级索引的页面恰恰是凉多热少。
1.3 一个办公室跑腿的比喻
打个比方。你在一栋办公楼里上班,修改数据好比修改文件,但文件分散在不同楼层的档案柜里。每改一个文件都要跑到对应楼层去翻档案柜,一天跑几十趟谁受得了。于是你在工位上放了个“便签盒”,需要改哪个文件就写张便签扔进去;等哪个同事刚好要去那层楼,顺手把对应的改动做了。或者哪天你自己非要去取那份文件时,一次性把便签上的事情都办了。Change Buffer就是InnoDB的这个便签盒,官方翻译叫“变更缓冲”。
2. Change Buffer的核心机制:先记账、后合并
2.1 从Insert Buffer到Change Buffer的一段历史
其实这个机制最早叫Insert Buffer,MySQL 5.5之前只对INSERT操作产生的二级索引变更做缓冲。后来版本发现UPDATE、DELETE同样制造大量随机写,MySQL 5.5开始把delete marking(逻辑删除标记)和purge(后台物理删除)也纳入缓冲,于是名字改成了Change Buffer。你如果翻源码,还是会发现满眼都是ibuf这个前缀,那就是当年Insert Buffer留下的烙印。
需要澄清一点:Change Buffer不是日志,也不是缓冲池里的“垃圾桶”,它本身是一个持久化的B+树,存放在系统表空间里,内存部分则占用缓冲池的配额。它的作用是在二级索引目标页不在缓冲池时,先把变更“记账”下来,等合适的时机再把这些变更合并到真正的索引页上。INSERT产生的二级索引新条目同样走这套机制,所以不要以为它只管更新。
2.2 一条UPDATE语句,InnoDB内部到底做了什么
拿一次普通的UPDATE来拆解,假设更新了某个被二级索引覆盖的字段:
- InnoDB先定位聚簇索引中对应的数据行,读取或更新数据页。如果数据页不在缓冲池,这一次原本就需要一次随机读。
- 接着依次维护每个受影响的二级索引。对每个二级索引,InnoDB会判断目标索引页是否已经在缓冲池里。如果页是热的,直接把变更apply到页上,标记为脏页,等后续刷盘。
- 如果目标页不在缓冲池,并且该索引是非唯一索引,InnoDB就把这次操作记进Change Buffer,记录内容包括:哪个表空间的哪个页、什么类型的变更、变更涉及的记录数据。
- 客户端收到成功返回,整个过程不需要去读那个凉飕飕的二级索引页。
注意最后一步的关键:整个流程里,一次原本必须发生的磁盘随机读被省掉了。等到这个二级索引页真的被某条查询读进内存的那一刻,InnoDB会先把Change Buffer里针对该页的所有变更一次性apply完,再让查询看到最新的索引数据。因为页已经读进来了,合并的额外成本只是CPU和内存,这是很划算的买卖。
2.3 Merge的触发时机,比你想的更“懒”
Change Buffer里的记录不会自己消失,合并动作要么被用户查询“顺路”触发,要么由后台线程“主动”触发,我总结为四类:
- 目标页被读入缓冲池:最常见的一种。任何查询只要需要访问那个二级索引页,InnoDB都要先把该页上积压的变更合并完,然后再读。
- 后台线程定期合并:InnoDB的后台线程会持续观察Change Buffer的积压规模,达到一定阈值或发现某个页积压太多时,会加速合并,避免无限膨胀。
- 崩溃恢复阶段:实例重启或崩溃恢复时,Change Buffer本身的数据要参与恢复流程,确保不丢。
- 表结构或索引被重建:如果ALTER TABLE重建了索引,或者直接DROP TABLE,积压的变更会作废丢弃。
这里有个精巧的设计:如果某个二级索引页一直没被读到,变更就可以一直“欠着”。欠着的好处是,可能一次合并就把几十条针对同一页的变更全部apply掉,原本要发生的几十次随机I/O被压缩成一次;坏处是,万一哪天这个页被高频查询触碰,第一次读它会格外慢——这就是Change Buffer收益和风险的来源。
2.4 一个容易忽略的例外:唯一索引
很多朋友查了很久参数,发现Change Buffer好像“没生效”,最后一看表结构:二级索引带UNIQUE。对唯一索引,插入时必须立刻读目标页来判断是否有重复值,这一读就把随机I/O提前暴露了,Change Buffer自然派不上用场。你可以记住这个判断规则:唯一二级索引的写入无法被缓冲,非唯一二级索引才有资格“记账”。
3. 收益与代价的边界:哪些业务场景真的适合它
3.1 让Change Buffer发挥最大价值的三类工作负载
结合我的经验,以下业务形态最容易吃到Change Buffer的红利:
- 大表、小缓冲池:数据量远超缓冲池容量,二级索引页大多数时候是凉的。这种环境下,每次DML都去读一次凉页的代价极高,缓冲收益最大。
- 低基数字段上的索引:状态、类型、地域这类字段,索引条目大量聚集在有限的几个页面上。写入频繁时,同一页会被反复命中,Change Buffer能把多次随机写合并成一次。
- 写多读少的批量更新:比如批量把订单状态从“待处理”改成“处理中”,扫到的行在表空间里天南海北,但索引B+树上的目标页其实就那么几片。
这几种场景的共同点是:延迟写入能明显提升吞吐,而且目标页短时间内不太可能被读。说白了,Change Buffer赚的就是“省掉现在这次读”的钱,前提是读这件事最好永远别发生。
3.2 适得其反的场景:读多写少和顺序导入
反过来,以下场景Change Buffer的收益就很小,甚至添乱:
- 读多写少、且读经常触碰二级索引:如果二级索引页被读的频率很高,缓存的变更很快就会在读页面时被强制合并,写时的延迟并没有被真正“挪走”,只是从写路径搬到了读路径,还多了一层合并开销。
- 大批量顺序导入:往一张空表或新表里灌数据时,二级索引页刚创建出来都在缓冲池里热着,写入本来就是顺序命中,没必要记账。MySQL官方文档也明确建议:大批量恢复或导入数据期间,可以把innodb_change_buffering设为none,导入完再开回来。
- SSD上随机I/O成本大幅下降:机械盘时代,省掉一次磁盘随机读的收益巨大;SSD时代这个收益缩水了不少,但也没到必须关闭的程度——具体怎么权衡,我放到调优部分说。
3.3 一张表帮你快速判断该不该用
| 判断维度 | 建议 |
|---|---|
| 缓冲池与数据量比例 | 数据总量远大于缓冲池,继续用;数据基本全热,可考虑限制 |
| 二级索引基数 | 低基数索引多,收益大;高基数唯一索引多,收益小 |
| 读写比例 | 写多读少,收益大;读多写少,收益小 |
| 存储介质 | HDD收益明显;SSD收益缩水但不建议直接关闭 |
| 数据导入/迁移期 | 临时关闭更合适 |
4. 两个参数,搞定Change Buffer的开关和容量
4.1 innodb_change_buffering:先决定“缓冲什么”
这个参数控制哪些类型的二级索引变更可以进Change Buffer,取值如下:
| 参数值 | 含义 |
|---|---|
| all | 默认值。缓冲insert、delete marking、purge三类操作 |
| none | 完全关闭Change Buffer |
| inserts | 只缓冲插入操作 |
| deletes | 只缓冲delete marking(逻辑删除标记) |
| changes | 缓冲insert和delete marking,不缓冲purge |
| purges | 只缓冲后台物理删除 |
需要解释一下InnoDB的删除流程:业务DELETE先给记录打上删除标记(delete mark),此时索引条目还“活着”,只是不可见;之后后台purge线程再把记录和索引条目物理抹掉。所以deletes和purges是两个阶段的操作,它们都可能制造二级索引随机I/O,也都可以被缓冲。你如果只把值设成changes而不含purges,等于让物理删除阶段继续走随机读的老路。
4.2 innodb_change_buffer_max_size:给缓冲池切多大蛋糕
这个参数决定了Change Buffer最多能占用缓冲池容量的百分比,默认25%,可设置范围0到50%,并且可以在线调整。需要注意,它不是一开始就预留25%内存,而是按需增长。达到上限后如果还有写入,InnoDB会被迫加速合并来释放空间,这时候写入延迟就会出现一个被动抬升。
我个人的经验是:不要为了“充分榨干”盲目调到50%。Change Buffer的本质是把写I/O延后,延后得越久,积压越壮观,后面的合并风暴和崩溃恢复代价就越大。如果缓冲池本来就有64GB,25%意味着最多16GB空间给Change Buffer,绝大多数业务根本用不到这么多,调到5%到10%反而能给数据页留出更多内存。另外别忘了,这个百分比是相对缓冲池总大小而言的:你调整了缓冲池大小,Change Buffer的可用容量也会跟着变。
4.3 调优时的三组实操建议
- 默认配置先跑一个月:我不知道你业务的具体写特征,所以别一上来就动参数。先保持all和25%跑一段时间,结合下一节说的监控数据判断,再决定是调低还是调高。
- 批量任务窗口期临时调整:大批量UPDATE/DELETE前,如果担心积压,可以临时把max_size调低到10%,限制积压规模;任务结束后再调回来。这两个参数都支持在线修改,实践起来很方便。
- 导入/恢复期间直接none:数据迁移、load data这类场景,导入期间把innodb_change_buffering改成none,等导入完成后重建或校验索引,再恢复默认。
5. 监控Change Buffer:它到底干了多少活
5.1 从SHOW ENGINE INNODB STATUS里读取核心指标
这是最直接的观察入口,执行SHOW ENGINE INNODB STATUS\G后,找到“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:Change Buffer当前使用的页面数。持续逼近上限,说明一直在满负荷记账。seg size:这个B+树段的总页面数,包括空闲页;free list len是空闲页数量。merges:累计发生过的合并次数。merged operations:按操作类型统计的合并条目数,insert/delete mark/delete分别计数。discarded operations:被丢弃的缓冲条目。如果这个数字持续增长,可能是有大量带缓冲变更的表被DROP、重建,或发生了页重建,说明一部分写入做了无用功。
5.2 用information_schema.innodb_metrics做趋势监控
SHOW ENGINE INNODB STATUS只能看当前值,想看趋势就得靠计数器。MySQL提供了information_schema.innodb_metrics,先开启监控:
SET GLOBAL innodb_monitor_enable = 'change_buffer';然后查询:
SELECT NAME, COUNT, MAX_COUNT, AVG_COUNT FROM information_schema.innodb_metrics WHERE NAME LIKE 'change_buffer%';不同版本里计数器的命名略有差异,但语义是相通的,核心看两类:合并相关的change_buffer_merges、合并后apply的操作数,以及size相关指标。做一个定时采样的脚本,画出merges的速率曲线,就能很直观地看到某次批量任务对Change Buffer造成的压力,以及合并是否集中在某个时间段。我见过很多团队只盯着CPU和磁盘利用率,却忽略了这本账,等到出问题才回头查。
5.3 结合业务侧指标做收益判断
监控数字是死的,业务收益是活的。我通常在三个层面交叉判断:
- 数据库层:执行大批量随机更新前后,对比平均写入延迟。如果开启Change Buffer时写入阶段明显更快,说明缓冲在起作用。
- 系统层:看iostat里await和%util。Change Buffer生效时,写路径等待会下降,但可能会把I/O压力转移到后台合并的时段。
- 应用层:看业务高峰期有没有周期性抖动。如果每天固定时段出现一次慢查询尖峰,而这个时段恰好对应大范围读索引,很可能就是积压的合并集中爆发了。
6. 生产环境实战:我踩过的坑和最终建议
6.1 “变更风暴”:白天爽了,夜里还
最典型的坑是我在某条业务线踩过的。白天业务做了一整天的批量状态更新,写入因为Change Buffer变得飞快,我还挺得意。结果每天凌晨2点报表任务一跑,状态索引被大量查询触碰,积压了一整天的变更在半小时内集中合并,直接把磁盘I/O打满,报表任务全线超时。
后来我的处理方式分两步:一是把innodb_change_buffer_max_size从25%调到10%,限制白天的积压规模;二是在低峰期写了一个预热脚本,用类似SELECT COUNT(*) ... WHERE status IN (...)的轻量查询,按状态值分批去触碰二级索引,让页面的合并在凌晨1点前分散完成,而不是2点那一刻集中爆发。这个办法听着简单,但在当时的架构下非常有效。
6.2 我曾把Change Buffer关掉,结果写得更慢了
还有一次,接手的团队为了“优化”批量更新,直接把innodb_change_buffering设成了none。他们的逻辑很直接:关了它,省掉合并过程,更新应该更快。实测以后完全相反——一万行随机更新,每行都要去磁盘读凉透了的二级索引页,会话一个接一个卡在磁盘读取上。改成默认配置后,同样的批量更新反而快了一倍多。
这件事给我的教训是:不要因为“讨厌延迟”就去掉一个专门解决随机I/O的机制。Change Buffer不是缓存垃圾,它是拿“写时不做”换“读时再做”,在写多读少的场景下这几乎总是划算的。
6.3 恢复时间、DDL和唯一索引的隐性成本
除了合并风暴,还有几个隐性成本容易被忽略。Change Buffer积压过大时,实例崩溃恢复需要处理的内容会增多,RTO会变长;如果业务对可用性要求极高,需要把积压规模纳入健康检查指标。另外,频繁对带缓冲变更的大表执行ALTER TABLE,重建索引时会把积压的变更作废,等于之前的一部分缓冲工作白做了。唯一索引写多的大表,也别指望Change Buffer能救你——该做的唯一性检查跑不掉,该发生的随机读也跑不掉。
6.4 回到最开始那个同事的问题
最后说回文章开头那个“更新状态字段慢”的案例。我并没有急着调Change Buffer参数,而是先让他把六个二级索引逐个审视了一遍——其中三个索引在业务代码里根本用不到。把冗余索引删掉之后,同样的UPDATE从几百毫秒降到了几十毫秒。Change Buffer在后台默默省下了剩余索引的随机I/O,一切恢复正常。
所以我的总结很简单:Change Buffer是InnoDB为二级索引随机写准备的“便签盒”,它在写多读少、大表低缓冲池、低基数字段索引的场景下价值最高;但它的效果再大,也抵不过索引设计本身的问题。先把不需要的索引删干净,再谈调参数。这个工具箱里,它是个好工具,但不是一个能替代“合理设计”的工具。