ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL InnoDB redo log深度解析:一条INSERT事务的完整生命周期

MySQL InnoDB redo log深度解析:一条INSERT事务的完整生命周期 一条INSERT语句从客户端敲下回车到事务提交成功中间到底发生了什么如果你只能回答“写缓冲、刷脏页”那可能还不够。很多后端开发者能把索引结构、隔离级别背得滚瓜烂熟但一遇到数据目录里的ib_logfile0、ib_logfile1就开始发怵。这篇我就从一条真实订单事务的完整生命周期切入把InnoDB的redo log从生成、写入、刷盘到崩溃恢复、再到与binlog、分布式事务的纠葛掰开揉碎。适合正在学MySQL内部机制的同学也适合被线上redo log膨胀和提交卡顿折腾过的运维。1. 一条事务的红与黑redo log到底在记什么1.1 事务落地的那张“草稿纸”redo log是InnoDB事务持久性的基石本质上是一份“操作流水账”。它不记录SQL语句原文而是记录对物理数据页的具体修改描述比如“在表空间ID5页码102偏移量1000的位置写上值1234”。这种格式介于物理日志和逻辑日志之间有个专门叫法叫“物理逻辑日志”意思是它描述的是“在这个页面的哪个位置做了什么逻辑变更”而不是整页二进制copy也不是“把某行金额改成99”这种纯逻辑描述。为什么需要它你可以把数据文件当成一本正式总账业务里每笔订单都要记进去。问题是修改总账页面的位置是随机分布的如果每笔事务都立刻去修改磁盘上的数据文件那就是大量随机小写一次commit要来回搬好多次磁盘性能直接废掉。redo log解决的方式特别像账房先生先拿一本草稿纸又快又稳地按顺序记流水账等到夜深人静再让人把草稿誊进总账。这样客户只要看到流水账上落了笔这笔交易就“稳了”至于总账什么时候誊可以后面慢慢来。流水账本身也必须落盘否则客户一走草稿纸就丢了。所以事务提交时InnoDB要确保这个事务产生的redo日志已经写入磁盘上的日志文件才能告诉客户端“提交成功”。这也是WALWrite-Ahead Logging预写日志的核心思想先写日志后改数据日志一旦落盘事务就不会丢。1.2 为什么不是直接写数据文件可能有人会问redo log每次也是写磁盘不也有IO开销吗为什么比写数据文件快关键差距在于IO模式。数据文件通常几百GB甚至几TB一个页分布在全国各地——不是是分布在磁盘各处。更新一行数据可能要访问很多次但每次落在磁盘上的位置都不固定这就是随机IO。机械硬盘最怕随机IO因为要不断移动磁头寻道时间够CPU干一大堆事了。即使上了SSD随机小包写也比顺序写差不少。而redo log文件是固定大小、循环使用的几GB文件写入位置永远在末尾往后追加。新日志上去的时候磁盘几乎在按顺序写顺序写的效率要比同样体量的随机写高一个到两个数量级。更重要的是redo log不需要去“读取”旧内容它永远只管写每次commit的延迟主要在fsync这个系统调用上只要攒够一批再刷IO次数就能大幅摊薄。有人还较真说数据文件也通过脏页机制批量刷不也是顺序吗但刷脏页的位置不连续而且频率由后台控制。前台事务如果直接改数据文件等于是被随机IO卡脖子改成写日志则把“高延迟随机写”变成了“低延迟顺序写”再把真正的数据写延后到后台合并。这就是redo log对性能最本质的贡献。2. 一条INSERT的完整生命周期以订单表为例2.1 从begin到第一个数据页缓冲池里发生了什么假设业务场景是用户下单执行这样一条插入BEGIN; INSERT INTO orders(order_id, user_id, goods_id, amount) VALUES(1001, 888, 666, 99.00); COMMIT;事务从BEGIN开始InnoDB内部会分配一个事务ID此时还不会立即写redo。真正的动作发生在第一条DML语句上。执行INSERT时InnoDB首先要确定订单数据落在哪个页。如果order_id1001要写入的页已经在Buffer Pool缓冲池里直接改内存如果不在则要从磁盘把数据页加载进内存。这一步已经是一次磁盘读但通常比随机写要好一些而且热点页可能早就在内存中。页加载完成后InnoDB在内存中修改这个页插入一行新记录然后把页标记为脏页。注意此时磁盘上的数据文件内容还是旧状态什么都没变。所有变化都存在于内存中这是它们最脆弱的时刻。同时为了支持未来的回滚InnoDB必须在修改数据页之前生成一条undo日志记录“怎么把这条记录删回去”。undo日志写在哪在系统表空间的回滚段里本质也是数据页的一种。所以undo页一旦被修改本身也需要保护这意味着undo日志的变化也要记redo日志。很多人忽略了这一点——你回滚时看到的“先生成一堆日志再删掉”其实日志量早就翻倍了。2.2 redo日志从生成到落盘的每一步InnoDB内部为了记录这条INSERT会把操作拆成多个mini-transaction迷你事务每个mtr包含一批redo日志记录。比如插入这条记录可能涉及“修改页的空闲空间列表”、“修改页面中对应的槽位”、“写入记录本身”等多个操作每个操作对应一条或多条redo记录。这些记录组装好之后统一写入内存中的redo log buffer。redo log buffer是内存里的一块环形缓冲区大小由innodb_log_buffer_size控制默认16MB。它的作用是把多个事务的日志先攒在内存再批量刷盘减少IO次数。当mtr提交时日志会从mtr的私有内存拷贝到全局的log buffer。这里有个细节分配LSNLog Sequence Number日志序列号。LSN就是给每条日志编的号单调递增。你可以把它理解成流水账的页码。log buffer中的日志和LSN是一一对应的后续刷盘、检查点、崩溃恢复都靠这个数字对位置。日志什么时候从log buffer刷到磁盘的redo log文件至少有四个触发时机事务提交时根据innodb_flush_log_at_trx_commit参数决定刷法后台有个线程大约每秒刷一次log buffer当log buffer已经用了超过一半内存时为了腾空间主动刷发生checkpoint时也会把日志刷到对应位置。无论哪种时机最终都是调用write把日志从内存copy到内核缓冲区再调用fsync真正落到磁盘。2.3 提交那一刻commit、LSN与组提交事务提交时InnoDB要把“该事务的所有redo日志都写入磁盘”这个动作做扎实。参数innodb_flush_log_at_trx_commit的默认值是1意思是每次提交都把log buffer里的相关日志fsync到磁盘。只有确认fsync完成才返回“commit success”。如果这个值设为0则提交时只写内存log buffer由后台每秒刷一次盘性能最高但数据库崩溃时最多丢最近1秒的事务。设为2则提交时把日志写入操作系统文件缓存但不主动fsync操作系统崩溃会丢数据MySQL崩溃不一定丢。对交易类系统默认值1基本不能动。真正的性能优化点是组提交。试想每秒有上千个事务并发提交如果每个事务都做一次fsync磁盘就废了。组提交的思路是一个事务完成提交并持有日志写入权后把其他正在等提交的事务也拉进来大家共享本批次的一次fsync。MySQL 5.7以后对redo和binlog都做了组提交优化顺序大致是事务依次进入队列第一个事务负责把这一批事务的binlog写入并fsync之后一并把redo log刷盘这批事务一起返回成功。因为有了组提交阈值不高的时候每秒能提交的事务数往往远高于磁盘单次fsync的极限。参数binlog_group_commit_group_sync_delay和binlog_group_commit_group_sync_no_delay_count可进一步控制延迟攒批我一般不建议上来就调大除非业务能接受几十毫秒的提交延迟。2.4 刷盘与checkpoint持久化之后的持久化事务提交后数据页仍然在内存里脏页并没有落盘。这没问题因为redo log已经保证了崩溃恢复能力。但redo log文件不是无限大的它自己也需要循环使用。redo log文件组通常由几个文件组成比如ib_logfile0、ib_logfile1大小由innodb_log_file_size控制总数由innodb_log_files_in_group控制。MySQL 8.0.30之后引入了innodb_redo_log_capacity参数可以让你不再操心文件个数和大小直接用总容量兜底。文件用完一轮后InnoDB会回到开头覆盖旧日志。但绝对不能覆盖那些“对应脏页还没落到磁盘”的日志否则将来崩溃恢复时数据页和日志对不上就会永久不一致。为此InnoDB会定期推进checkpoint检查点。checkpoint本质是“一个LSN位置表示这个位置之前的日志对应的脏页都已经刷盘完成”。每次后台刷脏页或者崩溃恢复结束时都会推进checkpoint。系统运行时后台线程持续将脏页刷入磁盘然后推进checkpoint LSN。被checkpoint覆盖掉的redo日志空间可以被新日志重新使用。这里有个线上常见的隐患如果脏页刷得太慢checkpoint一直停在老位置redo log文件很快写满一圈InnoDB就会卡住所有新写入强制腾空间刷脏页。表现是磁盘没怎么忙但写入请求突然变慢性能曲线呈锯齿状。后面我会单独讲一个我排查过的案例。3. 崩溃恢复redo log如何让事务“死而复生”3.1 崩溃时内存全丢为什么数据不丢数据库进程崩溃可能发生在事务提交前、提交后、甚至刚好在刷盘那刻。无论哪种只要内存中的log buffer还没来得及刷盘这部分日志就真的丢了。但对于已经提交的事务只要innodb_flush_log_at_trx_commit1它的redo日志已经躺在磁盘日志文件里。崩溃恢复的第一步是找到最近一个checkpoint点。checkpoint之前的日志对应数据页都落盘了不需要管checkpoint之后到日志末尾的这部分代表着“有些修改可能没有落盘”。InnoDB会从checkpoint的LSN开始顺序读取redo日志一条条重放到数据页上。重放是幂等的如果脏页实际上已经刷入了最新数据再重放一遍也没有破坏性如果目标页还没有刷重放正好把它更新到崩溃前的状态。重放完成后缓冲池里那些丢失的数据页其实就被“追”回来了。注意崩溃恢复不是“只恢复已提交事务”未提交事务所产生的物理修改也会被重放。因为redo日志是物理逻辑的恢复时它不知道哪个事务是否提交它只是把数据库恢复到“所有日志记录过修改之后”的物理状态接下来再靠undo来决定哪些要回滚。3.2 前滚与回滚redo、undo联手干活恢复过程分为两步。第一步前面说了用redo做前滚把磁盘上的数据页推进到日志末尾。但这时的数据页里可能包含了一些“不该被看到”的改动比如崩溃时一个事务修改了1000行但还没提交这些修改如果不清除等数据库启动后数据就是脏数据。第二步是回滚。InnoDB需要扫描undo日志找出所有“没有commit标记”的事务。每个事务在undo段里记录了它修改前的旧值。InnoDB根据undo信息生成反向操作把数据页恢复到事务开始前的样子。这一步听起来简单但实际开销很大因为涉及大量的页读取和更新。这里就看出redo和undo的分工redo管“把修改恢复到数据库”undo管“把不该有的修改抹掉”。两者配合起来数据库才能既恢复出已提交的全部数据又不残留未提交的半成品。崩溃恢复还涉及redo log和binlog的一致性内部XA事务的prepare记录会写在redo log中binlog是否写入也会被检查。如果事务在redo中处于prepare状态但binlog里有对应日志则视为事务已提交如果binlog里没有则应回滚。这套逻辑是为了保证主从复制下两个库的状态一致。3.3 恢复过程要多久关键参数与优化很多人以为崩溃恢复是个瞬间动作实际不是。恢复时间跟“checkpoint点到日志末尾的距离”强相关。如果系统长期高负载运行checkpoint推进不及时日志文件巨大恢复时要重放几十GB的日志启动时间可能以分钟甚至小时计。MySQL 8.0对这个“问题”有改进但原理没变。我建议普通DBA至少把innodb_log_file_size和innodb_redo_log_capacity设置在足够应对高峰写入量的水平同时监控“Log Checkpoint”和“Log Flushed up to”之间的差值别让日志文件写满。还有一点崩溃恢复和把数据库从备份恢复到某个时间点不是一回事。物理备份xtrabackup通常会把备份瞬间的redo日志一起复制恢复时同样要redo重放。如果备份一致性没做好恢复出来的库也会缺数据。4. redo log与事务相关的几个“大坑”4.1 大事务redo log膨胀与清理不及时我见过一个经典事故凌晨跑批一条UPDATE同时修改了3000万行数据运行了40分钟。这期间redo log文件以肉眼可见的速度疯涨把整个日志空间写满然后所有业务insert被阻塞最终把白天正常业务一起拖死。为什么大事务这么狠因为事务未提交之前它占用的redo日志空间是不能被覆盖的否则一旦需要回滚或崩溃恢复就没有完整信息。即使污染了大量页面数据文件也还没落盘checkpoint被卡在事务开始之前。如果这期间有大量其他事务日志文件就瞬间被“撑破”。线上处理大事务的基本原则是拆分。一个事务尽量别超过几万行更新宁可多开几个事务。如果业务实在拆不开至少把它安排在低峰期并且临时调大redo日志容量否则就是拿全库的可用性赌运气。4.2 DDL与临时表redo log的另类处理DDL操作比如ALTER TABLE也会产生redo日志。Online DDL的几次“copy阶段”会把原表数据复制到新表逐行修改页期间同样写入大量redo。所以你在低峰期执行一条大表DDL观察redo log使用率也会明显飙升。临时表在InnoDB内部如果用CREATE TEMPORARY TABLE存放其数据页如果已在内存中也会被记录redo。不过MySQL 8.0里临时表有自己的TempTable机制不一定走redo。这里不展开太多但有个经验不要在一个长事务里反复大量操作临时表因为它不仅占用临时表空间还可能拖住日志刷新节奏。4.3 回滚段与undo为什么回滚也会写redo在很多人的认知里回滚时事务撤销了数据库应该“回到之前”日志白写了。物理上不是这样回滚也是一次修改修改数据页本身、修改undo页本身都要设计物理恢复所以回滚操作会产生新的redo日志。只不过这些日志的作用是应用“旧值”而已。举个例子事务A将订单金额从50改成100事务提交前回滚。第1步生成redo记录“将金额改成100”第2步生成undo记录第3步回滚时生成redo记录“将金额改成50”。从日志量看一个回滚事务可能比正常提交的事务还费日志因为正反两遍都记录过。所以别以为“执行即回滚”没成本它的成本一字不落地写在redo log里。这里也解释了为什么长事务回滚比提交更危险回滚期间它持有的undo和redo空间都不释放其他事务也没法重用这些日志区。5. 分布式事务场景下redo log的另类应用5.1 XA两阶段提交redo log与binlog的“双写一致性”MySQL内部为了让redo log和binlog都正确使用了内部XA。先说为什么需要两份日志redo log保证MySQL自身崩溃恢复binlog用于主从复制、PITR时间点恢复。如果两边没有共识主库恢复出来一条记录备库却因为binlog缺失而没有这条记录主备就分叉了。两阶段提交的关键动作事务在InnoDB内修改数据生成redo日志此时事务处于prepare状态redo log中记下prepare LSN写入binlog并在其中记录对应的XID事务进入commit阶段redo log中写入commit标记事务最终提交。崩溃恢复时InnoDB扫描redo中处于prepare的事务。如果binlog中能找到对应XID说明binlog已经落盘事务应该提交如果binlog没有事务应回滚。这一机制确保了即使崩溃时刻刚好卡在“写binlog”和“写commit”中间主库的最终状态和binlog里的事件也完全一致。5.2 订单与库存的分布式事务redo log能帮我们做什么回到热词“订单与库存分布式事务”。假设订单系统单独用一个库库存系统用另一个库一次下单要同时写两个库这就是跨库分布式事务。此时单个MySQL的redo log只能保证“订单库本地这个事务不丢”不能保证“库存库也同时扣减成功”。通常的落地方案要么是TCC、Saga这类分布式事务框架要么用本地消息表binlog订阅做最终一致性。很多后端同学在方法上写个Transactional以为就能同时管两个库——这是最容易踩的坑。Spring的Transactional默认只管理一个DataSource上的本地事务跨库时会发现要么第一个库事务提交了第二个库失败两边都对不上。如果非要用MySQL原生能力可以尝试XA让外部事务管理器协调两个MySQL实例。但说实话在高并发订单场景我很少见有人用跨库XA性能差实现复杂还容易出死锁。更实用的方案是“先落订单本地事务再异步发消息扣库存”。这个过程中本地事务的可靠性就靠redo logbinlog撑着比如Canal订阅binlog后投递到MQ最终扣库存。这样即便扣库存失败也能通过补偿重试。redo log在这里的价值是确保“本地事务一旦提交binlog必然存在”从而让下游拿到确定性的数据变更事件。5.3 备库实时应用与standby redo log如果你接触过Oracle Data Guard应该听过“standby redo log”这个词。Oracle的备库配置standby redo log是为了让从主库传过来的日志能更快落盘从而支持实时应用不需要等归档再应用。MySQL没有“standby redo log”这个对象主从复制走的是binlog relay log但在思路上有相似之处。MySQL备库要想接近“实时”首先主库binlog要尽快传到备库并写入relay log然后备库的SQL线程要尽快应用。MySQL 8.0的并行复制MTS能把一个事务组内不同事务并行应用这有点像Oracle实时应用机制思路的简化版。实操中我习惯在备库设置replica_parallel_workers和replica_parallel_typeLOGICAL_CLOCK同时关注主库的组提交参数因为组提交批次越密备库越能并行重放。顺带说一句备库的relay log也会占用磁盘如果主库一个大事务产生了几GB的binlog备库的relay log同样会噌噌上涨。很多事故是主库正常、备库磁盘被打满然后复制中断。这种问题保守的做法是给relay log目录监视磁盘配额同时监控主库大事务。6. 实录一次redo log引发的性能事故及排查过程6.1 症状写入陡降、磁盘IO打满去年我处理过一套订单库的线上故障。现象很典型下午3点业务高峰应用监控显示“事务提交时间”从平时5毫秒涨到200多毫秒写成功率直线下降。看CPU并不高load却很蹊跷磁盘util接近100%。乍一看像磁盘坏了但iostat显示写IOPS不高只是await非常大。更奇怪的是show engine innodb status里能看到大量线程状态是log_flush_not_needed或者waiting for redo log flush。我知道问题大概率卡在redo log刷盘上。继续看发现checkpoint lag数值一直在拉大而且redo log文件使用率早已接近99%。说白了磁盘一直忙但忙的是“被迫提前刷脏页”因为redo日志快写满了必须把脏页刷下去、推进checkpoint才能释放一点日志空间。6.2 排查从Parameters到Group Commit一步步查下来发现几个叠加因素这台机器用的机械盘RAID卡带缓存没开单次fsync要3毫秒左右innodb_log_file_size配置只有256M而业务每小时生产的redo日志接近2G因为没有开组提交相关参数每个事务提交都单独触发一次fsync日志空间飞快耗尽由于日志写满后台线程疯狂刷脏页又和前台业务争抢磁盘带宽形成恶性循环。查看innodb_flush_log_at_trx_commit确实是1这是好事说明没靠牺牲持久性来掩盖问题。问题出在日志空间太小和fsync次数太多。6.3 解决参数调优与架构调整当天先做了一个紧急操作把日志文件调大。由于是社区版需要先innodb_fast_shutdown0干净停库然后把innodb_log_file_size改成4GInnoDB会重建日志文件再启动实例。几分钟后checkpoint不再被频繁触发写入延迟恢复到了15毫秒以内。随后我把组提交参数调了一下binlog_group_commit_group_sync_delay1binlog_group_commit_group_sync_no_delay_count20让事务在1毫秒窗口内尽量攒批。这会稍微增加提交延迟但能把fsync次数降到原来的五分之一。对订单业务完全可接受。磁盘设备短期无法升级但这些调整把峰值事务吞吐拉高一倍。后来我长期监控LogSequenceNumber的每秒增量估算每小时产生的redo日志量再按“日志文件够撑2个小时”这个标准来设置容量。这个经验后来复制到其他项目基本都能提前规避“日志写满导致抖动”的坑。7. 实操心得那些文档上不会写的redo log细节最后讲几个我踩过的、文档上不会明说的细节。第一判断redo log是否瓶颈别只看使用率。redo log使用率90%不一定代表危险真正要看的是Log Checkpoint和Log Flushed up to之间距离的增速。如果checkpoint lag在持续增长说明刷脏跟不上哪怕当前使用率只有30%迟早也会撞墙。第二innodb_flush_log_at_trx_commit的值从1降到0/2绝大多数业务根本不该考虑。降下去那点性能提升换来的是“可能丢最近一秒已提交事务”的风险。你永远不知道哪一秒的订单会丢。真觉得刷盘慢先看磁盘和组提交不要轻易牺牲持久性。第三观察大事务最有效的手段是查information_schema.innodb_trx重点看trx_rows_modified和trx_started。如果发现某个事务跑了很久且修改行数巨大立刻评估能不能提前熔断否则它每多活一分钟redo log就多承受一份压力。第四做备份时要额外留意redo log。物理备份如xtrabackup在执行阶段会上演“拷贝数据文件 持续收集binlog/redo”的组合戏法。如果业务高峰时候做全量备份redo产生速度极快备份线程都跟不上整体耗时和锁竞争都会加剧。我会把大备份挪到低峰并监控备份期间redo log增长是否异常。第五redo log文件损坏是最难救的故障之一。它不像数据文件可以用innodb_force_recovery跳过很多问题redo日志损坏严重时InnoDB几乎无法在不丢数据的情况下启动。所以我的态度是永远不要把redo log目录放在容易写坏的地方比如满载的、无UPS保护的、非企业级SSD上。给日志文件单独一个挂载点并且定期检查文件系统一致性这是最基本的保命操作。关于redo log和事务生命周期能聊的细节其实远不止这些。如果你手头也正被“事务提交慢”“checkpoint lag大”“大事务撑爆日志文件”折磨不妨按照上面的思路先看一眼日志容量和刷盘频率。很多时候问题不在应用代码而是在日志这个被大多数人忽略的角落。
返回列表