深入解析MySQL InnoDB Undo Log:事务回滚与MVCC核心机制

发布时间:2026/10/7 11:06:55
深入解析MySQL InnoDB Undo Log:事务回滚与MVCC核心机制 1. 先搞明白Undo Log到底在解决什么问题在MySQL里InnoDB引擎之所以敢拍胸脯说“支持事务”底层靠的就是三样东西Redo Log重做日志、Undo Log回滚日志和Binlog归档日志。Redo Log负责“做完了的事要留下痕迹”Undo Log负责“做错了可以反悔”Binlog则负责“整个数据库的历史记录”。三者各有分工缺一个都可能出大乱子。我接触InnoDB Undo Log是从一个实际事故开始的。有一次线上业务跑了一个批量更新涉及几百万行数据结果业务逻辑有个判断写反了整个表的数据被改得面目全非。虽然MySQL事务本身没报错但因为每一条更新都带了旧值快照DBA用Undo Log一步步恢复了误操作的数据才避免了从备份重建的噩梦。那次之后我才真正意识到Undo Log不是“MySQL内部实现细节”而是每个后端开发都应该理解的核心机制。用生活化的话说Undo Log就像你写论文时的“草稿历史记录”每一步修改之前系统都会自动帮你把上一版存下来。如果改错了按CtrlZ就能一步步退回去。MySQL的CtrlZ就是靠Undo Log实现的。这篇文章适合谁看三类人写过SQL但没看过InnoDB源码的后端工程师想知道回滚和MVCC底层原理的DBA以及面试被问到“Undo Log和Redo Log有什么区别”时只能背答案的同学。看懂这篇文章之后你不仅能把Undo Log的底层原理讲清楚还能在实际排障时知道从哪下手。2. Undo Log的核心机制与设计思路2.1 事务原子性的“后悔药”是怎么来的事务的原子性Atomicity要求“要么全部成功要么全部失败”。如果事务执行到一半崩了或者你主动ROLLBACK数据库就要把已经修改的数据恢复到事务开始前。问题是InnoDB的数据页是16KB的物理页修改是直接在内存缓冲池里改的。直接改动意味着旧值不会自动保留。所以InnoDB的做法是每次修改数据之前先把旧值写入Undo Log。这样当事务需要回滚时就用Undo Log里的旧值逆着执行一遍UPDATE改成INSERTINSERT改成DELETEDELETE改回INSERT。这个设计思路其实特别朴素——做任何有风险的操作之前先留个备份。就像拆电脑之前先拍张照片装回去的时候对照着装。但是Undo Log的重要性远不止“回滚”这么简单。它还承担着另一个核心职责支撑MVCC多版本并发控制。MVCC让读操作不阻塞写操作、写操作不阻塞读操作而实现的关键就是“每个事务能看到自己应该看到的那一版数据”这一版数据的来源正是Undo Log中的历史版本链。2.2 Undo Log与Redo Log的明确分工很多初学者会把Undo Log和Redo Log搞混。我简单梳理一下对比项Redo Log重做日志Undo Log回滚日志记录内容物理层面的“修改了什么页、什么偏移、改成什么”逻辑层面的“原来值是什么怎么改回去”主要作用崩溃恢复时重放已提交事务的修改前滚回滚未提交事务、服务MVCC读回退方向向前恢复Redo向后恢复Undo存储位置redo log文件ib_logfile/innodb_redo_logundo表空间默认undo_001、undo_002生命周期循环覆盖写完一轮从头再来需要保留到无事务引用为止由purge线程清理核心动作记录新值物理变更记录旧值逻辑反操作一句话记忆Redo Log保证了“已提交事务不丢失”Undo Log保证了“未提交事务能撤销”。两者在崩溃恢复时是配合工作的先Redo前滚到崩溃点再用Undo回滚掉崩溃时未提交的事务。2.3 Undo Log如何服务MVCC的快照读MVCC的原理在面试中几乎必考。核心可以拆成三步每行数据除了业务字段还隐藏着一个DB_TRX_ID事务ID列和DB_ROLL_PTR回滚指针列。每次UPDATE时InnoDB不会原地覆盖旧版本而是把旧版本存入Undo Log新版本行的DB_ROLL_PTR指向Undo Log中的旧版本。读操作发起时生成一个ReadView活跃事务列表根据DB_TRX_ID判断当前事务能看到哪个版本。如果看不到新版本就顺着DB_ROLL_PTR去Undo Log里找老版本。这就是为什么一个长时间运行的查询长事务会让Undo Log不断膨胀——只要还有事务持有旧版本的ReadView那些Undo Log记录就不能被清理。我用一个极简场景说明事务A更新了某行数据但没提交事务B此时来查询这行数据。事务B的ReadView里看到活跃事务包括A所以它看不到A改的新值只能顺着回滚指针找到A修改前的旧值返回。整个过程不需要等待A提交或回滚这就是MVCC的“读不阻塞写写不阻塞读”。3. Undo Log的物理结构与存储细节3.1 undo表空间与回滚段的组织方式InnoDB的Undo Log不是随便往一个文件里塞的它的物理组织分了好几个层级表空间层独立的undo表空间文件默认undo_001、undo_002MySQL 8.0起不再使用共享表空间ibdata1存储undo。回滚段层每个undo表空间内有多个回滚段Rollback Segment默认128个。undo slot层每个回滚段内有1024个Undo Slot每个Slot对应一个事务可能用到的Undo Log链表。undo页层真正存储undo记录的数据页一个事务的所有undo记录按类型分别链成链表。可以用一个生活比喻来理解undo表空间就像一个大仓库回滚段是仓库里的不同货架undo slot是货架上的货位undo页是货位上放着的文件盒文件盒里装的就是一条条undo记录。MySQL初始化时会在undo表空间中预分配回滚段每个回滚段管理一组页。当一个事务需要写undo记录时会从回滚段中分配一个slot然后找到对应的undo页开始写入。3.2 undo页与undo记录的格式结构每个undo页默认16KB与InnoDB数据页大小一致。页的头部有一个TRX_UNDO_PAGE_TYPE字段标记这是Insert Undo页还是Update Undo页这个类型决定了页内记录的解析方式。一条undo记录本身也有固定的头部结构虽然具体字段在不同版本的MySQL里略有差异但核心的几项一直存在TRX_UNDO_REC_TYPE记录类型标记这是INSERT、UPDATE还是DELETE产生的undo记录TRX_UNDO_TRX_ID产生这条undo记录的事务IDTRX_UNDO_ROLL_PTR指向上一条undo记录的指针形成版本链TRX_UNDO_DEL_MARKSDELETE标记标志位对于UPDATE操作undo记录里还会额外记录修改前的完整旧值包括旧的行数据。这样才能在回滚时执行“反向”操作把新值改回旧值。这里有一个非常关键的细节聚集索引记录中的DB_ROLL_PTR直接指向该行最新版本的undo记录顺着这个指针InnoDB能在Undo Log中按时间倒序找到该行的所有历史版本这就是MVCC版本链的物理基础。也就是说每行数据不是孤立的它背后有一条“历史祖先链”挂在Undo Log里。3.3 Insert Undo与Update Undo的不同生命周期Undo Log按照产生场景分为两大类生命周期差异很大Insert Undo Log发生在INSERT操作时记录的是插入行的主键值。因为插入的新行只有当前事务自己能看到其他事务的MVCC根本不需要它的旧版本。所以只要事务提交这份undo记录立刻失去MVCC价值可以直接清理。Update Undo Log发生在UPDATE或DELETE操作时记录的是修改前的旧值。旧值可能被其他并发的只读事务引用因为他们需要看到“修改前”的版本。所以即使事务提交了这份undo记录也得继续保留直到确认没有任何事务的ReadView还需要它才能被purge线程物理删除。这也是为什么DELETE操作不直接删数据而是先打一个delete-mark标记——这样旧版本还能从undo里取到等purge线程确认安全后再真正清理。理解这个差异对排查“undo表空间为什么一直不缩水”这类问题特别有用。4. Undo Log的写入流程与清理机制4.1 一次UPDATE语句在Undo Log中走了哪些步骤我来梳理一次完整UPDATE的执行路径把Undo Log在这一过程中的每个动作标注出来UPDATE t_user SET age 30 WHERE id 1001;这条SQL在InnoDB内部大致经历了以下阶段在缓冲池中定位id1001的数据页如果不在内存就先从磁盘读入。写Undo Log把当前行的旧值age25写入undo页形成一条Update Undo记录并让新行版本的DB_ROLL_PTR指向这条记录。写Redo Log记录Undo页和数据页的物理变更Redo Log里会记录“我写了哪个undo页内容是什么”保证后续崩溃恢复时这个Undo记录也能被重放出来。修改数据页把age从25改成30更新DB_TRX_ID为当前事务ID。事务提交时将Undo Log从“活跃”状态标记为“待清理”状态然后异步由purge线程处理。注意一个容易被忽略的点Undo Log写入本身也需要写Redo Log。为什么因为崩溃恢复时Redo会重放所有物理变更如果Undo页的变更没记进Redo那么恢复后Undo页可能是残缺的回滚也就无从谈起。所以Redo和Undo是唇亡齿寒的关系Redo保护UndoUndo保护数据页。4.2 Purge清理Undo Log的完整机制事务提交后Undo Log并不会立即被删除。InnoDB专门有一个Purge线程负责这件事。它的工作流程大致如下第一步维护一个History List历史链表提交事务的Undo记录按提交顺序挂在这条链表上。第二步Purge线程定期扫描History List判断每条undo记录是否还被任何活跃事务的ReadView引用。如果没有就标记为可清理。第三步物理清理undo记录并释放对应的undo页空间。判断能否清理的关键是ReadView中的“活跃事务最小ID”min_trx_id。如果一条undo记录的事务ID小于当前所有活跃事务的最小ID说明已经没有任何活跃事务能看到“需要这个旧版本”的场景了就可以安全purge。Purge线程是后台运行的它的清理速度直接影响undo表空间占用。如果业务上有大量更新操作而Purge线程跟不上产生速度就会出现Undo Log膨胀。4.3 关键参数与Undo表空间管理实测MySQL 8.0起Undo Log的治理参数主要集中在下面几个我把自己实际调优后的配置分享给大家参数名默认值作用实际建议innodb_undo_tablespaces2设置undo表空间数量高并发写入场景建议设为4~8个分散I/O压力innodb_undo_log_truncateON允许自动收缩undo表空间保持ON防止空间无限膨胀innodb_max_undo_log_size1GB默认值在8.0.30前是1G触发truncate的阈值合理范围内偏大避免频繁truncate带来的性能抖动innodb_purge_threads4Purge线程并发数CPU核数充足时建议4~8加速undo清理innodb_purge_batch_size300每轮purge处理的undo页数大批量更新场景可适当调高到500~1000需要注意的是在MySQL 8.0中undo表空间truncate机制和旧版本有较大差异。8.0的truncate是独立的undo表空间轮流收缩且truncate期间会短暂禁用该表空间所以阈值配置得过小可能导致频繁切换造成周期性性能抖动。我见过某些将innodb_max_undo_log_size设成128MB的系统每隔几分钟就触发一次truncate业务SQL的响应时间肉眼可见地出现规律性毛刺。后来调到2GB这类抖动彻底消失。另外提一句MySQL 8.0的undo表空间在初始化时是预分配的文件大小一开始就有几百MB到1GB。不要以为空数据库的undo文件应该很小这是正常的预分配行为不是泄露。4.4 事务回滚时Undo Log的执行路径当用户执行ROLLBACK时InnoDB的处理方式是用Undo Log“倒着做一遍”如果undo记录类型是INSERT回滚动作就是执行DELETE删除插入的行。如果undo记录类型是UPDATE回滚动作就是把数据页上的当前值改回undo记录里的旧值。如果undo记录类型是DELETE回滚动作就是取出旧行内容重新插入取消delete-mark。回滚操作本身也会产生Redo Log因为它在物理上确实是改了数据页。但回滚不会新产生Undo Log因为“回滚这个动作”本身不需要再被回滚了。这里有个常见误区很多人以为回滚就是把所有修改直接“抹掉”内存里的数据页回到最初状态。实际上InnoDB是“逆操作”方式恢复的而且方式是逻辑逆操作不是物理页级恢复。举例来说如果事务执行了10次UPDATE每次都是把同一个值改来改去回滚时会从最后一次的旧值开始逐条往前恢复而不是直接把数据页的物理镜像还原。原因很简单InnoDB没有在Undo里保持数据页级别的物理备份。5. 崩溃恢复Redo与Undo如何配合5.1 崩溃恢复的两阶段分析数据库崩溃后重启InnoDB进入恢复模式流程分为两阶段第一阶段Redo前滚Redo Phase。把Redo Log里所有已记录的物理变更重新应用一遍无论事务是否提交。这一步的目标是“把实例恢复到崩溃时刻的内存状态”也就是让所有“已经发生的修改”都写进数据页。第二阶段Undo回滚Rollback Phase。扫描崩溃时尚未提交的事务利用Undo Log把这些事务的修改回滚掉。只有未提交的事务需要回滚已提交事务直接保留。为什么不能先做Undo再做Redo因为Undo记录本身也依赖Redo才能完整恢复。如果没做Redo就先回滚你可能面对的是缺失了部分Undo记录的半成品回滚都不知道从哪下手。所以顺序必须是“先Redo前滚再Undo回滚”。5.2 崩溃恢复状态下Undo被频繁访问的实际表现高负载系统崩溃恢复时我遇到过一种情况MySQL启动后SHOW ENGINE INNODB STATUS输出里长时间有“Rolling back”和“Purge”字样其实是崩溃时有一批大事务没提交正在用Undo回滚。恢复期间Undo Log所对应的数据页会被大量随机读取。如果undo表空间和系统表空间在同一个磁盘上I/O会成为瓶颈恢复时间被拉长。实操中为了缩短恢复窗口有几个经验可以参考把undo表空间文件放在独立的、性能较好的磁盘上最好SSD。控制单个事务的规模如果单事务更新几十万甚至上百万行崩溃后回滚时间会非常吓人。监控innodb_history_list_length指标这个值代表History List上挂了多少个undo记录批次如果长期高位说明Purge跟不上一旦崩溃恢复时回滚的工作量就很大。6. 常见的Undo Log问题排查与经验技巧6.1 Undo表空间膨胀的排查与处理问得最多的一个问题是为什么我的undo表空间文件越来越大甚至把磁盘撑满了排查思路按以下顺序走看长事务和长查询。查询information_schema.innodb_trx表找出持续时间特别长的事务。这些事务的ReadView一直不释放导致Undo Log无法被purge。SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds FROM information_schema.innodb_trx WHERE trx_state RUNNING ORDER BY trx_started ASC;如果事务没问题看Purge线程是否异常。可以查看SHOW ENGINE INNODB STATUS中History list length值。如果该值持续增长而且很大几十万、上百万说明undo产生速度远大于purge速度。检查是否有大事务执行了超大批量的UPDATE/DELETE。这类事务即使在正常提交后Purge线程也需要很长时间才能清理完产生的undo记录。我曾经处理过一个典型案例某个数据订正任务单事务UPDATE了全表1.2亿行事务执行了40分钟才提交。提交后系统出现了近20分钟的明显性能下降History list length从几千飙升到几十万。解决方式是把大更新拆分成每批5万行的循环提交性能回归正常undo表空间也再没有暴涨过。6.2 慢查询与Undo Log的关联分析有些慢查询不见得是SQL写得差而是被Undo Log间接拖累的。我遇到过一个现象一条原本20ms的简单主键查询某段时间突然变成500ms。通过分析发现目标行的版本链非常长——由于频繁更新同一行而一个长事务一直不提交阻塞了purge线程清理旧版本。每次查询都要顺着回滚指针遍历十几条历史版本性能自然就掉下来了。如果线上出现类似情况可以用下面的方法确认版本链的长度和阻塞情况-- 查看历史链表长度 SHOW ENGINE INNODB STATUS\G -- 然后看 History list length 这一行另外长时间运行的SELECT也会拖住purge线程的进度。在业务上查询最好要控制执行时长避免不必要的长事务。如确实需要读写分离或分析查询可以考虑在只读副本上跑。6.3 大事务回滚导致的线上事故复盘我亲手处理过一个最棘手的事故凌晨的数据任务写了一个大事务因为逻辑bug触发异常分支直接ROLLBACK。这个事务更新了大约800万行数据回滚花了整整25分钟期间相关表的写入全部阻塞业务严重受损。复盘原因时发现两个问题事务执行过程中没有分批提交积累了海量undo记录。ROLLBACK时InnoDB需要逐条遍历undo记录执行逆操作800万条记录的回滚是一个漫长的过程。从那以后我给自己定了一条“铁律”任何批量操作必须分批提交单事务影响行数控制在5万以内。这条规则虽然简单但能规避掉绝大多数大事务回滚灾难。另外默认隔离级别下DDL和DML混合使用的情况也要小心。MySQL 8.0中DDL操作比如ALTER TABLE会触发元数据锁如果此时有长事务持有Undo Log旧版本可能导致DDL阻塞反过来加剧undo堆积。6.4 实用排查命令与监控指标速查最后分享一套我日常排查Undo Log问题的“急诊工具箱”排查目的命令/视图关键信息查看当前事务information_schema.innodb_trx事务ID、持续时间、状态查看锁等待sys.innodb_lock_waits持锁事务与等锁事务关系查看InnoDB状态SHOW ENGINE INNODB STATUSHistory list length、Purge线程状态查看undo表空间大小information_schema.innodb_tablespaces表空间文件大小、状态持续监控undo膨胀脚本定时采集information_schema数据绘制增长曲线设定阈值告警监控层面PrometheusGrafana配合mysqld_exporter可以采集history_list_length、undo_size等指标。我建议对这三个指标设告警history_list_length超过10万可能purge跟不上需检查长事务undo表空间大小超过磁盘总容量的某个比例如20%可能发生膨胀trx长时间未提交如超过30分钟可能在阻塞undo清理写在最后的实操体会行了关于InnoDB Undo Log的底层机制、存储结构、清理流程和实际排查方法我基本把压箱底的东西都倒出来了。最后说几句掏心窝的话。开发阶段如果就养成分批提交的习惯你在生产环境遇到Undo相关问题的概率能下降八成。等到线上出了undo膨胀再去救火每次都是大工程。另外排查问题永远从长事务开始排查80%的undo异常背后蹲着一个没提交的事务。还有一个容易被忽略的小技巧MySQL 8.0里undo表空间文件尽量不要手动删除。有些人为了“释放磁盘空间”直接把undo文件删了轻则崩溃恢复失败重则整个实例起不来。如果你实在需要收缩undo空间正确操作是依靠innodb_undo_log_truncate机制自动完成或者通过官方文档说明的安全步骤手动truncate。最后的最后分享一个小经验如果你在面试中被问到Undo Log试着把MVCC、崩溃恢复、Purge清理机制串成一个完整的故事讲而不是零散地背概念。面试官想听的本质上是你对这条链路有没有真正建立起系统性的认知。能把“一条UPDATE语句的一生”完整讲述出来——从写Undo、写Redo、改数据页、提交、被Purge回收——这个深度就够了。