Skip to content
Charles Shao
Go back

MySQL 内核深挖 · redo、undo 与 binlog 机制

–views

事务提交返回「成功」之后,如果机器立刻断电,计费库的钱到底算不算扣成功?从库会不会少一条关键的投放流水?这些致命问题的答案并不在 SQL 语义里,而深深植根于 redo / undo / binlog 这三条日志的底层协作中。本篇将把三条日志的各自职责、刷盘参数组合以及内部两阶段提交机制彻底讲透,这正是计费库「绝对不敢丢提交」的坚实底子。

本文是 MySQL 内核 系列的第 3 篇(redo / undo / binlog)。 全系列 5 篇:

  1. MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引
  2. MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁
  3. MySQL 内核深挖 · redo、undo 与 binlog 机制(本篇)
  4. MySQL 内核深挖 · EXPLAIN 计划与慢查询优化
  5. MySQL 内核深挖 · 主从复制链路与 HA 高可用

一句话定位:redo 保证崩溃后已提交事务能重放到数据页;undo 支撑回滚与 MVCC;binlog 在 Server 层服务复制与时间点恢复;两者用内部 2PC 对齐「到底提交没有」。

TL;DR

Table of contents

Open Table of contents

1. 为什么需要三条日志

要理解三条日志为什么必须同时存在,我们不妨先观察一次预算 UPDATE 操作在内核中走过的粗粒度路径:当事务修改数据时,引擎首先会修改 Buffer Pool 中的数据页使其变脏,同时写入记录旧值的 undo log 以支持事务回滚与一致性视图(ReadView)读取;接着,将该页的物理修改增量写入 redo log 以便应对崩溃后的数据重放;直到事务准备提交时,才在 Server 层写入 binlog 供从库复制与时间点恢复,而变脏的数据页本身则可能在很久之后才被异步刷脏落盘。

正因如此,如果每次提交都强行把脏页同步落盘,随机 I/O 的极低效率会让线上计费库的吞吐完全扛不住;但若只改内存而不记日志,一旦崩溃必将面临丢失提交数据的灾难。预写日志(WAL,Write-Ahead Logging) 正是这一矛盾的最佳折中方案——因为日志的顺序追加写极快,从而将昂贵的数据页随机落盘转化为高效的异步后台操作。

UPDATE 路径上 Buffer Pool、redo、事务提交与异步刷脏,以及 redo/undo/binlog 三分职责。

三条日志的所处层级与核心用途截然不同:将其混为一谈往往是引发 crash-safe 失效与复制事故的温床。

2. redo log:物理 WAL

2.1 写什么

redo 记录的是诸如「某个表空间、某页、页内偏移,被改成了什么」这一类物理增量。虽然其具体实现细节随版本不断演进,但在日常运维的心智模型上,我们完全可以将其按物理日志来理解。这些日志被顺序追加到一组可循环使用的固定大小日志文件中,并通过 LSN(Log Sequence Number) 机制精确标记当前的写入进度;当后台线程完成刷脏并推动 checkpoint 前进时,即意味着「该 LSN 之前的脏页均已安全落盘」,落后的 redo 日志空间也因此得以被循环回收利用。

2.2 崩溃恢复在干什么

当遭遇断电重启后,InnoDB 的崩溃恢复机制主要会执行两个关键步骤:首先,内核会利用 redo log 前滚重放那些已提交或处于确定状态的变更,将其严丝合缝地恢复到对应数据页上;随后,再结合 undo log 将那些未能完成提交的活跃事务安全回滚。由此我们便能推导出一条在计费库运维中极为关键的结论——只要事务提交成功并且 redo 已经按既定策略安全落盘,即使此刻的脏页完全没来得及刷,实例重启后也绝对能把修改的数据完整找回来。

2.3 innodb_flush_log_at_trx_commit

值行为风险
1每事务提交 fsync redo最安全,fsync 贵
2提交写 OS 缓存,约每秒 fsyncOS 崩溃可能丢约 1s
0约每秒写+刷mysqld 崩也可能丢约 1s

需要强调的是,生产环境中常常潜伏着一种隐蔽的性能陷阱:虽然实例参数看上去是标准的双 1 配置未曾动过,但底层的磁盘 I/O 却可能发生抖动。这是因为在双 1 模式下,每一次事务提交的延迟 p99 会死死贴着底层存储的 fsync 延迟;因此,当云盘突然变慢时,计费库的事务提交延迟往往会先于系统负载率先报警,此时 DBA 切忌将其误判为业务层面的锁等待冲突。

3. undo log:回滚与 MVCC 原料

相较于记录物理增量的 redo,undo 记录的则是逻辑层面的「如何将数据变回去」。它在内核中身兼三职:支撑事务执行 ROLLBACK 操作、为 MVCC 机制构造历史旧版本,以及在崩溃恢复阶段负责清理未提交的数据。这些堆积的历史旧版本一旦确认不再被当前系统内任何活跃的读视图(ReadView)所需要,就会交由后台的 purge 线程异步清理回收。

需要强调的是,由于上一篇提到的版本链机制,一旦业务代码中出现长事务与大查询,它们持有的 ReadView 就会死死拖住 purge 进度,导致 undo 记录疯狂堆积、版本链被无限拉长,最终往往会把磁盘空间与 CPU 资源一起彻底拖垮——在广告库的日常故障中,「一个跨天大报表查询事务挂了几个小时」绝对是引发这类雪崩的经典事故诱因。当 purge 进度严重跟不上时,线上症状会表现得极为典型:undo 表空间急剧膨胀、状态变量 History list length 飙升,甚至连最简单的索引点查也会因为顺着漫长版本链回溯而显著变慢。针对这一痛点,治理往往聚焦于三条主线:首先,坚决禁止超长只读事务长时间霸占连接不放;其次,将大批量数据删除操作改写为分批的 DELETE … LIMIT 语句并尽快提交释放锁;最后,必须通过监控系统严密盯住历史链表长度与 undo 空间使用率,并设立合理的告警阈值。

4. binlog:复制与时间点恢复

对比前文属于 InnoDB 专属日志的 redo 与 undo,binlog 则是独立存在于 Server 层的逻辑日志。它与具体存储引擎无关,只要在配置中开启,无论是 InnoDB 还是其他引擎的数据变更都会被一视同仁地记录。binlog 在高可用架构中承担着三大核心职责:作为主从复制的数据同步事件源(详见 主从复制与高可用)、作为点时间恢复的关键凭证(通过全量备份结合 binlog 重放机制),以及配合完整的行镜像数据服务于下游 CDC 流水与业务审计。

在日志格式的选择上,MySQL 提供了三个选项:STATEMENT 格式直接记录 SQL 语句原文,虽然极大地节省了存储空间,但遇到不确定性函数或复杂触发器时极易踩坑导致主从数据分叉;ROW 格式则忠实记录数据行在变更前后的完整镜像,尽管体积庞大,但胜在绝对安全可靠,这绝对是广告投放流水库雷打不动的推荐选项;至于 MIXED 格式则是一种混合策略,由 Server 层自行判断并切换。

与 redo 类似,sync_binlog 参数同样有着严格的分档策略:设置为 1 代表每次事务提交时都会强行 fsync binlog 落盘,这通常与 InnoDB 的日志参数组成业内标准的「双 1」搭档;若设为 0 / N 则依赖操作系统缓冲批量刷盘,虽然峰值吞吐表现极佳,但在宕机崩溃时极易丢失最后若干个事务的 binlog 事件。

回到刚才那条 UPDATE 路径,运维侧还需要格外警惕 binlog 保留周期与磁盘空间之间的微妙平衡。在计费库面临大促流水高峰时,单日激增几十 GB 的日志量并不稀奇。如果保留周期留得太短,一旦需要进行点时间恢复或搭建延迟从库时就会因为日志断档而彻底卡死;但若留得太长,被撑爆的磁盘空间又会反过来瞬间拖垮主库实例。因此,这部分参数必须与全量备份频率、恢复窗口期等整体策略协同设计。另外还需要小心的是,在开启 binlog 的表上执行部分 DDL 等「隐式提交」语句时,会直接打断你原本以为包裹严密的大事务边界——那些在深夜跑批的批量数据变更脚本必须要深刻理解这一点。

5. 内部两阶段提交:对齐 redo 与 binlog

前文提到崩溃恢复依赖重放 redo,但从库的数据同步却只认 binlog。如果内核任由这二者各写各的,在极端断电场景下必然会出现两种灾难性的数据分叉:要么是引擎层已经将更改提交落盘、但 binlog 却还没来得及写,最终导致主库明明有数据、从库却永远缺失这一行;要么恰好反过来,binlog 刚写完还没等引擎真正提交机器就崩溃了,结果从库反而多出了主库根本不存在的幽灵行。

为了彻底打平这两种日志的状态,MySQL 巧妙地引入了内部 XA 式的两阶段提交(2PC) 机制,将二者强行绑定在同一个 XID 事务编号上协同推进:

  1. Prepare 阶段:InnoDB 将 redo log 的状态标记为 prepare,并依据配置策略执行刷盘动作;
  2. 写 binlog 阶段:Server 层将带有对应 XID 的 binlog 事件写入,并依据 sync_binlog 策略落盘;
  3. Commit 阶段:InnoDB 最后将 redo log 的状态正式标记为 commit,宣告事务完结。

通过这种交叉握手的机制,实例崩溃后的裁决规则变得异常清晰且坚不可摧:如果引擎层发现某事务只有 prepare 状态而缺乏完整的 binlog 记录,则判定事务未决直接执行回滚;若不仅有 prepare 且在 binlog 中找到了完整对齐的事件记录,则内核有底气在引擎侧强行重放提交。正因如此,crash-safe replication(崩溃安全的复制)的根基才算真正立得住。

在日常排障时,若你亲眼看到「主库有这行记录、从库死活查不到」或者恰好反过来的诡异现象,除了排查常规的复制延迟与同步过滤规则外,务必要怀疑该实例在历史上是否曾遭遇过异常宕机关机叠加非双 1 配置的组合暴击,亦或是人为跳过了某些关键错误事件。诸如 GTID 体系中莫名出现的编号缺口、亦或是 DBA 抢修时人工执行了 SET sql_log_bin=0 来强行订正数据,这些往往都是导致主从数据分叉的高频人因。

应用、InnoDB、binlog、磁盘之间 prepare → 写 binlog → commit 的时序,以及崩溃裁决规则。

内部 2PC 机制解决的是「双日志对事务是否提交的意见一致性问题」,这与微服务架构下的分布式跨库事务截然不同。

6. 双 1 与组提交

双 1、折中、高吞吐三档刷盘参数的安全与性能对照。

在计费与预算库场景下必须优先死守双 1 底线;应通过组提交机制与升级硬件设备来换取 TPS 吞吐,而绝不是妥协去关掉安全阀门。

回到上一节的内部两阶段提交流程:要想让基于双日志的 crash-safe 承诺真正立得住,必须保证 redo 与 binlog 都能在事务提交的瞬间可靠落盘,这也就是业内常说的「双 1」配置组合的由来。然而频繁落盘必定拖慢性能,内核为此专门引入了组提交(group commit) 机制,它通过在队列中短暂等待,将多个并发事务的 binlog 与 redo 刷盘动作巧妙合并成一次底层的 fsync 操作,从而在不牺牲双 1 安全底线的前提下,大幅摊薄 fsync 成本并抬高系统的整体提交吞吐量。基于这一机制,生产环境的调优顺序往往遵循如下路径:

  1. 仔细评估并确认当前业务场景是否真的不容许任何数据丢失从而必须死守双 1 底线;
  2. 为底层配备足够低延迟的高速存储介质,并预先规划好合理的 redo 容量;
  3. 充分吃透组提交带来的性能红利(重点观察 binlog 组提交相关的内部状态变量是否处于健康运转区间);
  4. 若 TPS 依然扛不住,再谨慎评估是否将边缘非关键库进行降档处理——但千万不要拿核心的计费流水库去试刀;
  5. 推动应用研发侧在代码中采用适当批量的操作(即多行改动包裹在一次事务中提交)以直接减少底层的总提交次数,但同时也要严防死守将其组装成「超大事务」反过来伤害主从复制的实时性。

这也解释了为什么这类底层参数不仅关乎单机稳定,更直接关联到诸如 分布式事务 Outbox 的微服务架构选型。因为业务侧的 Outbox 本地消息表往往需要与 binlog CDC 监听组件协同实现微服务的最终一致性;一旦作为事件源头的 binlog 失去可靠性保证或与引擎数据出现割裂,整个分布式状态机就会彻底崩盘,这一痛点再次将系统的安全底线指向了「双 1 + ROW」的铁律。

7. LSN、checkpoint 与 redo 容量

承接上一节关于配置合理 redo 容量的讨论,我们在运维直觉上需要牢牢记住这三条环环相扣的因果链:业务写入并发越高,redo 的 LSN 序号推进也就越快;而只有后台脏页刷盘的速度紧紧跟上,安全水位 checkpoint 才能随之向前推进,被用过的旧 redo 空间才得以被循环回收复用;倘若底层脏页实在刷不动(常见于磁盘 I/O 遭遇瓶颈或 Buffer Pool 极大且遭遇爆发式猛写),redo 日志空间极有可能被瞬间打满,进而触发全局性的系统停写以强行等待刷脏——因此,当计费库在流量高峰期遭遇「所有 UPDATE 语句突然集体阻塞停滞」的诡异故障时,根本诱因往往不一定是单纯的锁冲突等待,而很有可能是底层 redo 空间耗尽或者刷脏速度严重跟不上。

应对上述风险,我们在运维侧往往需要雷厉风行地落实三件事:首先,必须给足 innodb_log_file_size 即充足的 redo 总容量,但要注意容量并非无限加大就好(过大的文件会导致崩溃恢复时间被无限拉长,且相应的刷盘与刷脏策略也必须同步配套调整);其次,需要通过监控面板严密观察 checkpoint 年龄(与当前最新 LSN 的落差)以及 Buffer Pool 中待刷脏页的实时比例;最后,务必死死盯住底层存储介质的 I/O 延迟指标(尤其是 p99 贴着 fsync 延迟 的波动表现),因为它直接决定了在严苛的双 1 配置下,每一次关键事务提交所需付出的真实延迟代价。

8. ROW 镜像与 CDC

当 binlog 配置为 ROW 格式时,日志记录会完整携带变更数据行的前后镜像(具体可以通过 binlog_row_image = FULL/MINIMAL 参数进一步控制精度)。在广告业务线的架构中,这一特性的常见用途可以归结为三大场景:维持常规的从库数据复制运转、借助 Canal 或 Debezium 这类 CDC 工具实时捕获数据变更推入下游数仓或搜推引擎,以及配合对账系统精准审计「究竟是哪个接口把这笔预算改成了多少金额」。

如果为了极致节省存储空间而将行镜像参数压缩为 MINIMAL,虽然能显著减轻主库压力,但下游那些嗷嗷待哺的 CDC 消费组件极有可能会因为拼凑不出完整的列数据而直接报错罢工;因此,对于财务级别的流水大表,强烈建议在彻底想清楚上下游链路的依赖关系之前,不要轻易去砍除这层行镜像数据的安全垫。与此同时,如果执意在 STATEMENT 格式下对包含诸如 UUID() 或 NOW() 等不确定性函数的语句进行复制操作,就趁早打消指望主从数据能保持绝对一致的危险念头。

除了常规的 DML 操作,DDL 与 binlog 这条关键生命线同样需要运维人员死死盯住。在执行 Online DDL 期间,主从节点间剧烈的复制延迟抖动以及由此引发的排队元数据锁等待,向来是广告配置大表在紧急加列时最常遭遇的痛点。正因如此,诸如业务低峰变更窗口的敲定、ALGORITHM/LOCK 等执行选项的严格打磨,以及是否需要提前在演练从库上跑一遍确认耗时的要求,统统都应当作为红线标准写进每一次数据库变更单里,而绝不应拖到线上跑批当晚,才在一片兵荒马乱中无奈惊呼「从库回放落后了」。

9. 小结:三条日志怎么记

日志层级主要回答的问题
redoInnoDB崩溃后已提交的改动怎么重放到数据页?
undoInnoDB怎么回滚?别人怎么读旧版数据?
binlogServer从库、CDC 流水与时间点恢复怎么重放?

针对这三条交织着血与泪的日志防线,最后再次奉上这套实战提炼出的排障口诀:丢提交先查 flush 参数与磁盘 fsync;主从不一致先查 2PC/双 1/是否跳过 binlog;undo 膨胀先查长事务。

下一篇转向「慢」的日常——执行计划与慢查询优化:EXPLAIN 怎么读、索引为何失效、JOIN 与深分页怎么改。

参考


–views
Share this post on:

Previous Post
特征工程系统化框架:能用哪些料、料从哪进、料坏了怎么报警
Next Post
MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁