图 1:本文封面,一条 UPDATE 在 InnoDB 内部要走好几站。
之前解析「MySQL 的存储引擎有哪几种」这道真题时,主轴是 MyISAM、InnoDB 等引擎的对比选型;这篇往 InnoDB 内部走一步,回答一个更实在的问题:一条 UPDATE 语句发出去,存储引擎内部到底干了多少活?看懂这条链路,「写放大」这个词就从玄学变成了可以治理的工程问题。
先把结论立住:InnoDB 写路径的所有设计都围着同一个总原则——把随机写尽量攒成顺序写,把立刻写推迟成批量写。Buffer Pool、redo 日志、Change Buffer、双写缓冲区四个部件各司其职,但它们叠加在一起也意味着:你执行一次逻辑更新,磁盘上发生的物理写远不止一处,这就是写放大的来源。
**第一站:Buffer Pool。**更新不直接落盘,而是先改缓冲池里的数据页。缓冲池是 InnoDB 在内存里缓存表和索引页的区域,官方文档给出的经验值是专用数据库服务器上常把物理内存的八成分给它。它的淘汰策略是 LRU 的变体:新读入的页插在列表中点而不是头部,尾部约八分之三是「旧页区」,全表扫描涌进来的页不会立刻把真正的热页挤出去。被改过的页叫脏页,留在内存里等后台刷盘。
**第二站:redo 日志。**改内存页的同时,这次物理修改被追加进 redo 日志。redo 是顺序追加写,比随机写数据文件便宜得多;它存在的意义是崩溃恢复——机器断电时脏页可能还没刷盘,重启后靠重放 redo 把已提交事务的修改补回来。日志里用一个单调递增的 LSN 标记位置,检查点推进到哪里,之前的日志就可以被覆盖。这也是「先写日志再写数据」(WAL)思想在 InnoDB 里的落点。
**第三站:Change Buffer。**如果这次更新命中的是普通二级索引,而目标索引页恰好不在内存,InnoDB 不会为了改它专门从磁盘读页,而是把这条变更先记进 Change Buffer,等将来这页因为别的原因被读进缓冲池时再合并。省掉的是每次更新都可能触发的一次随机读。不过要注意时效性变化:在 MySQL 8.4 里,控制该特性的 innodb_change_buffering 默认值是 none,也就是默认关闭——是否开启要按你的写入特征(二级索引多不多、写入是否以随机更新为主)权衡,而不是默认它一直在替你工作。
**第四站:双写缓冲区。**脏页刷盘前,先整体写进双写缓冲区,再写到数据文件里的真正位置。它防的是「半页损坏」:一个 16KB 的页写到一半断电,磁盘上就是一个新旧混杂的坏页,redo 也没法在坏页上重放。有了双写,崩溃恢复时能拿到一份完好的页副本。名字里带「双」,但开销远不是两倍 IO——双写区是大块顺序写、一次 fsync 提交,比随机写两遍便宜得多。
图 2:写路径四站协同,写放大的来源与治理方向。
**写放大从哪里来,现在可以数清楚了。**一次逻辑更新最终对应:redo 的顺序写、双写缓冲区的一遍写、数据页刷盘的又一遍写;如果表上有多个二级索引且更新随机,Change Buffer 的合并还会带来额外的读与写。再乘上一个隐蔽的系数:页是 16KB,你改的可能只是其中几十字节的一行,刷盘却要整页重写。放大数倍是常态而不是异常。
**治理方向对应着来源。**批量提交能摊薄每事务的日志刷盘开销;提高缓冲池命中率能减少换页与合并压力;二级索引少、工作集基本装得进内存的库,关掉 Change Buffer 反而省事(8.4 的默认值也正是这个取向);存储介质与主键设计上向顺序写倾斜,页分裂少了,刷盘总量自然下来。每一条都对应写路径上的一站,盲调参数不如先定位放大发生在哪一站。
**常见误区。**一是把 redo 当多余开销,想关掉提速——没有它,崩溃即丢已提交数据,这条防线不能拆;二是把双写当成「白白写两遍」,忽略它是顺序写加单次 fsync 的便宜保险;三是默认 Change Buffer 一直在生效,按老经验估算写入成本;四是看到磁盘写入量是数据量的好几倍就以为出了故障,其实先算一遍写路径上的账,很多「异常」是设计的代价。
下次有人问你「InnoDB 写性能怎么调」,先把这条 UPDATE 之旅画出来,再问瓶颈站在哪一站。你排查过最离谱的一次写放大是多少倍?欢迎分享。