InnoDB Undo Log解析:从MVCC原理到回滚日志故障排查

发布时间:2026/10/7 11:06:55
InnoDB Undo Log解析:从MVCC原理到回滚日志故障排查 打开SHOW ENGINE INNODB STATUS看到History list length指数级飙升时我第一反应不是查慢查询而是去information_schema.innodb_trx里找那个跑了几十分钟还不提交的事务。搞InnoDB这些年我最深的体会就是Redo Log出镜率高是因为数据安全、崩溃恢复都靠它BTree索引人人能聊面试必考。可一旦线上出问题——事务卡住、磁盘被撑爆、性能突然劣化——最终翻出来的根因十次有八次都跟Undo Log回滚日志有关。这篇文章我就想把InnoDB Undo Log从原理到排查到参数配置一次性讲透。不管你是MySQL DBA、后端开发还是运维同学看完以后不光能理解事务回滚和MVCC是怎么配合的还能在真实故障里第一时间定位到Undo问题并且知道该怎么处理。1. 先搞清楚Undo Log到底在解决什么问题1.1 两个核心场景回滚与一致性读Undo Log诞生要解决两件事。第一件事是事务回滚。假设一个事务里先执行了UPDATE把A账户余额从100改成50然后又改了B账户结果B账户那步失败事务需要回滚。这时候怎么把A账户改回100靠的就是Undo Log记录下来的旧值。它像是事务操作的撤销记录每做一次数据修改就留下一条反向操作。第二件事是MVCC也就是多版本并发控制。InnoDB里读操作默认走的是非锁定读读的时候不需要加锁。那一个事务正在读数据另一个事务同时改了一行读到的是改之前还是改之后的数据InnoDB的做法是读取时根据当前事务的视角沿着数据行上的版本链找到那个我应该看得见的旧版本。旧版本存在哪就存在Undo Log里。所以Undo Log是MVCC实现的数据地基没有它一致性读根本跑不起来。这两个场景里事务回滚是显式的、低频的MVCC的旧版本读取是隐式的、高频的。实际工作中后者更常见也更难排查——因为旧版本会一直占着Undo空间直到确保没有任何事务再需要它了才会被清理。1.2 生活化理解记账凭证和红字冲销我用财务记账来打个比方。事务里的数据修改相当于在明细账上记录了一笔账。如果后面发现这笔账记错了你不是直接把账页划掉——那会影响其他账目的对账。你应该是再记一笔红字冲销把这笔错误账目抵消掉。Undo Log干的差不多就是这个事。它不覆盖旧数据而是把旧值按版本记录下来。每个版本之间有指针串着查询的时候从最新版本往旧版本里翻找到符合当前可见性规则的版本。它面对的问题是如何在数据不断变化时让不同事务看到不同时点的快照。财务是用冲销凭证解决InnoDB是用Undo Log解决。1.3 Undo Log与Redo Log的分工很多新手会把Undo和Redo搞混。我做一个简化区分Redo Log记录怎么做。把数据页上的物理修改记录下来页号、偏移量、修改后的值。崩溃恢复时靠它把丢失的修改重放一遍确保持久性。Undo Log记录怎么撤销。它保存数据行的旧版本和反向操作信息靠它实现回滚和多版本读确保持原子性和隔离性。两者还有一层更微妙的关系Undo Log页本身的修改同样要被Redo Log保护。也就是说写Undo时也要写Redo。崩溃恢复时流程是先利用Redo把包括Undo页在内的所有页面恢复到崩溃点然后根据Undo记录回滚掉崩溃时尚未提交的事务。所以严谨地说Undo不是Redo的替代品而是Redo的互补面。2. Undo Log的内部结构与版本链2.1 两种Undo记录Insert与UpdateUndo Log内部并不是一锅粥它按操作类型分成两大类。一类是Insert Undo Log只在插入记录时产生。插入行为发生时这条新记录对其他事务是不可见的所以它不需要保留旧版本。事务回滚时直接把插入的记录删掉就行事务提交后这类Undo记录基本就可以立即释放。它的生命周期很短。另一类是Update Undo Log在UPDATE和DELETE时产生。为什么DELETE也要走Update Undo因为InnoDB的删除不是立刻物理擦除而是先把记录打上删除标记delete mark再等Purge线程去物理清理。这段时间内其他事务可能需要读到这条被删除的记录所以旧版本必须保留在Update Undo Log里。事务提交后它也不能立刻消失要挂在History List上等Purge。打个简单的对应操作类型Undo类型回滚方式提交后何时清理INSERTInsert Undo反向删除记录可立即释放UPDATEUpdate Undo回写旧值等Purge线程DELETEUpdate Undo恢复旧记录等Purge线程2.2 隐藏列DB_TRX_ID和DB_ROLL_PTR每一行记录上都有几个用户看不到的隐藏列。其中两个对理解Undo Log至关重要DB_TRX_ID最近一次修改这行记录的事务ID。DB_ROLL_PTR回滚指针指向这条记录之前版本在Undo Log中的位置。每次更新一行记录InnoDB不直接覆盖旧版本而是把旧值写入Undo Log然后更新聚簇索引记录本身让DB_TRX_ID变成当前事务IDDB_ROLL_PTR指向刚刚写入的Undo记录。如果之前已经存在旧版本那这条Undo记录里还会记录更早的版本位置。这样一层层串下去就形成了一条从当前版本出发、由新到旧的版本链。读操作要判断可见性时先看这行记录上的DB_TRX_ID是否在自己的可读范围以外如果是就沿着DB_ROLL_PTR到Undo Log里取出旧版本还不够旧再顺着Undo记录里的更早指针继续往前翻。这也是为什么说Undo Log是版本链的物理载体。理解了这条链就理解了MVCC的一半。2.3 回滚段与Undo Segment的物理布局Undo Log不是散落在任意页面里的它被组织在回滚段Rollback Segment中。InnoDB把Undo表空间划分为多个回滚段每个回滚段里又包含若干个Undo Slot每个Slot对应一个Undo Segment一个Undo Segment管理一组Undo页。参数innodb_rollback_segments控制回滚段的数量默认是128。我最初看这个结构时也很迷糊后来用一个类比才彻底搞清回滚段就像一个大仓库仓库里有很多货架Undo Slot货架上放着货物Undo Segment。新事务启动时要在一个回滚段里分配一个Undo Segment来写自己的Undo记录。货架被占满了新事务就得等待甚至报错。这也是为什么并发事务特别高的时候偶尔会碰到无法分配Undo一类错误——不是内存不够是回滚段Slot耗尽了。物理存储上普通用户的Undo数据独立放在undo表空间中默认文件名undo_001、undo_002不再和系统表空间挤在一起。这样做的直接好处是Undo膨胀时可以把整个表空间文件截断回收而不影响ibdata。这点后面讲排查和配置时还要展开。3. 核心机制写入、Purge与可见性3.1 一条UPDATE语句的Undo写入路径我完整走一遍UPDATE的路径你就能直观感受到Undo是怎么产生、怎么被使用的。假设执行UPDATE t SET balance balance - 50 WHERE id 1。第一步InnoDB通过主键找到id1那条记录第二步在事务已分配的Undo Segment里写入一条Update Undo记录里面保存这行数据被修改前的字段旧值、主键值、以及修改前的DB_TRX_ID和DB_ROLL_PTR第三步更新聚簇索引记录把balance改成新值DB_TRX_ID置为当前事务IDDB_ROLL_PTR指向刚写入的Undo记录。如果这个事务短且很快提交Undo记录会进入History List等待Purge。如果事务一直不提交这条Undo记录就会被一直保留。而且后续同事务内再更新同一行或者更新其他行Undo Segment里的记录继续累积事务的Undo尺寸只增不减。这里有一个DBA必须掌握的直觉一个事务修改的数据量越大、运行时间越长它占用的Undo空间就越多。批量UPDATE一百万行即使事务后续只花1秒提交这100万条Undo记录也要等Purge慢慢清。如果该事务同时阻塞了Purge那Undo表空间就会以肉眼可见的速度增长。3.2 Purge线程为什么必须慢半拍Purge线程负责清理两类东西Undo Log里可以释放的记录以及那些被标记删除的旧记录。但它不能在有其他事务依赖旧版本时贸然删除。道理很朴素。事务A更新了id1的记录并提交事务B在事务A提交前就启动了一个快照READ VIEW。事务B查询时必须看到id1的旧版本。如果Purge在事务B结束前就把这条Undo记录删了事务B的查询就找不到旧版本会直接读到一个不该看见的新值快照语义就被破坏了。所以Purge线程必须慢半拍它要定期检查当前系统中还有没有更早的活跃事务或未结束的读视图。只要存在比Undo版本更年轻的快照需求Purge就得等待。用InnoDB术语说就是判断Undo记录在History List上的位置是否已经过时。这部分判断是受read_view_open状态和历史链位置共同控制的。这句话值得背诵Undo Log保留多久不完全取决于事务本身而是取决于系统中最早存在的活跃读视图。一个很长的事务或者一个很长的SELECT都能让Purge停滞Undo表空间持续膨胀。3.3 隔离级别如何决定Undo的存续时间隔离级别影响的是READ VIEW的创建时间而READ VIEW直接影响Undo的清理节奏。在READ COMMITTED下每条语句执行前都会创建新的READ VIEW。语句执行完这个视图就失效了。旧版本只要不再被当前语句引用Purge就能清理Undo保留窗口较短。在REPEATABLE READ下事务内第一次SELECT时创建READ VIEW整个事务期间复用同一份视图。这意味着事务从第一次读开始到结束所有行版本判断都基于这个固定快照。只要事务不结束哪怕是空闲状态所有比这个视图更早的Undo记录都不能被Purge。很多坑都出自这个差异。开发环境里默认REPEATABLE READ一个应用跑着跑着开启事务后不提交回头去做别的业务半小时后回来继续。这半小时里数据库里其他更新的Undo记录全都被钉在History List上。等数据库发现Export时Undo已经占掉几十GB磁盘。4. 实操监控与排查Undo问题4.1 三条命令看清Undo真实状况先别急着调参第一步永远是摸清现状。我排查Undo问题时会按顺序跑下面三组SQL。第一组看当前最活跃的事务SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec, trx_rows_modified, trx_isolation_level FROM information_schema.innodb_trx ORDER BY running_sec DESC LIMIT 20;重点看trx_started和trx_rows_modified。前者告诉你事务已经活了多少秒后者告诉你它改了多少行——这两个数字越大Undo占用就越可观。第二组看Undo表空间的物理尺寸SELECT NAME, FILE_SIZE / 1024 / 1024 AS size_mb FROM INFORMATION_SCHEMA.INNODB_UNDO_TABLESPACES;如果size_mb达到GB级别而业务量并不大基本可以断定有Undo未正常回收的情况。第三组看InnoDB整体状态里的History list lengthSHOW ENGINE INNODB STATUS;在TRANSACTIONS段里找History list length。这个数字代表Purge队列里等待清理的Undo记录数量。它很直观正常业务下应该在几百或几千附近波动如果持续上涨、甚至几十万上百万Purge一定被卡住了。SHOW ENGINE INNODB STATUS输出冗长不要只盯一处它同时会告诉我们当前活跃事务最早启动时间以及读写事务列表对定位长事务非常有帮助。4.2 Undo膨胀的元凶定位五步法遇到Undo膨胀时我的定位顺序是固定的避免乱查浪费时间。第一步确认膨胀源是不是Undo表空间文件本身。用du -h /data/mysql/undo_002看物理文件大小结合INNODB_UNDO_TABLESPACES确认。第二步查活跃事务。用上一节的事务SQL重点关注running_sec超过业务阈值的事务。一般超过几分钟就要警惕超过一小时基本属于事故级别。第三步查History list length的走势。隔几分钟连查几次如果只升不降说明Purge确实停了而且有一个或多个非常老的视图挡住了清理。第四步定位那些还没提交的长事务对应的连接。trx_id可以和performance_schema.threads里的事务信息关联进一步找到具体是哪个应用、哪条SQL。很多时候就是连接池里一个忘了提交的写操作。第五步查看是否有长查询大型SELECT在跑。有时候UPDATE事务本身已经提交了但一个超大SELECT读了很久它的READ VIEW依然存活同样会卡住Purge。这五步走完90%的Undo膨胀都能找到元凶。剩下10%是配置模式问题比如Purge线程数不足、innodb_max_purge_lag设置不当、或回滚段参数被错误调小这种情况看后面的参数配置。4.3 Undo表空间自动Truncate原理与配置找到元凶之后清理是一个更麻烦的话题。先把丑话说在前头Kill掉长事务后Undo文件不会立刻缩小。旧版本还在History List上需要Purge慢慢清清完之后Undo表空间文件物理大小也要靠Truncate机制来收缩不是自动发生的。MySQL专门实现了Undo表空间的自动Truncate机制。机制大致是这样的当某个Undo表空间大小超过innodb_max_undo_log_size阈值且开启了innodb_undo_log_truncate后台会把该表空间标记为非活动不让新事务再分配它接着等该表空间上的所有Undo记录都被Purge干净完成后把表空间文件截断到初始大小再重新初始化供后续使用。整个过程对业务基本无感因为InnoDB会把新事务引导到其他活跃Undo表空间。这里就顺带解释了为什么Undo表空间至少要有两个一个进入Truncate流程时另一个还能继续扛业务。如果把所有Undo都塞到一个表空间Truncate期间就没有地方给新事务写Undo了你不想碰到这种窘境。默认配置下8.0innodb_undo_log_truncate是开启的innodb_max_undo_log_size是1GBTruncate机制会自动运行。但要注意如果存在那个活得很久的事务这个表空间永远等不到安全的清理时机Truncate就会一直悬挂着文件尺寸下不去。5. 参数配置与8.0版本需要注意的变化5.1 核心参数盘点与推荐基线我整理了一份和Undo关系最密切的参数清单。下面的值是我在中等规模业务TPS几千、CPU 32核下的常用基线大方向是普适的具体数值还是按业务实测微调。参数名默认值8.0作用我的建议innodb_rollback_segments128回滚段数量保持默认不要随意调小innodb_undo_tablespaces2初始化时决定独立Undo表空间数量建议2个不要低于2个innodb_max_undo_log_size1GB触发Truncate的阈值1GB足够无需刻意调大innodb_undo_log_truncateON开启自动Truncate保持ONinnodb_purge_threads4Purge线程数默认值可用过高收益有限innodb_max_purge_lag0Purge滞后上限默认0表示不限制通常不建议开启除非你非常确定innodb_rollback_segments这条我单独强调一下。很多人误以为调小它能节约内存实际上它直接影响并发事务分配Undo Segment的能力。默认128个回滚段每个段支持最多1024个Undo Slot理论上可支撑的并发事务上限很大。如果你把它调成4或者更小高并发下就可能出现新事务等待Undo Slot的情况表现为事务启动变慢、锁等待变多。我见过不止一次因为误调这个参数导致性能劣化的案例。5.2 8.0版本的特有变化如果是从5.7升到8.0有两点和Undo相关的区别要特别留意。第一8.0里Undo表空间已经是一等公民。初始化时就建议使用独立表空间并且不再支持把Undo放回系统表空间的旧姿势。如果你从旧版本升级上来官方工具会帮助你完成迁移但你要清楚这一步是不可逆的不要想着再改回去。第二8.0对Truncate机制做了大量打磨。5.7虽然也有自动Truncate但默认关闭、表空间初始创建数量限制也多8.0默认开启且管理得更平滑。另外8.0支持在线查看Undo表空间的STATE信息配合监控更方便判断是否有表空间正在等待Truncate。除了这两点8.0还把临时表的Undo放到了独立的临时表空间里不再和普通提交数据混在一起。这样临时表的频繁写入删除不会污染正式Undo表空间对磁盘空间管理更友好。5.3 关于Undo参数的三个常见理解误区误区一把innodb_max_undo_log_size调大就能减少Truncate频率。这个想法方向反了。阈值调大只是让表空间能涨得更大磁盘占用更多Truncate触发更晚。除非你的业务会产生大量突发性写操作、频繁Truncate反而带来额外开销否则保持默认1GB更安全。误区二Purge就是删数据线程越多越好。Purge线程数量增加会提高清理速度但也会消耗更多CPU和内存并且Purge本身可能成为热点锁的竞争点。默认4个已经覆盖绝大多数场景我这边测试过8个、16个并没有带来成比例收益。误区三重启数据库能释放Undo磁盘空间。重启后Undo表空间文件还躺在磁盘上历史记录虽然会被重新初始化但物理文件大小不会因为一次重启自动回收。想回收空间要么靠Truncate要么手动重建实例。这个误解在运维同学里尤其常见一定要纠正。6. 常见问题与避坑速查6.1 高频问题对照速查表现象可能原因处理建议History list length持续上涨长事务未提交 / 长查询存活kill空闲长事务定位应用侧连接undo表空间文件体积巨大Purge被卡住 / 大事务写入了海量Undo先查trx再等Purge最后观察Truncate一个事务回滚特别慢该事务修改了大量行需要逐条反向执行避免大事务必要时检查是否有锁加重回滚开销高并发下新事务启动变慢回滚段Slot不足检查innodb_rollback_segments是否被误调小磁盘被Undo占满长事务Truncate被悬挂紧急时可临时扩磁盘或清掉长事务之后配置监控告警Kill事务后空间没释放Undo记录还在History List等Purge耐心等Purge并确认后续Truncate执行6.2 我实际踩过的几个坑第一个坑是上线前的脚本里忘写事务提交。当时后端同学在循环里执行UPDATE每次循环都开了隐式事务但没有及时COMMIT。业务流量一大事务一个叠一个History list length直接冲到几百万undo_002文件在半小时内涨到20多GB。最后定位到是一个连接把事务一直悬着后续操作全部堆积在同一条事务上。处理办法是kill掉那个连接让MySQL回滚未提交部分再等待Purge追赶。这个问题从根本上提醒我业务侧的每一个未提交事务最后都会以磁盘空间消耗的形式出现在Undo上。第二个坑是执行大型DDL或大范围删除时没有分批。曾经给人救火对方要清一张日志大表直接DELETE WHERE create_time 2020-01-01一下删了几千万行。删除是标记删除产生的Undo体积巨大而且因为删除操作长时间持有表写锁整个业务几乎停摆。后来改成按主键范围分批删除每批几千行、sleep一下再继续Undo增长平稳多了。大变更一定要想到Undo这个隐形代价。第三个坑是监控告警配置缺失。早期只监控CPU、内存、磁盘容量没监控History list length和innodb_trx中最大事务时长。结果磁盘被Undo打满实例直接进入只读保护业务全面受影响。后来我把这两项加入监控任何异常都能在萌芽阶段看到再也没出现过类似事故。说回日常我现在对Undo的维护工作其实已经很流程化每天看一次History list length的趋势每周检查一次Undo表空间物理大小事务超过30秒未结束就告警。这套东西看着简单但效果远比出了问题再优化SQL来得实在。最后再分享一个我自己用过的小技巧处理Undo膨胀前先记录SHOW ENGINE INNODB STATUS里History list length的基线值然后每隔几分钟再查一次。如果数字在下降说明Purge在正常追赶你只需要等待如果数字不降甚至上升说明还有活跃事务或读视图挡住清理。这个等一等再判断的习惯能帮你少做很多无用的紧急操作。Undo Log是那种平时你感觉不到它存在、一旦感觉到就说明已经出事的组件。与其事后救火不如在监控上提前堵住它。