ARTICLE DETAIL

资讯详情

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

MySQL崩溃恢复核心:redo log与undo log机制全解读

MySQL崩溃恢复核心:redo log与undo log机制全解读 刚接手一个线上MySQL实例时我最怕听到的一句话是“刚才数据库重启了一下数据会不会丢”这种时候判断依据就藏在两个日志里——redo log和undo log。一个管“崩溃之后怎么把已提交的修改捞回来”一个管“事务要回滚时怎么把数据改回去”。这两兄弟是 InnoDB 存储引擎的地基也是面试题里的常客但很多做了两三年开发的同事对它们的理解还停留在“听说过名字”的层面。这篇文章我打算把这套机制彻底讲透先解释为什么 MySQL 会设计出“先写日志再改数据”这种反直觉的套路再拆开 undo log 看它如何支撑回滚和多版本读最后结合崩溃恢复、两阶段提交以及生产环境的调参排障把这些知识落到能直接用的层面。适合刚接触 MySQL 底层机制的开发者也适合被“半路宕机”“事务回滚”“版本链太长”问题折磨过的运维和 DBA。1. 为什么会有“先写日志再改数据”这种反直觉设计很多初学者第一次接触 WALWrite-Ahead Logging预写日志时都会愣一下我明明是要改数据的怎么反而先去写日志要理解这件事得先搞清楚 InnoDB 的数据到底是怎么落盘的。1.1 数据页、缓冲池和那颗“悬着的心”InnoDB 读写数据的最小单位是页Page默认 16KB。数据库不会每次操作都直接读写磁盘上的页文件而是先在内存里维护一个缓冲池Buffer Pool。你执行一条 UPDATEInnoDB 会先把包含目标行的数据页从磁盘捞进 Buffer Pool在内存里把页改掉这个页从此变成了“脏页”。脏页什么时候刷回磁盘答案是不一定。可能下一秒也可能几分钟后由后台线程根据淘汰策略、空闲缓冲、检查点等因素决定。这本身是性能优化的核心逻辑——内存操作快如闪电磁盘随机写慢如蜗牛InnoDB 打的就是这个时间差。但问题也随之而来如果事务提交了、返回客户端“更新成功”了而对应的脏页还在内存里这时候数据库突然崩溃断电、进程被杀、内核恐慌内存里的东西全部蒸发磁盘上的页还是老样子——那这条“已提交”的事务不就白干了吗这时候 redo log 就是那颗“后悔药”。1.2 redo log 到底记了什么redo log 记录的是物理层面的变更哪个表空间、哪个页、哪个偏移量、被改成了什么。你可以把它理解成一份“施工现场的原始流水单”——今天 16 号页第 100 个字节被写成了 0x1234全部白纸黑字记下来。关键点在于这条日志必须在事务提交前写到磁盘上这就是 WAL 的核心思想——“日志先行数据后写”。事务提交时内存里的脏页可以继续躺着但 redo log 必须落盘。这样即使脏页还没刷出去数据库崩溃了重启后也能拿着这份流水单把崩溃前已经做了但没来得及落盘的修改重新应用到数据页上。用个生活化的比喻你开了一家面馆客人点单时你先把单子记在本子上redo log然后才下锅煮面改数据页。如果后厨着火了崩溃菜没端出去没关系本子上的单子还在重新照着单子把面煮了就行。1.3 LSN、环形空间和那两个关键指针redo log 不是无限长的它是一块环形写入的磁盘空间。既然是环形就牵扯到“写到哪了”和“哪些还能覆盖”的问题。InnoDB 用 LSNLog Sequence Number日志序列号来给每一条日志记录编号。LSN 单调递增数值本身等同于写入的字节偏移量。环形空间上有两个位置至关重要write pos当前日志写入的位置。checkpoint当前可以被覆盖的起点。checkpoint 之前的日志对应的脏页都已经刷到磁盘了所以那些日志没用了可以被覆盖。两者之间的区域是“还活着”的日志记录着“已提交但数据页还没落盘”的操作。当 write pos 快要追上 checkpoint 时MySQL 就得停下来催促后台把一部分脏页刷出去、推进 checkpoint。这就是为什么过小的 redo log 会导致数据库出现周期性“卡顿”——它不是在偷懒是仓库满了必须先把货发出去才能进新货。所以 redo log 的设计哲学可以总结成一句话用一块环形“流水单”换取“事务提交不等待脏页刷盘”的极速体验代价是这块流水单必须够大、必须每次提交都落盘。2. undo log事务回滚背后的“后悔药”redo log 解决了“已提交事务不丢”的问题那“未提交事务要撤销”怎么办这就是 undo log 的主场。2.1 undo log 里的内容长什么样undo log 和 redo log 正好相反。redo 记录“数据被改成了什么”undo 则记录“如果想撤销这次修改该怎么改回去”。它保存的是反向操作的逻辑描述你执行了INSERT INTO t VALUES (1, 张三)undo log 里就记着“删除这条记录”。你执行了DELETE FROM t WHERE id 1undo log 里就记着“重新插入 id1 的完整行”。你执行了UPDATE t SET name 李四 WHERE id 1undo log 里就记着“把 name 改回原来的值”。正因为 undo log 保存的是逻辑层面的反向操作它才被称为“逻辑日志”。执行回滚时InnoDB 会从后往前事务里后执行的语句先回滚依次执行这些反向操作。2.2 事务回滚只是它的基本盘一个事务里可能执行了 N 条 SQL如果执行到一半发现报错了或者你主动执行ROLLBACKInnoDB 会把该事务产生的 undo log 从尾到头依次取出逐步倒放。倒放的每一段都会把对应数据页改回原来的样子。这里要强调一点回滚是在内存里进行的不是看一眼 undo log 就完事。它会把相关的数据页重新加载到 Buffer Pool执行反向修改让这些页再次变成脏页等待后续刷盘。2.3 MVCCundo log 的另一半使命如果 undo log 只能用来回滚那它充其量算个“草稿纸”。它真正厉害的地方在于支撑了MVCCMulti-Version Concurrency Control多版本并发控制——也就是 InnoDB 在没有读写锁冲突的情况下实现“快照读”的基石。InnoDB 的每行记录里有两个隐藏列DB_TRX_ID最近一次修改这行记录的事务 ID。DB_ROLL_PTR回滚指针指向该行修改前的 undo log 记录。由于每个修改都会生成对应的 undo log而回滚指针又把它们串在一起一条记录的不同历史版本就形成了一条版本链。举个例子事务 10 把 id1 的 name 从“张三”改成“李四”事务 15 又把它改成“王五”。当前行数据是“王五”它的 DB_ROLL_PTR 指向“李四”版本对应的 undo 记录那条记录的回滚指针再指向“张三”版本对应的 undo 记录。当一个事务执行快照读时InnoDB 会根据当前活跃事务列表生成一个 ReadView然后顺着版本链往回找当前版本的 DB_TRX_ID 是否对当前事务可见不可见就顺着回滚指针看下一个版本。重复这个过程直到找到事务开始之前就已提交的版本。这就是“读旧数据不阻塞、写数据不被旧读阻塞”的核心机制。理解到这一层你大概就能明白为什么 undo log 不能随便清了——**它不光是给本事务回滚用的还是给所有并发的“读旧版本”操作当数据源的。**一点都不过分地说没有 undo log就没有 MVCCInnoDB 的并发能力会倒退好几个时代。2.4 insert 与 update/delete 的 undo log待遇差别很大这里有个比较容易忽略的细节INSERT产生的 undo log 和UPDATE/DELETE产生的 undo log生命周期完全不同。INSERT的 undo log 只在事务回滚时有用。因为新插入的行在插入前根本不存在其他并发事务的快照读压根看不到它不需要为别人保留历史版本。UPDATE/DELETE的 undo log 则不同——它们对应的历史版本可能会被别的事务通过版本链读到。所以事务提交之后这些 undo 也不能马上删除要等所有可能读到旧版本的事务尤其是长事务都结束再由后台的 purge 线程来清理。这叫purge 机制。purge 线程会回收那些“不再被任何 ReadView 需要”的 undo log并且顺手清理那些被标记删除的行记录。你要是看过 MySQL 的SHOW ENGINE INNODB STATUS里面的 “History list length” 就是当前尚未 purge 的 undo 版本链长度。3. 数据库崩溃后redo 和 undo 是怎么联手把现场还原的现在两部分的核心机制都过完了我们来看一个 DBA 最关心的场景数据库突然崩溃重启之后 InnoDB 究竟做了什么。3.1 崩溃恢复的三步流程InnoDB 的崩溃恢复可以拆成三段来理解定位 checkpoint前滚 redo。拿着最近一个 checkpoint 对应的 LSN从该位置开始顺序扫描 redo log把其中所有已记录的操作重新应用到数据页。这一步解决的是“已提交但脏页没落盘”的丢失问题也叫 Redo Phase。找出“半途而废”的事务。redo log 里不仅有已提交事务的记录也会包含那些“还没提交就崩溃了”的事务操作。这些操作已经把数据页改到一半但事务整体不该生效。怎么判断哪些该作废看 redo 记录里的事务状态标记——如果事务在崩溃时还处于 prepare 状态就得进一步用 undo 来判断。回滚未提交事务。对这一部分事务InnoDB 会使用 undo log 把它们做的修改逐条回滚掉让数据回到事务开始前的状态。这一步叫 Rollback Phase。三步走完磁盘上的数据就是“所有已提交事务生效、所有未提交事务消失”的精确状态。这个过程对于任何 MySQL 服务都是自动完成的不需要人工干预用户看到的只是启动日志里多了一段 “Starting crash recovery” 的提示。3.2 两阶段提交redo 和 binlog 必须对得上这里必须把 binlog 拉进来一起说。binlog 是 MySQL Server 层的日志记录的是逻辑操作主要用于主从复制和时间点恢复redo log 是 InnoDB 引擎层的物理日志。一个事务要想真正生效binlog 和 redo log 都得记上缺一个都不行。如果 MySQL 在“写 redo”和“写 binlog”之间崩溃了就可能出现两边不一致redo 里说提交了binlog 里却没有或者反过来。复制链路会乱数据恢复会错。为了解决这个问题InnoDB 引入了两阶段提交2PCInnoDB 先把该事务的 redo log 写入磁盘但把事务标记为prepare准备状态。MySQL Server 层把该事务的 binlog 写入磁盘。InnoDB 再把事务标记为commit提交状态。很多人不理解为什么 commit 要放在 binlog 之后反问binlog 是 Server 层的InnoDB 干嘛要等它原因在于**在主从架构里binlog 是复制给从库的“权威来源”从库只看 binlog。**如果 InnoDB 先 commit、binlog 后面才写而这时主库崩了从库就永远看不到这个事务主从数据就撕裂了。所以崩溃恢复时有一套明确的裁决规则redo log 里有 prepare 记录binlog 里也有完整记录 → 事务提交。redo log 里有 prepare 记录binlog 里没有 → 事务回滚。redo log 里已经是 commit 状态 → 正常提交无需纠结。这个设计把 InnoDB 和 binlog 的一致性缝合得非常严密。3.3 掉电瞬间的四种可能我们可以更具体地推演一个更新事务在极端时序下的命运。假设这条 UPDATE 依次经过① 事务开始执行并修改内存页 → ② 写 redo log 并标记 prepare → ③ 写 binlog → ④ 将 redo log 标记 commit。如果掉电发生在这四个节点之间掉电时点redo 状态binlog 状态恢复结果① 之前无记录无记录事务像没发生过① 与 ② 之间部分写入无记录回滚② 与 ③ 之间prepare无记录回滚③ 与 ④ 之间prepare完整提交④ 之后commit完整提交你会发现这个规则能保证一个最基本的承诺**只要 binlog 完整写盘事务就不会丢只要 binlog 没写事务就不会活过来。**这也是为什么不少 DBA 在调sync_binlog参数时特别谨慎——它直接决定了这条准则的严格程度。3.4 组提交一次 fsync 服务多个事务既然每次提交都要先写 redo、再写 binlog、最后标记 commit磁盘 fsync 的次数岂不是被砍成三倍设计者当然想到了这个问题。MySQL 5.7 起实现了组提交Group Commit多个事务的提交阶段被合并同一时刻等待提交的事务可以同时完成一次日志刷盘。简单说A、B、C 三个事务几乎同时进入提交状态它们可以共享一次 fsyncbinlog 写完一次大家一起 commit。这极大地降低了高并发短事务场景下的磁盘 IO 压力。这也是为什么那些“每秒几千次小事务”的系统实际 fsync 次数远低于事务数的原因。4. 生产环境实操调参、排障和一个必须养成的习惯原理讲完落地才是重点。下面这些是我在实际运维和优化中遇到并验证过的场景每个都有对应的操作路径。4.1 关键参数innodb_flush_log_at_trx_commit 到底该怎么选这个参数决定的是“事务提交时 redo log 怎么刷盘”三个取值对应三种截然不同的安全性取值行为崩溃丢数据窗口性能0事务提交不主动刷盘仅每秒刷一次最多丢约 1 秒已提交事务最高1每次事务提交都强制刷盘不丢磁盘 OS 层不坏最低2每次提交写入操作系统缓存每秒再刷盘OS 不崩溃则不丢OS 崩溃丢约 1 秒中我的建议很直接**核心交易类业务、数据不可重建的业务老老实实用 1不要拿数据安全换那一点性能。**日志采集、统计报表、可重算数据这类场景可以用 2但 0 我基本不推荐——你省下的那点 IO 换来的是一大块滑梯般的丢数据窗口遇上事故时解释成本太高。如果你想知道当前实例的 redo 写入进度可以看SHOW ENGINE INNODB STATUS里的三段数值Log sequence number当前写入位置也就是 write pos。Log flushed up to已经刷到磁盘的日志位置。Last checkpoint at最近一次 checkpoint 位置。三者之间的关系是Log sequence number ≥ Log flushed up to ≥ Last checkpoint at。如果发现 Log sequence number 和 Last checkpoint at 的差距长期逼近 redo 总容量说明你的 redo 可能偏小或者存在大事务一直没提交。4.2 redo 文件大小别让“日志满”成为卡顿源头在 MySQL 8.0.30 之前redo log 的大小由innodb_log_file_size单个文件大小默认 48MB和innodb_log_files_in_group文件个数默认 2共同决定也就是默认一共 96MB。8.0.30 之后官方把这两个参数合并成了innodb_redo_log_capacity默认 100MB并且支持自动调整大小。我接手过一个老系统redo 一直用默认的 96MB结果线上每隔一段时间就会出现一次诡异的“瞬间卡顿”时间点完全不固定但每次卡完之后SHOW ENGINE INNODB STATUS里 checkpoint 都狠狠推进了一段。定位下来就是 redo 太小写满了InnoDB 被迫停下业务去刷脏页。后来把innodb_log_file_size调大后这个卡顿凭空消失了。调多大合适我的经验值是**如果实例是 5.7 及以前版本先把单个 redo 文件调到 1GB 以上总数建议在 2GB 到 8GB 之间。**8.0.30 之后用innodb_redo_log_capacity直接设成 4GB 起步。不过要注意改大 redo 后崩溃恢复扫描日志的时间也会变长所以也别无脑越大越好——容量和恢复时长要取一个平衡。4.3 undo 膨胀最容易被忽视的“磁盘黑洞”redo 是环形空间大小可控但 undo 表空间是持续增长、按需截断的一旦失控能把磁盘吃到爆。我遇到过一次真实的告警某业务线反馈磁盘空间告急我登上去一查发现 undo 表空间涨到了几十 GB。再查information_schema.innodb_trx发现有一条事务的trx_started已经是 12 个小时之前了——程序代码里写了更新逻辑但事务忘记 commit连接一直挂着。由于这个事务一直存活它可能读取的所有旧版本都得在 undo 里保留purge 线程根本无法清理undo 表空间只能一路膨胀。排查命令很简单SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started ASC;看到长时间运行的事务后先找业务方确认是否能 kill然后执行KILL trx_mysql_thread_id;把这个僵尸事务终结掉。事务结束后undo 表空间不会瞬间变小但 purge 线程会开始回收配合自动截断机制慢慢把空间释放出来。这里必须提醒的是**在 8.0 中undo 是放在独立 undo 表空间的并默认开启自动截断truncate功能。**但自动截断有一个前提——undo 表空间里不能有正在被使用的事务。如果你的实例常年跑着长事务truncate 永远等不到机会undo 就会持续膨胀。根治方案不是等它自己截断而是从业务层面消灭长事务。4.4 一个排查大事务引发的“尖刺延迟”的完整思路再分享一个复用价值很高的排查链路曾经把我从一次性能事故里捞出来。现象是线上某个库每天凌晨跑批时监控出现一个持续的延迟尖刺持续几分钟业务侧表现为大量慢查询。我的排查路径是这样的先看SHOW ENGINE INNODB STATUS里的 “Log sequence number” 和 “Last checkpoint at”发现两者差距达到好几个 GB——redo 接近写满。再查information_schema.innodb_trx果然有一条跑批事务已经执行了几十分钟且仍在进行它产生了海量 redo 日志。由于 run 批事务长期占用 Buffer Pool 里的脏页redo 写满后 InnoDB 必须强制推进 checkpoint把脏页刷到磁盘才能腾出环形空间继续写日志。就在这时所有业务请求都在“等日志空间”全局性地被拖慢。处理办法分两步短痛是调整跑批的调度时间错开线上高峰长痛是让跑批操作分批提交比如每处理 1000 行就提交一次这样 redo 和 undo 的占用曲线都会平稳很多。这个案例说明一个问题大事务不是只跟自己有关它会把共享资源——redo 环形空间、undo 历史版本、Buffer Pool 脏页——全部拖入紧张状态最终把无关业务也拉下水。4.5 养成每周看一眼“僵尸事务”的习惯最后分享一个我坚持了很多年的小习惯。每周一早上我会挑一个业务低峰期执行一次SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(MINUTE, trx_started, NOW()) AS running_minutes, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY running_minutes DESC LIMIT 20;只要发现有事务运行超过了 30 分钟就先记录下来去追业务方的操作。这个习惯的成本很低但收益极大——绝大多数 undo 膨胀、锁等待堆积、版本链过长导致的性能劣化追根溯源都能找到一条超长待机的事务。预防它比等表空间告警了再去救火省心一百倍。我自己刚接触 InnoDB 时也曾经把 redo log 和 undo log 当成两个无关的“备份日记”直到真正经历过“事务提交了但数据差点丢了”的惊魂时刻才明白它们其实是同一枚硬币的两面redo 负责让已提交的事务在灾难后复活undo 负责让未提交的事务死得干净。把这一对日志的运行逻辑装进脑子里再遇到 MySQL 的崩溃恢复、主从数据对不上、磁盘莫名暴涨这些问题你手里就有了第一张可靠的排查地图。
返回列表