Theory · MySQL · Crash-safe
redo 管崩溃恢复、undo 管回滚与 MVCC、binlog 管复制与按时间点恢复 —— 两阶段提交把它们焊成一个整体
物理日志,WAL 先写日志再写页;循环写,掉电后前滚恢复——crash-safe 的根基
逻辑日志,记录反向操作;事务回滚 + MVCC 版本链(细节见 MVCC deck)
逻辑日志,所有引擎共用、追加写;主从复制与按时间点恢复的唯一事实来源
Why Logs Matter
-- 用户支付 100 元,账户余额从 500 改成 400 BEGIN; UPDATE accounts SET balance = 400 WHERE id = 1; COMMIT; -- 客户端收到:提交成功 ✓ -- COMMIT 返回后,这一行其实还在内存的 buffer pool 里, -- 磁盘上的 accounts.ibd 仍然是 500。 -- 数据页要等后台线程"慢慢"刷回磁盘。 -- 0.3 秒后:机房断电 —— 内存全丢。 -- 重启后余额是多少? -- 没有 redo log → 400 这个改动凭空消失,余额变回 500。 -- 用户扣款成功了,商家没收到钱,账对不上。 -- 有 redo log → 400 完好无损。这就是 crash-safe。
因为太慢。数据库按 16KB 的页读写磁盘,改一个字节也要把整页写回;而且一个事务改的行往往散落在不同页、页又散落在文件各处——等于每次提交都要做好几次随机写。机械盘一次随机写约 10ms,一秒只能提交一百来个事务。
它把随机写换成顺序写:提交时不碰数据页,只在日志文件末尾追加一条"把第 N 页的某处改成 X"的记录并落盘。顺序追加比随机写快一到两个数量级。这就是 WAL(Write-Ahead Logging,预写日志):先写日志,再(慢慢)写数据。
① redo:管"崩了之后把做过的事重放一遍"——解决上面的断电丢数据;
② undo:管"把没做完的事撤销回去"——断电时可能有事务只改了一半;
③ binlog:管"把发生过的事告诉别人"——从库要照着抄,出事要按时间点恢复。
三份日志分属两层、各管一段,最后还要用两阶段提交把它们焊成一个整体。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 页(Page) | InnoDB 读写磁盘的最小单位,默认 16KB;改一个字节也要整页写回 |
| buffer pool | 数据页在内存里的缓存区;事务改的是内存里的页,磁盘上的副本是旧的 |
| 脏页(Dirty Page) | 在内存里被改过、还没刷回磁盘的页 |
| 刷盘 / fsync | 把内存里的数据真正交给磁盘。write 只是交给操作系统缓存,fsync 才保证断电不丢 |
| WAL | 预写日志:修改数据之前先把"改了什么"写进日志——用顺序写换随机写 |
| 物理日志 | 记"哪个页的哪个位置改成了什么",重放时不用解析 SQL(redo 属于此类) |
| 逻辑日志 | 记"做了什么操作",如 UPDATE t SET c=1 WHERE id=2(binlog / undo 属于此类) |
| LSN | Log Sequence Number,redo 的全局递增坐标:写到哪、刷到哪、页改到哪,全用它对齐 |
| checkpoint | 一个界线:它之前的 redo 对应的脏页已经刷盘,那部分日志可以被覆盖复用 |
| 两阶段提交 2PC | 让 redo 与 binlog 要么都生效、要么都不生效的跨层提交协议(prepare → binlog → commit) |
transaction-basics.html → ACID 各自靠什么实现、事务的生命周期与长事务治理
mvcc.html → undo 版本链、ReadView 与 purge(本 deck 只讲 undo 在回滚里的角色)
为了不纠结名词,后面统一按"这份日志写给谁看、往哪个方向用"来区分:redo 向前重做、undo 向后撤销、binlog 给别人抄。遇到 LSN 就当它是"日志的页码"。
Overview · Three Logs, Two Layers
开场那笔"支付成功却丢了的钱",靠 redo 补回来、靠 undo 撤销做一半的、靠 binlog 通知从库。先看三份日志分别住在哪一层。
Redo Log · WAL · §17.6.5
每次事务提交都要把改过的数据页(16KB)刷回磁盘。一个页只要改了几个字节也要整页写,且不同页散落在表空间各处——大量随机 IO,提交延迟完全被磁盘性能卡死。
提交时只把"改了什么"先顺序追加到 redo log 并落盘,数据页留在 buffer pool 里慢慢刷。崩溃后按 redo 重放(前滚),把没来得及刷的页恢复出来——顺序写小日志换随机写大页。
| 要素 | 说明 |
|---|---|
| physiological logging | redo 是"物理到页、逻辑到行"的日志:记录哪个表空间哪一页、哪个偏移、做了什么修改(page-oriented physical redo)。重放不需要解析 SQL,恢复极快 |
| log buffer | 先写内存中的 log buffer(innodb_log_buffer_size 默认 16MB),按策略刷到 redo 文件(默认在 #innodb_redo 目录) |
| LSN(log sequence number) | redo 的全局单调递增坐标:写入位置、checkpoint 位置、页上的最新修改都拿 LSN 说话 |
| 为什么必须是物理日志 | 崩溃恢复要重放到"崩溃那一刻的页状态",逻辑日志(binlog)缺少页级信息、也无法驱动 checkpoint——这是 redo 不能被 binlog 取代的根本原因 |
Redo · Circular Write · Checkpoint
Redo · Durability Knob
| 取值 | 提交时做什么 | 丢数据窗口 | 适用 |
|---|---|---|---|
| 0 | 提交不写 log buffer;后台线程每秒 write + fsync 一次 | mysqld 崩溃 / 主机断电都丢约 1 秒事务 | 纯测试环境 |
| 1(默认) | 每次提交:log buffer → redo 文件,并 fsync 到磁盘 | 不丢已确认事务(除磁盘本身损坏) | 生产默认,配合 sync_binlog=1 即"双 1" |
| 2 | 每次提交:写到 redo 文件的 OS page cache;每秒 fsync 一次 | mysqld 崩溃不丢(数据还在 OS 缓存);主机断电 / OS 崩溃丢约 1 秒 | 容忍秒级丢失换性能(如日志型写入) |
innodb_flush_log_at_trx_commit=1 + sync_binlog=1。两者分别管 redo 和 binlog 的落盘时机,缺一个就有一份日志可能丢——答题时要把两条一起报出来,并说明各自防的是哪种崩溃。
三档的代价都来自 fsync。InnoDB 与 binlog 都支持组提交:同一时刻并发提交的多个事务合并成一批,一次 write、一次 fsync 服务整组(redo 组提交 + binlog 组提交,见第 13 页)。
Undo Log · §17.6.6
| undo 分类 | 服务谁 | 何时可丢弃 |
|---|---|---|
| insert undo log | 仅事务回滚(没有其他视图会读插入的新版本) | 事务提交即可丢弃(commit 即可清) |
| update undo log | 回滚 + MVCC 版本链(一致性读要沿它找旧版本) | 等"没有任何 ReadView 还可能需要它"——由 purge 判定 |
insert 反向是 delete、update 反向是"把旧值写回"、delete 反向是"把整行插回"。所以回滚 = 沿反向操作执行一遍;而"把旧值写回"恰好也是快照读要的历史版本——回滚与 MVCC 共用 update undo。
undo 从系统表空间拆为独立 undo 表空间(默认 2 个),支持 innodb_undo_log_truncate 自动截断回收——长事务钉住 update undo 导致 undo 膨胀的问题依旧存在(见 MVCC deck 的 purge 与长事务页)。
Binary Log · Formats · §19.2.1
| 格式 | 记录什么 | 体积 / 开销 | 主从一致性风险 |
|---|---|---|---|
| STATEMENT | 记录 SQL 语句原文(及上下文信息) | 最小;一个语句影响百万行也只记一条 | 不确定语句在从库重放可能得到不同结果(RAND/UUID/LIMIT 等,见 复制 deck) |
| ROW(8.0 默认) | 记录行的实际变更(哪一行、改成什么样),binlog_row_image=FULL 默认记整行前后镜像 | 最大;按行放大写放大 | 确定性强,复制最安全;更新范围可精确回放 |
| MIXED | 默认 statement,遇到不安全语句自动切 row | 介于两者 | 折中;但"何时切换"的边界依赖实现判断 |
READ COMMITTED 下 gap 锁禁用、加锁范围不受控,statement 重放结果可能不一致——手册直接规定 RC "only row-based binary logging is supported";MIXED 在 RC 下自动按 row 记录(呼应 MVCC deck 第 15 页)。
8.0 起 binlog_format 默认 ROW;手册 §19.2.1 注明该设置自 8.0.34 起被弃用——官方方向是 row-only。答"选什么格式"时补一句版本趋势是加分项。
Binary Log · sync_binlog · Redo vs Binlog
| sync_binlog | 提交时行为 | 风险 |
|---|---|---|
| 1(8.0 默认) | 每次提交 write + fsync binlog | 不丢已确认事务(最安全) |
| 0 | write 交给 OS,fsync 由 OS 决定 | 主机崩溃丢 OS 缓存里的 binlog |
| N | 攒 N 个事务组提交 fsync 一次 | 崩溃最多丢 N−1 个事务的 binlog |
| 维度 | redo log | binlog |
|---|---|---|
| 所属层 | InnoDB 引擎层 | Server 层(所有引擎) |
| 内容类型 | 物理日志(页级修改) | 逻辑日志(语句 / 行变更) |
| 写方式 | 循环写,空间固定复用 | 追加写,写满换文件不覆盖 |
| 核心用途 | 崩溃恢复(前滚) | 复制 + 按时间点恢复 |
循环写与追加写的差异最常被追问:redo 敢覆盖是因为旧 redo 对应的脏页必然已刷盘(checkpoint 保证);binlog 是复制与恢复的事实来源,永远不能覆盖。
Internal 2PC · The Core Slide
Proof by Contradiction
| 方案 | 崩溃点 | 恢复结果 | 后果 |
|---|---|---|---|
| 不用 2PC: 先 redo commit,后写 binlog | redo 已提交、binlog 还没写 | 主库恢复后有该事务;binlog 里没有 → 从库不会执行 | 主库有、从库无——主从不一致,且无 binlog 可补 |
| 不用 2PC: 先写 binlog,后 redo commit | binlog 已写、事务没提交 | 主库恢复时回滚该事务;binlog 里却有 → 从库照常执行 | 主库无、从库有——从库凭空多数据,且是"已回滚"的脏事务 |
| 2PC(真实做法) | 任意点 | prepare 事务拿 XID 对账 binlog:有 → 补提交;无 → 回滚 | 主库恢复结果与 binlog 始终一致 → 主从一致、crash-safe |
binlog 是复制与 PITR 的唯一事实来源:binlog 里没有的事务,全世界的从库和备份都不会有。让"是否进入 binlog"决定主库恢复时的生死,主库状态就永远向 binlog 对齐——这是把两份日志焊死的锚点。
redo 的 prepare 记录携带事务的 XID,binlog 事务末尾也写 XID event。恢复时扫 redo 发现 prepare 状态事务 → 拿 XID 查 binlog 是否有完整 event → 有则提交、无则回滚。一组 ID、两份日志、一个裁决。
Group Commit · FLUSH / SYNC / COMMIT
| 阶段 | 做的事 |
|---|---|
| FLUSH | 各事务的 binlog cache 内容写入公共 binlog 文件(write 到 OS cache);组内 leader 带队,后来者挂到队列等下一轮 |
| SYNC | 对整个组做一次 fsync——N 个事务的持久化成本摊成 1 次;可用 binlog_group_commit_sync_delay/_no_delay_count 故意等一小会儿凑更大的组 |
| COMMIT | 组内事务逐一进入 InnoDB commit(写 redo commit 标记、释放锁);binlog_order_commits 保证提交顺序与 binlog 一致 |
三阶段命名出自官方 Worklog WL#5223 与手册 §19.1.6.4(Binary Logging Options and Variables)
完整提交路径是:redo prepare(InnoDB 组提交 fsync)→ binlog FLUSH → SYNC → COMMIT。两段组提交各自合并 fsync:redo 一次、binlog 一次——双 1 配置下高并发 TPS 依然可观的原因就在此。
同一组事务的 XID 在 binlog 中连续落盘,从库并行回放(WRITESET 等)也更容易找依赖组——组提交是"主库省 fsync、从库好并行"的公共地基(见 复制 deck)。
Crash Recovery · §17.18.2
从最新 checkpoint LSN(存在 redo 文件头)开始扫描重放 redo,把数据页恢复到崩溃那一刻的物理状态——注意此时可能包含"已写页但事务未提交"的修改。
恢复过程中发现 redo 处于 prepare 状态的事务:拿 XID 到 binlog 中查是否存在完整 XID event——在 → 补 commit;不在 → 标记回滚。binlog 裁决,主库向 binlog 对齐。
对裁决为"未提交"的事务,沿 undo 的反向操作逐条撤销。回滚可能持续较长时间(大事务),但期间数据库已可用、锁按需释放——先快后准的两段式。
| 崩溃场景 | 恢复动作 |
|---|---|
| 事务已 commit(redo commit 标记完整) | redo 前滚已覆盖 → 保留,不碰 |
| 崩溃点 B:binlog 完整、redo 只有 prepare | XID 对账命中 → 补提交(与从库一致) |
| 崩溃点 A:redo prepare、binlog 没写 | XID 对账不中 → 回滚(主库从库都没有) |
| XA 事务停在 PREPARED(外部 XA) | 恢复后保持 prepared,等待 XA COMMIT / XA ROLLBACK 手动处置(手册 §17.18.2) |
Walkthrough · The Classic
-- UPDATE t SET a=a+1 WHERE id=1; 的执行链 ① 连接器 建立/复用连接 · 鉴权(8.0 已无查询缓存) ② 分析器 词法 + 语法解析 ③ 优化器 选择索引(id 主键 vs 其他) ④ 执行器 调 InnoDB 接口,逐行处理 ⑤ InnoDB:取行 id=1 所在页 → buffer pool (不在则从磁盘读入) ⑥ InnoDB:写 undo 旧值 a=0 存入 update undo ⑦ InnoDB:改页 页内 a=0→1 · 写 redo buffer 该页变脏页 ⑧ 提交 redo prepare + fsync(trx_commit=1) binlog write + fsync(sync_binlog=1) redo commit → 返回客户端 OK ⑨ 异步收尾 dump 线程推 binlog 给从库; page cleaner 择机刷脏页
| 追问 | 答案落点 |
|---|---|
| 页不在内存怎么办? | 从磁盘读入 buffer pool(doublewrite 保护写回) |
| a=1 什么时候"永久"? | 内存页立刻可见(自己事务内);磁盘上要等脏页刷盘——但 redo 已落盘,崩溃也不丢 |
| 为什么先写 undo? | 崩溃后可能要回滚这条 UPDATE;回滚依据必须在修改前就位 |
| 从库什么时候有? | binlog 落盘后 dump 线程推送 → 从库 relay log → 回放(见 复制 deck) |
Cheat Sheet
| redo | 物理日志、InnoDB 层、循环写;崩溃后前滚重做已提交事务 → 保 D(持久性) |
| undo | 逻辑日志、InnoDB 层;回滚未提交事务 + MVCC 版本链 → 保 A(原子性) |
| binlog | 逻辑日志、Server 层、追加写不覆盖;主从复制 + PITR(MyISAM 也有!) |
| 不可替代性 | binlog 无页级信息、无 checkpoint,替不了 redo;redo 循环写会被覆盖、只有 InnoDB 有,替不了 binlog |
innodb_flush_log_at_trx_commit | 1(默认)提交即 write+fsync,不丢|0 每秒写+刷,崩了丢约 1 秒|2 提交只 write 到 OS cache、每秒 fsync,mysqld 崩不丢、主机断电丢约 1 秒 |
sync_binlog | 1(默认)每组提交都 fsync;N = 攒 N 组才 fsync,最多丢 N−1 组 |
| 双 1 | 两个开关都开到最严 —— 真正的 crash-safe 前提(代价:每事务两次 fsync) |
| 两个指针 | write pos 往前写,checkpoint 往前推(之前的脏页已刷盘,空间可覆盖) |
| 写满会怎样 | write pos 追上 checkpoint → 必须先刷脏推进 checkpoint → 期间更新全部阻塞,写入骤降 |
| 8.0.30 起 | innodb_redo_log_capacity(默认 100MB,可在线改)取代旧的 innodb_log_file_size / _in_group |
| 为什么需要 | redo 与 binlog 分属两层、无法原子写;必须让主库恢复结果永远向 binlog 对齐 |
| 时序 | ① redo prepare → ② 写 binlog → ③ redo commit |
| 崩溃点 A | prepare 后、binlog 未写 → binlog 里查无此 XID → 回滚 |
| 崩溃点 B | binlog 已写、commit 未写 → binlog 里有完整 XID event → 补提交 |
| 裁决依据 | 拿 redo 中 prepare 事务的 XID 去 binlog 里对账 |
| ① 前滚 | 从最新 checkpoint LSN 起重放 redo,把库推回崩溃那一刻(含未提交修改) |
| ② 对账 | prepare 事务逐条用 XID 查 binlog,定生(补提交)死(回滚) |
| ③ 回滚 | undo 把未提交事务逐个撤销 |
| 外部 XA | 恢复后仍停在 PREPARED,需人工 XA COMMIT/ROLLBACK |
| 组提交 | 双 1 的两次 fsync 是瓶颈 → 同批事务合并一次 fsync;binlog 走 FLUSH → SYNC → COMMIT 三阶段,redo 侧同理 |
| 凑组参数 | binlog_group_commit_sync_delay(等多少微秒)/ _no_delay_count(凑够多少个就不等) |
| binlog 格式 | 8.0 默认 ROW(确定性强)|STATEMENT 体积小、可能主从不一致|RC 只能用 ROW|binlog_format 8.0.34 起弃用 |
Interview QA · 1/2 · 先自答再对照
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回第 16 页速查表。
提交时刷随机数据页太慢。redo 让修改先以顺序追加方式落盘,脏页延迟刷;崩溃后重放 redo 恢复——用顺序小日志换随机大页写,同时保证持久性。
四维:redo 在 InnoDB 层、binlog 在 Server 层;redo 物理日志、binlog 逻辑日志;redo 循环写、binlog 追加写;redo 管崩溃恢复、binlog 管复制与 PITR。
默认 1:提交即 write+fsync,不丢;0 每秒后台写+fsync,崩了丢约 1 秒;2 提交写到 OS page cache、每秒 fsync——mysqld 崩溃不丢、主机断电丢约 1 秒。
redo 与 binlog 的持久化开关都开到最严:前者保证崩溃后已提交事务能从 redo 恢复;后者保证已提交事务的 binlog 一定在磁盘上、从库一定能拿到。缺一个就有一份日志可能丢。
insert undo 只服务回滚、提交即丢;update undo 还要服务 MVCC 版本链(快照读找旧版本),必须等没有 ReadView 引用后由 purge 清理——丢弃时机完全不同。
默认选 ROW:确定性强、复制安全,代价是体积。STATEMENT 体积小但不确定语句会导致主从不一致。MIXED 自动切换只是折中。binlog_format 设置自 8.0.34 起弃用,官方方向 row-only。
每 N 个事务组才 fsync 一次 binlog,fsync 成本降为 1/N;代价是崩溃时最近一批还没 fsync 的 binlog 丢失(连同对应已提交事务)——在从库上表现为"主库有、从库无"。
write pos 追上 checkpoint,InnoDB 必须先推进 checkpoint——把对应脏页刷盘腾空间,期间所有更新阻塞,系统写入能力骤降。容量调大(innodb_redo_log_capacity)+ 合理 io_capacity 可缓解。
Interview QA · 2/2 · 先自答再对照
redo 与 binlog 分属两层、无法原子写。先 commit redo 再写 binlog:崩溃后主库有、从库无;反序则从库有、主库回滚——都不一致。2PC 以 binlog 完整性(XID event)为裁决,让主库恢复结果永远向 binlog 对齐。
恢复时该事务 redo 停在 prepare,拿 XID 查 binlog:存在完整 XID event → 补提交。与从库视角一致(binlog 里有),所以不破坏主从一致——这正是 2PC 设计的意义。
① 从最新 checkpoint LSN 起 redo 前滚到崩溃时刻(可能含未提交修改);② prepare 事务用 XID 对账 binlog 定生死;③ undo 回滚未提交事务。外部 XA 的 prepared 事务保持原状等人工处置。
双 1 下每事务两次 fsync(redo + binlog)是吞吐瓶颈。binlog 组提交按 FLUSH→SYNC→COMMIT 三阶段把同批事务合并成一次 fsync;redo 侧同样有组提交。参数 binlog_group_commit_sync_delay 可主动凑大组。
binlog 不能替代 redo:binlog 是逻辑日志、无页级信息、也没有 checkpoint 机制支撑"恢复到崩溃瞬间"。redo 不能替代 binlog:redo 循环写会被覆盖、且只有 InnoDB 有,复制与 PITR 需要全引擎、永不覆盖的逻辑日志。
指"崩溃重启后,已提交事务不丢、未提交事务全部回滚",且主库状态与 binlog 一致。实现 = redo 前滚 + undo 回滚 + 两阶段提交对账。InnoDB crash-safe 的前提是双 1 等持久化配置正确。
undo:长事务的 ReadView 钉住 update undo,purge 停滞、undo 表空间膨胀(详见 MVCC deck)。binlog:大事务一次性写入巨型 binlog event,主从传输与回放都卡顿。redo:未提交前其修改页迟迟不能 checkpoint 掉,拖累刷脏节奏。
8.0.30 起用 innodb_redo_log_capacity(默认 100MB,可在线 SET GLOBAL),落在 #innodb_redo 目录约 32 个 #ib_redoN 文件;旧的 innodb_log_file_size / innodb_log_files_in_group 同步弃用——升级脚本要改参数名。
Related & References
LRU 缓存(手撕实现) —— 哈希 + 双链表的 O(1) 结构与手撕
主从复制与高可用 —— binlog 如何流向从库、并行回放、半同步
事务基础 —— ACID 中原子性/持久性由谁保证(undo vs redo)
MVCC 多版本并发控制 —— update undo 版本链、purge 与长事务
InnoDB 锁体系 —— 2PC commit 阶段释放锁的时机衔接
崩溃恢复 = redo 前滚 + XID 对账 + undo 回滚
提交 = redo prepare → binlog fsync → redo commit
双 1 = trx_commit=1 × sync_binlog=1
吞吐 = redo 组提交 × binlog 组提交(FLUSH/SYNC/COMMIT)
主从 = binlog → dump → relay log → 回放
参考来源(本 deck 全部版本敏感结论可溯源至下列一手材料)
| dev.mysql.com/doc/refman/8.0/en/innodb-redo-log.html | §17.6.5 Redo Log:WAL、循环写、checkpoint LSN、innodb_redo_log_capacity(8.0.30 起,默认 100MB)取代 ib_logfile 参数 |
| dev.mysql.com/doc/refman/8.0/en/innodb-undo-logs.html | §17.6.6 Undo Logs:insert/update undo 分类、purge、回滚角色 |
| dev.mysql.com/doc/refman/8.0/en/replication-formats.html | §19.2.1 Replication Formats:8.0 默认 ROW、MIXED 语义、binlog_format 自 8.0.34 弃用 |
| dev.mysql.com/doc/refman/8.0/en/replication-options-binary-log.html | §19.1.6.4:sync_binlog 默认 1、binlog_group_commit_sync_delay/_no_delay_count、组提交逻辑 |
| dev.mysql.com/doc/refman/8.0/en/innodb-recovery.html | §17.18.2 InnoDB Recovery:checkpoint 起点重放、undo 回滚未提交事务、XA PREPARED 处置 |
| dev.mysql.com/worklog/task/?id=5223 | WL#5223 Group Commit of Binary Log:FLUSH / SYNC / COMMIT 三阶段定义 |