资讯详情

资讯详情

InnoDB Undo Log深度解析:从回滚机制到生产环境治理

1. 事故现场被undo撑爆的账务批处理这个系列写到第十一篇轮到 InnoDB 的 Undo Log回滚日志了。我先从一个事故说起某天夜里的账务批处理任务跑了快四十分钟业务方临时喊停运维同事按了下 CtrlC程序抛出异常后开始 rollback。本以为几分钟就能收拾完结果回滚时间比任务本身还长数据目录里的 innodb_undo_001 文件肉眼可见地变大最后把一块数据盘塞满数据库直接进入只读状态。群里一开始都在说“事务太大”但把现象拆开看根子并不只是“大”这一个字。回滚慢的本质是 InnoDB 必须把每条已更新记录再改回原状。一个 UPDATE 覆盖到几百万行回滚时就要逐条做反向修改。undo 之所以重要因为它就是反向修改的依据。理解了这一点才谈得上理解 Undo Log 在 InnoDB 里的两个核心身份一是事务原子性二是 MVCC。这篇文章会从事故现象讲到物理存储形态、一条真实 UPDATE 的 undo 轨迹、purge 线程的工作机制最后给出一套生产环境排查和治理的建议。适合正在被大事务、长事务和 undo 表空间膨胀困扰的人也能帮刚接触 InnoDB 事务机制的同学把概念串起来。1.1 回滚为什么比执行还慢当时批处理的逻辑很典型开启事务后循环执行 UPDATE account_balance SET amount amount - ? WHERE account_id ?。单条更新非常快执行阶段靠每次随机更新累积几千 TPS四十分钟完成。等回滚时每一条被更新过的记录都要用 undo 里保存的旧值覆盖回去这相当于把刚刚做过的写操作全部重做一遍只是方向和内容反了过来。更麻烦的是普通的一行更新往往不止改聚簇索引如果这行还挂在二级索引上回滚时二级索引项也要相应处理。数据页一旦被回滚逻辑改回去同样需要生成 redo 日志、再走一次刷盘路径。所以回滚本质上就是第二遍写操作慢一点都不奇怪。很多人看到回滚时间长第一反应是“SQL 写法不对”其实这是 undo 数据结构决定了绕不开的代价。1.2 只把 Undo 理解成“回滚日志”会误判方向在很多人的印象里undo log 就是为 ROLLBACK 服务的平时根本不关注。实际上InnoDB 的普通 SELECT 读老版本也依赖 undo。一个事务修改了某行还没提交另一个事务在可重复读隔离级别下发起了 SELECT如果它要读到修改前的值就要顺着聚簇索引记录上的回滚指针找到 undo 记录把旧版本重建出来。这种通过 undo 构建历史版本的能力是 MVCC 的核心。所以你看到 undo 表空间一直涨未必是有事务在回滚。更常见的是某个读事务持有着一个 ReadView把整条版本链“钉”住了已经提交的旧版本不能被清理。这时候按“事务太大”去排查会绕很多弯路。后面我会用专门一节来讲这个问题的完整链路。1.3 insert undo 和 update undo两类记录干的事完全不同InnoDB 在实现上把 undo 记录分成两类。第一类是 insert undo log只服务于事务回滚。新插入的行在提交前对别人本来就是不可见的MVCC 根本用不到它所以记录里只保存插入行的主键回滚时按主键删除即可。第二类是 update undo log它会保存被修改列的旧值和主键信息既要用于回滚也要用于 MVCC 构建旧版本。这里有个容易踩的认知坑DELETE 在 InnoDB 里几乎可以视为一种特殊的 UPDATE是先给记录打上删除标记并不会立刻从索引页中物理消失。因此 delete 操作的 undo 也属于 update undo log。打完删除标记的记录要等 purge 线程确认没有任何 ReadView 需要它之后才能从索引中真正清除。这个“确认”环节往往是 undo 一直下不去的元凶。2. 从隐藏列到 undo 表空间undo 究竟存在哪2.1 聚簇索引记录上的隐藏列与版本链要看 undo先看 InnoDB 聚簇索引记录上的隐藏列。除了用户定义的主键和数据列每条记录还带着DB_TRX_ID表示最近一次修改这条记录的事务 IDDB_ROLL_PTR指向这条记录对应的 undo 回滚记录另外还有 DB_ROW_ID在聚簇索引没有主键时被用作行标识。每当我们更新一行在把新值写进数据页的同时InnoDB 会先在 undo 里保存旧值再把 DB_ROLL_PTR 从原来的位置改到新写入的 undo 记录上。于是多个历史版本串成一条链这条链就是版本链。可以把这条链理解成串成串的手写记录每次修改都在链头挂一张记录着旧值的小纸条读端需要旧版本时就顺着回溯到某一环。2.2 回滚段、undo 段、undo 页的三层结构版本链再往下挖undo 本身的物理组织可以分成三层。最顶层是回滚段InnoDB 把它们保存在一个全局数组中每个回滚段里挂着若干 undo slot每个 slot 对应一个 undo 段undo 段会申请若干 undo 页undo 记录就一条条写在 undo 页里。这种设计的好处是分配、扩容、回收都局限在段和页的粒度不会因为某个大事务产生大量不可控的碎块。InnoDB 支持的并发事务数量也会受到回滚段和 slot 数目的约束。日常一般不会撞到这个上限但如果你频繁在海量连接里开启小事务又迟迟不提交可能就会看到接近 rollback segment 上限的现象。这通常不是参数配小了而是事务堆积该清理了别一上来就加参数。2.3 从系统表空间到独立 undo 表空间老版本 InnoDB 把 undo 存放在系统表空间 ibdata1 里优点是省事缺点是这个文件通常只增不减undo 膨胀后想回收很麻烦。后来 InnoDB 支持了独立 undo 表空间5.7 里可以显式配置多个8.0 之后变成系统自动管理默认至少两个 undo 表空间磁盘上是数据目录下的 innodb_undo_001、innodb_undo_002 这类文件。要确认 undo 占用最简单的方法是直接看数据目录下的文件大小比如进入数据目录执行 ls -lh innodb_undo_001。看到文件大先别急着砍文件。undo 文件可能正被当前活跃事务使用直接删除不会释放空间反而会让数据库启动失败。正确思路是让事务先结束等 purge 线程追上再依赖 undo 表空间的自动收缩机制回收。2.4 提交不等于可以清理事务 commit 后它生成的 undo 不会马上清理。Commit 只代表当前事务结束版本链上可能还有更老的 ReadView 在引用旧版本。这些 undo 会排进一个叫 history list 的组织里等待 purge 线程按顺序处理。因此数据库里看到 History list length 这个指标时数值越大说明等待清理的 undo 越多。这也是很多人看 show engine innodb status 时最容易忽略的一行。3. 一条 UPDATE 语句执行时undo 记录怎么生成3.1 建一个可复现的测试表纸上谈兵不如直接跑一遍。假设有一张账户余额表建表语句很常见CREATE TABLE account_balance ( account_id BIGINT PRIMARY KEY, amount DECIMAL(18,2) NOT NULL DEFAULT 0.00, version INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB;插入一行初始数据然后开两个会话模拟并发。会话 A 执行 BEGIN; UPDATE account_balance SET amount amount - 100, version version 1 WHERE account_id 1001; 先不提交保持事务打开。3.2 数据页上到底发生了什么步骤可以拆成三件先在 undo 段里分配一个 undo 页写入一条 update undo 记录内容包含旧 amount、旧 version以及主键 1001接着修改聚簇索引记录amount 和 version 写入新值DB_TRX_ID 改成会话 A 的事务 IDDB_ROLL_PTR 指向刚写好的 undo 记录最后所有这些修改包括 undo 页上的写入都会生成 redo 日志。很多人把“undo 和 redo”理解成两条独立的日志流程其实它们是被一条完整的事务日志记录串起来的。undo 页本身也是数据页必须用 redo 保护否则系统崩溃时回滚信息也会一起丢失。理解这一环对后续的恢复调优很有帮助。3.3 另一个会话为什么会读到旧值接着在会话 B 里执行 SELECT amount, version FROM account_balance WHERE account_id 1001。在可重复读隔离级别下B 的第一条普通 SELECT 会建立一致性快照。因为 A 还没提交B 不能直接使用当前行版本此时就会沿着 DB_ROLL_PTR 走到 undo 记录里读到旧的 amount 和 version。这个“走到 undo 里读旧版”的动作就是 MVCC 的快照读取。版本链不一定只有一跳。如果同一行被多个事务连续更新读端就要沿着回滚指针一路回溯直到找到对当前 ReadView 可见的版本。这也是为什么同一行的并发修改次数一多一次普通 SELECT 也可能要访问好几条 undo 记录读吞吐自然会受影响。3.4 用 information_schema 和 status 实时观察要实时观察 undo 和事务状态可以执行这条 SQL 查当前活跃事务SELECT trx_id, trx_state, trx_started, trx_rows_modified, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx\G在会话 A 未提交时能看到 trx_state 为 RUNNINGtrx_rows_modified 至少为 1。然后再执行 SHOW ENGINE INNODB STATUS\G在 TRANSACTIONS 段能看到 History list length 的值。如果事务提交后这个数值迟迟不降说明还有别的连接或 ReadView 在拖住版本链。这些指标结合起来基本能勾勒出 undo 当前到底是被谁占着。3.5 为什么 COMMIT 之后 undo 常常还在回滚场景更直观会话 A 执行 ROLLBACKInnoDB 会根据 undo 记录里的旧值把 amount 和 version 改回去。因为恢复本身也是写操作同样需要写 redo。不过真正要记住的是即便回滚或提交已经完成这些 undo 记录在绝大多数情况下也不会立刻被删除它们还要继续服务 MVCC。只有 purge 线程确认没有任何活跃 ReadView 需要某个旧版本时对应的 undo 页才能被释放和复用。这也是“大事务回滚慢”之外第二个容易让人误解的角落。很多 DBA 盯着 undo 文件大小等待它下降等一晚上文件纹丝不动原因就在这里。4. purge 线程如何工作长事务是怎么把它拖死的4.1 purge 线程到底在清什么purge 线程专门负责版本链和 delete 标记的最后清理。事务提交后History list 上的 undo 记录不会无限堆积只有不再被任何 ReadView 需要、也不再被任何打开事务引用purge 线程才会动手。它要做的事有两类一是把版本链上过期的旧版本摘掉让 undo 页可以复用二是把早已打了删除标记的记录从索引页上真正删除。所以 purge 处理慢影响的绝不只 undo 表空间大小还会牵连索引页的回收和整体写入性能。一个删除量很大的事务提交后你会看到 History list length 飙升随后 purge 线程开始慢慢消化。如果消化速度跟不上写入速度磁盘占用和性能都会恶化。这里的关键是“跟不上”和“没人让它清”是完全不同的两种状态前者是吞吐瓶颈后者是阻塞故障。4.2 一个“人畜无害”的读事务如何锁住版本链这里最典型的场景是一个报表查询凌晨启动在可重复读隔离级别下开启事务中途要跑六七个小时。由于可重复读的快照在第一个读语句时建立并且会保留到事务结束这个事务就等于在整个过程中持有一个“不允许任何版本被清除”的 ReadView。如果这个时间段里恰好有批处理在大量更新同一批行那么每次更新产生的新 undo都要连同前面所有旧版本继续保留。读取端不结束purge 就永远干不掉这一段。我见过一个线上案例某个统计任务长期占用连接事务状态显示是 Sleep但事务一直没提交。那段时间前台写操作经常超时磁盘还不停增长最后定位到它头上。所以排查 undo 问题第一怀疑对象不一定是写事务也可能是某个看着无害的读事务。4.3 三步定位法事务、History list length、文件大小第一步查询 information_schema.innodb_trx找出所有未提交事务按 trx_started 排序重点盯 trx_state 为 RUNNING 且启动时间特别早的连接。很多 Sleep 状态的连接trx_query 为空但 trx_started 已经非常久。第二步执行 SHOW ENGINE INNODB STATUS\G看 TRANSACTIONS 段里 History list length 的数值。长事务存在时这个值通常会持续高位。第三步到数据目录检查 innodb_undo_001、innodb_undo_002 等文件的大小和之前的监控值对比量级变化。三步合起来就能判断问题到底是长事务拖住了版本还是 purge 线程本身跟不上了。4.4 如果 History list length 在降但 undo 文件不收缩还有一种情况明明老事务已经处理掉History list length 也在逐步下降undo 文件却没有变小。这多半是事务量太大、purge 线程处理不及时或者磁盘 IO 出现了瓶颈。你可以适当调大 innodb_purge_threads如果是纯写型实例可以把数量调上去同时关注 innodb_max_undo_log_size当 undo 表空间超过这个阈值后自动 truncate 机制会尝试把不再使用的文件收缩回阈值以内。但别忘了自动 truncate 的前提是没有事务正在占用其中的老版本。只要某个长查询还握着旧的 ReadView它的 undo 就不能被回收阈值设得多小都白搭。所以调参只是辅助先解决读事务和写事务交叉的问题才是关键。5. 生产环境的 undo 治理从应用侧到参数侧5.1 把事务边界调小是最有效的药绝大多数 undo 问题根子在应用层事务设计不在参数。我处理过的案例里批量更新动辄几十万行的脚本基本都是在同一事务里循环执行。后来把它改成每 500 行或者每 1000 行提交一次通过率没降多少undo 和回滚时间都变得可控。核心思路是单个事务影响的行数要控制在能快速回滚的范围内。具体阈值跟服务器配置、磁盘性能和单行长度有关。可以先用 500 递增观察 commit 和 rollback 延迟找到不会让锁等待明显上升的批次大小。注意分批提交虽然能缓解 undo 压力但可能破坏原本“要么全成、要么全不成”的原子性业务可以接受“部分成功”时再这样改。5.2 只读长事务和写入业务最好物理隔离如果一个库既要服务长报表查询又要承载高频短事务undo 会被长查询拖得非常难看。能接受的话把报表类查询挪到只读从库或者使用独立的数据副本。实在要走主库开启一致性快照后不要让事务长时间空闲尽量在拿到结果后立刻提交。一个空转的连接比一个正在写数据的事务对 undo 的伤害往往更大。因为它既不提交也不产生任何可观测的工作量却牢牢占着数据库的版本链表面上完全看不出来。这种情况下监控也应该覆盖连接空闲时的持续时间和事务启动时间而不只是 CPU 和慢查询。5.3 参数配置参考一般生产环境会关注的参数不多下面是一张我常用作参考的表参数作用一般建议innodb_rollback_segments回滚段数量决定可用 undo slot 规模默认值通常已经够用并发写入极高时再考虑调整innodb_purge_threads负责清理历史版本的线程数纯写场景可适当增加但要根据 IO 竞争验证innodb_max_undo_log_sizeundo 表空间自动 truncate 的阈值默认约 1GB按磁盘容量和峰值写入量调整innodb_undo_log_truncate是否启用 undo 表空间自动收缩老版本需要显式开启8.0 后普遍默认开启改参数前先看当前值比如 SHOW VARIABLES LIKE innodb_purge_threads; 参数是动态的还是静态的不同大版本表现不一样别全凭记忆操作。purge 线程多了undo 清理确实会快但线程调度和内部锁竞争也可能带来新瓶颈。8.0 以后 undo 表空间管理自动化程度很高多数场景保持默认即可。5.4 线上 undo 暴涨时的止血顺序如果线上已经出现 undo 暴涨导致磁盘告警我的建议顺序是先不要动数据文件先确认是否有超长事务有就协调业务尽快提交或回滚确认没有活跃长事务后再观察 History list length 曲线和 undo 文件大小。如果 purge 本身正常但还是缓慢可以在低峰期把 purge 线程调高等待它追平。整个过程最忌讳的是凭直觉去动数据文件任何一个在线状态下对 undo 文件的粗暴操作都可能让 InnoDB 恢复困难甚至直接造成实例不可用。最后说一个我自己的排查习惯遇到 undo 膨胀永远先看 information_schema.innodb_trx 里有没有“睡着的连接”再看 SHOW ENGINE INNODB STATUS 里的 History list length最后才去看 undo 文件大小。顺序反了很容易被表象带偏。很多人问我该把 innodb_max_undo_log_size 调到多大我一般会反问你先确认你的事务到底有多大这个答案才是真正的限制条件。参数解决不了应用层的长事务能救场的永远是事务边界和提交节奏。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →