redo / undo / binlog 三本账:崩溃不丢、回滚可退、复制有据——Java 工兵连第 12 篇
「Java 工兵连」第 12 篇,接着第 11 篇的 undo 链往深处走。新手常见三个问题:为什么改数据先写日志(不更慢吗)?为什么日志要三条、一条不行吗?主从数据为什么会不一致?答案全在三本账的分工里。
一、先建直觉:一家饭店的账
- 数据页(Buffer Pool 里的脏页)= 柜台上的账本原件——改完先放着,打烊后(后台线程)才誊进保险柜的正式账本(磁盘数据文件);
- redo log = 柜台边的流水便签:每笔交易先草记一笔。突然停电(崩溃)?凭便签把没誊完的账一笔笔补上——崩溃不丢;
- undo log = 退货登记簿:每笔交易都顺手记下「这笔怎么撤」。顾客要退货(回滚),照簿反向操作;老顾客要查上个月的账(MVCC 读历史),也翻这本簿——可反悔、可查史;
- binlog = 上报总店的正式流水册:按时间顺序追加、只增不改,定时抄送各分店(从库复制)、永久存档(时间点恢复)——传承有序。
三句职责口诀就是标题那句:redo 保崩溃,undo 保后悔,binlog 保传承。
二、三本账对比:一张表看全
| redo log | undo log | binlog | |
|---|---|---|---|
| 所属层 | InnoDB 引擎 | InnoDB 引擎 | MySQL Server(所有引擎) |
| 记什么 | 物理页修改 | 逻辑反操作 | 逻辑事件(ROW/STMT/MIXED) |
| 写的时机 | 事务执行中先写(WAL) | 修改前先写 | 提交时写 |
| 空间方式 | 固定大小,循环写 | 按需增长,purge 清理 | 追加写,永不覆盖 |
| 崩溃之后 | 重放「已提交」的 | 回滚「没提交」的 | 已落笔的不动 |
| 拿来干嘛 | 崩溃恢复 | 回滚 + MVCC | 主从复制、按时间点恢复 |
三个「为什么」从表里直接读出来:
为什么改数据先写日志反而快?(WAL 的灵魂)改数据页是随机写(页散落在磁盘各处),写 redo 是顺序写(文件末尾追加)。顺序写比随机写快几个数量级——先把便宜的做了,贵的(刷脏页)交给后台慢慢来。「先记便签后誊账,随机写变顺序写」,这个思想 Kafka 的顺序写、LSM 树(No.18、No.19 还会遇到)全是同门。
为什么 redo 循环写、binlog 追加写? redo 只管「最近的账」:便签写满了就必须逼着柜台把旧账誊完(推 checkpoint 强制刷脏页),旧便签即可覆盖——所以 redo 不能做时间点恢复。binlog 是存档,只增不改——所以能按时间点恢复(把备份 + binlog 重放到任意时刻,「误删表抢救」全靠它)。
为什么 binlog 默认 ROW 格式? STATEMENT 记原始 SQL,省空间,但 now()、uuid()、LIMIT 这类不确定语句在主从会执行出不同结果;ROW 记行镜像(改前改后各一份),笨但绝对一致。默认的安全换空间,没毛病。
三、两阶段提交:redo 和 binlog 怎么不打架
三条日志里有一致性风险的是 redo 和 binlog:一个在引擎层、一个在 Server 层,两次落笔之间崩溃怎么办? 只写了一边,主库恢复后和从库(靠 binlog 同步)数据就岔了。
InnoDB 的答案是内部 XA · 两阶段提交(见 hero 图时序):
redo prepare——物理修改落 redo,状态标记「预备」;- 写
binlog——总账落笔; redo commit——盖章,事务完成。
崩溃恢复规则:重启后扫描 redo,发现停在 prepare 状态的事务 → 去查 binlog:binlog 里有完整记录 → 提交;没有 → 回滚。一句口诀:先预备、再记总账、最后盖章;恢复时看总账定生死。这样无论崩在哪一步,主从永远一致。
四、实战:双 1 与大事务
刷盘档位是生产上最常被问的取舍,两个参数一乘:
innodb_flush_log_at_trx_commit = 1 # redo:每次提交都 fsync 到磁盘
sync_binlog = 1 # binlog:每次提交都 fsync 到磁盘
- 双 1:最稳,提交即落盘,崩溃一条不丢——交易、支付类库的底线;
- 双 0:最快(攒一批再刷),崩溃可能丢最近几秒的事务——日志库、可重算的场景可用;
- 1×2 或 2×1:折中,MySQL 崩溃不丢、操作系统崩溃可能丢——看似安全实则最尴尬的档位,要么稳到双 1,要么明确接受丢失,别选半吊子。
大事务与主从延迟:从库回放 binlog 是单事务串行的——一个跑了几分钟的大事务在 binlog 里是一个巨型事件,从库要整体回放完才继续下一个,延迟瞬间拉高(还能把从库 OOM——第 10 篇的大对象暗线又来了)。处方和第 11 篇一模一样:拆事务。批量更新按千行一批分段提交,主从延迟立刻回落。一个坏习惯,两篇的病。
老兵点评
- 把 WAL 当成一种通用思维,而不只是 MySQL 名词。先写便宜的顺序日志、再异步做昂贵的随机落盘——Kafka、Elasticsearch、LevelDB、文件系统的 journal,全是这一招。看懂 redo,你看一半存储系统都开了透视。
- 「双 1」不是性能问题,是品味问题。我见过太多库「为了性能」调成双 0,出事后才想起这是钱。真正的性能优化路径是:拆大事务、合并小提交(组提交)、升级磁盘——而不是关保险。
- 面试段位差:初级背「三种日志的作用」;中级讲 WAL 和两阶段提交流程;高级讲 redo 物理与 binlog 逻辑的差异、循环写与追加写的代价、以及组提交(group commit)怎么把多次 fsync 合并成一次——到这层你在聊存储引擎设计,不是背书。
带走三句话
- redo 保崩溃(物理、循环写、WAL 先写),undo 保后悔(逻辑反操作 + MVCC 版本链),binlog 保传承(逻辑、追加写、复制恢复)。
- 一致性靠两阶段提交:redo prepare → binlog → redo commit;崩溃恢复看 binlog 定生死。
- 刷盘口诀:双 1 最稳、双 0 最快、半吊子别选;主从延迟第一嫌疑人永远是大事务。
本篇是「Java 工兵连」第 12 篇。→ 上一篇:第 11 篇 · 事务隔离与 MVCC | 继续阅读:第 13 篇 · MySQL 锁全景与死锁排查 | 浏览整个专栏 →。
❦ 终 ❦