三种日志是 InnoDB 的一切承诺(不丢、可回滚、可复制)的物质基础;两阶段提交则是它们之间唯一的粘合剂。

学习目标

读完本章,你应当能够:

  1. 从"顺序写 vs 随机写"与"日志是紧凑表示"两条理由推导出 WAL 的收益,并据此判断 innodb_flush_log_at_trx_commit 三种取值在进程崩溃与机器断电下各自的丢失窗口;
  2. 说清 redo log 为什么是物理逻辑日志,画出 write pos/checkpoint 的环形结构,推导"日志写满"时为什么会导致写入停顿;
  3. 画出组提交(group commit)三个阶段的队列模型,解释 binlog_group_commit_sync_delay 如何用延迟换吞吐,以及它与 sync_binlog/innodb_flush_log_at_trx_commit 的耦合关系;
  4. 区分 insert undo 与 update undo 在提交后能否立即删除,说明 purge 线程、长事务与 undo 表空间膨胀的因果链;
  5. 对两阶段提交的三种崩溃时机逐个给出恢复结果,解释为什么决断依据必须是"redo 是 prepare 状态且 binlog 完整",并按 checkpoint → 重放 redo → 处理 prepare → 用 binlog 决断 → 回滚未提交 的顺序复述崩溃恢复流程,说出 innodb_force_recovery 各级的含义。

前置知识检查

你需要知道在哪一节补齐
一条 UPDATE 在 buffer pool、undo、redo、binlog 之间的完整流转见第 20 章 20.4、20.5 节;本章在其基础上深化"落盘顺序"
表空间、段、区、页(16 KB)与行格式见第 20 章 20.4 节;本章只用到"页号 + 页内偏移"这一层抽象
事务语法与 BEGIN/COMMIT/ROLLBACK 的语义见第 22 章 22.1 节;本章只讲这三条语句在日志层的动作
磁盘顺序读写与随机读写的量级差异见第 13 章 13.4 节;本章用 fio 口径的数字做对比

21.1 WAL:为什么"先写日志再写数据"更快也更安全

21.1.1 两条理由:顺序写与紧凑表示

WAL(Write-Ahead Logging)的规则只有一句:修改数据页之前,必须先把这次修改的日志刷到持久存储。它同时解决两个独立问题。

第一是性能。数据页散落在表空间的各个位置,一次事务可能改 5 个页,这 5 个页在磁盘上的物理位置毫无规律;而 redo log 是追加写(append-only),从头顺到尾。机械盘上一次随机写要等寻道 + 旋转(约 5~10 ms),一次顺序写只需付出传输时间;即使在 SSD 上,随机写 4 KB 约 100 μs,而顺序写带宽可以跑满接口。更关键的是合并:同一页被改 100 次,redo 里就有 100 条记录,而刷脏页只写 1 次磁盘——把"随机写"转成"顺序写 + 批量合并"。

第二是安全。提交时若必须把数据页本身刷盘,一次事务要等待所有涉及页的随机写完成,且部分页写成功、部分失败会留下物理上不一致的页(这也是 innodb_doublewrite 存在的原因之一)。而只要日志落盘,内存里的数据页可以随便延迟写——崩溃后用日志重放即可重建。

还有第三个常被忽略的点:日志是紧凑表示。redo 记录的是"页号 5、偏移 0x40 起 8 字节改成 X"这样的增量,而不是整页 16 KB。一条改单列 INT 的记录通常只有几十字节,比页小两三个数量级。

1
2
3
4
5
6
 不用 WAL(假设):                       使用 WAL(InnoDB 实际做法):
 事务 T 提交时必须把 5 个脏页写回磁盘     提交时只需把 redo 追加到 log file
   5 次随机写,位置毫无规律                 1 次顺序写,几十~几百字节
   page#17 page#3 page#88 page#41 page#9    log file: [rec][rec][rec]...
   ~100 μs 各一次(SSD 口径)               ~一次 fsync 的量级
   最坏:部分页写成功 → 页内结构损坏         脏页可以慢慢刷;崩溃后重放 redo

21.1.2 innodb_flush_log_at_trx_commit 的三种取值与丢失窗口

这个参数的名字常常误导人:它管的是提交时 redo log 如何落盘,而不是"多久刷一次脏页"。

取值提交时动作谁负责落盘进程崩溃(mysqld crash)机器断电 / OS 崩溃典型场景
0不写不刷后台线程每秒刷一次丢最近约 1 s丢最近约 1 s可重建的日志表、批量导入
1(默认)写入并 fsync提交线程自己不丢不丢(假设磁盘缓存诚实)金融、订单等一切不能丢的场景
2写入 OS page cache,每秒 fsyncOS 每秒刷不丢(进程挂了,内核缓冲区还在)丢最近约 1 s允许断电丢 1 s 的高吞吐场景

重要

1 与 2 的差别不在"是否写了文件",而在"是否越过了 OS page cache 这道墙"。进程崩溃时 page cache 由内核持有,数据不会丢;掉电时 page cache 里的脏页就没了。所以"2 比 1 快但掉电会丢 1 秒"必须补上"丢的是 OS 缓存里还没刷的那部分"。2 与 0 的对比同样易错:0 连 write 都不做,进程崩溃也可能丢。

21.1.3 为什么不让日志直接替代数据页

常见追问:“既然 redo 记了所有修改,为什么不只留 redo、不要数据页?“答案是恢复时间必须有上界:checkpoint 不断推进,把"该 LSN 之前的脏页都已刷盘"记下来,崩溃后只需从最近一次 checkpoint 重放。若不刷脏页,checkpoint 永远停在开头,恢复时长随运行时间线性增长。刷脏页不是为了省日志,而是为了让恢复时间有界。

21.2 redo log:物理逻辑日志与环形结构

21.2.1 物理逻辑 的含义

redo log 的记录分两种粒度:物理层记录"哪个表空间的第几号页、页内哪个偏移、改成什么字节”,靠 (space_id, page_no, offset, len, data) 定位;逻辑层面对"插入一行导致页分裂"这类操作,不记录分裂后每个字节的最终形态,而是记录"在某索引上插入主键为 K 的记录”,由重放线程重新执行、得到确定结果。

所以准确说法是物理逻辑日志(physiological logging):页内改动的重放是幂等的、不依赖 B+ 树当前结构,同时又能把页分裂这种逻辑操作表达得足够短。

警告

答"redo log 是物理日志,记录数据页的修改"会被追问:“页分裂怎么记?“坚持纯物理就必须记录分裂后两个页的全部内容,日志膨胀且重放不幂等。正确答案是"页内物理 + 操作逻辑”。

21.2.2 LSN:三个位置记录的同一个数

LSN(Log Sequence Number)是单调递增的字节偏移量,8 字节整数,表示"日志已经写到哪儿了”。它出现在三处,混淆这三处是高频扣分点:

记录位置名称含义(谁写)
每个数据页的页头FIL_PAGE_LSN该页最后一次被修改时对应的 LSN(由修改该页的事务写)
redo log 全局Log Sequence Number当前已写入日志的末尾位置(日志写入线程)
redo log 全局 / 页checkpoint LSN该 LSN 之前的所有脏页都已刷盘(checkpoint 线程)

判断一个数据页是否需要重放,靠的是页头 LSN 与 checkpoint LSN 比较:页的 FIL_PAGE_LSN 小于 checkpoint LSN,说明它的修改早已刷盘,无需重放;反之才需要。这样恢复时不必扫描所有页。

21.2.3 环形结构与"日志写满"的后果

redo log 在 8.0.30 之前是固定大小的循环文件组(innodb_log_file_size × innodb_log_files_in_group),8.0.30 起改为 innodb_redo_log_capacity(默认 100 MB)统一控制,但环形语义不变:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
     innodb_redo_log_capacity(循环使用的总空间)
  ┌──────────────────────────────────────────────────────┐
  │  ┌──────────┐  ← 可被覆盖 →   ┌──────────┐           │
  │  │ 已刷盘区域 │                │ 待刷盘区域 │           │
  │  └──────────┘                └──────────┘           │
  │       ▲                            ▲                 │
  │  checkpoint LSN                write pos             │
  │ (脏页已刷到的位置)        (日志已写到哪)            │
  └──────────────────────────────────────────────────────┘
   checkpoint 与 write pos 之间 = 尚未刷盘的脏页所依赖的日志
   write pos 追上 checkpoint  ⟹ 环形缓冲区写满

写满时发生什么:新事务要写 redo,但 write pos 已追到 checkpoint,环形空间没有空闲。此时 InnoDB 必须强制推进 checkpoint——把一批脏页刷盘(innodb_page_cleaners 与刷脏线程参与),刷完才能腾出日志空间。这期间新写入被阻塞,表现为"刷脏抖动"、写入 RT 尖刺;若刷脏速度跟不上日志产生速度,就是持续停顿。由此得两条工程结论:容量不宜过小(否则刷脏被迫提前,失去"把随机写攒成顺序写"的合并收益),经验口径是能容纳 1~2 小时的写入量;刷脏成为瓶颈时症状是写入周期性卡顿而 CPU 不高,观测点是 SHOW ENGINE INNODB STATUS 里 Log sequence number 与 Last checkpoint at 的差距。

21.2.4 组提交:三个阶段各排一次队

一次提交只要 innodb_flush_log_at_trx_commit = 1,就必须 fsync。而 fsync 是整条链路上最贵的单点操作(机械盘约 510 ms,SSD 约 0.11 ms 量级)。如果每个事务各自 fsync,QPS 会被硬压到每秒几百。组提交(group commit)的做法是把并发事务分成三阶段、每阶段排队,由 leader 代大家做一次真正的刷盘/落盘:

1
2
3
4
5
6
  阶段 1 flush        阶段 2 sync              阶段 3 commit
  ────────────        ────────────             ────────────
  T1..T5 各自写        T1..T5 排成一队            T1..T5 按序写
  redo/binlog 缓存     一次 fsync 覆盖全队        引擎层 commit 标记
                       (leader 执行)            (顺序不能乱,否则
                                                  binlog 顺序与提交顺序不符)

三个阶段必须拆开,是因为每个阶段能合并的对象不同:flush 合并"从用户态缓冲区写进文件",sync 合并 fsync(收益最大),commit 合并"引擎层提交"(本身无 I/O,但不排队就会退化成每个事务各自拿锁,把前面的收益吃掉)。

binlog_group_commit_sync_delay(微秒)与 binlog_group_commit_sync_no_delay_count 是"用延迟换吞吐"的旋钮:sync_delay = 1000 表示 leader 在 sync 阶段故意等最多 1 ms,把这段时间到达的事务攒成更大一批,一次 fsync 覆盖更多事务;no_delay_count 规定"攒够 N 个就不再等"。代价是每个事务至少多等这段时间:它换来的是吞吐与 P99 稳定性,牺牲的是平均延迟——高并发批量场景收益明显,低并发时纯粹自伤。

提示

面试话术:“组提交不优化 fsync 本身,而是把 N 个事务的 N 次 fsync 摊薄成 1 次,所以吞吐随并发度上升,而单事务延迟不降反升。“这句话能区分"背过概念"和"调过参数”。

21.2.5 innodb_flush_method 与 O_DIRECT

innodb_flush_method 决定数据文件与日志文件的打开方式,核心分歧是要不要绕过 OS page cache:默认 fsync 走 buffered I/O,同一页在 buffer pool 与 page cache 各存一份,但保留内核的写合并与预读;O_DIRECT 绕过 page cache 直接写盘,省内存但失去预读与写合并;O_DIRECT_NO_FSYNC 在 O_DIRECT 基础上不再为元数据额外 fsync,只在文件系统(如 xfs)已保证元数据时可用。

关键点:O_DIRECT 不会让 redo 的 fsync 语义消失。它只改变脏页回写路径,提交时的持久化仍由 innodb_flush_log_at_trx_commit 决定。它要求按扇区对齐(512 B 或 4 KB),未对齐的 I/O 会被内核退回 buffered 路径。

21.3 undo log:逻辑日志与两个作用

21.3.1 记录的是反向操作

undo log 是逻辑日志:不记录"页 5 的偏移 0x40 原来是 X”,而记录"把插入的行删掉"或"把 age 从 24 改回 23"这样的反向操作。回滚时由引擎重新执行这些反向操作,而不是把字节倒回去。原因是同一个逻辑修改可能落在不同的物理位置(页分裂、记录迁移),只有逻辑层才能保证回滚到正确的语义状态。

undo 有且只有两个作用:

  1. 事务回滚:显式 ROLLBACK,以及崩溃恢复时对未提交事务的回滚;
  2. MVCC 快照读:为一致性读提供历史版本,版本链的构建与可见性判断细节见第 22 章 22.3 节。

小心

答"undo log 用来回滚"只对一半,会被追问"还有别的作用吗"。漏掉"给 MVCC 提供历史版本"是本章最典型的丢分点,而这个作用恰恰解释了后面所有关于"长事务为什么危险"的结论。

21.3.2 insert undo 与 update undo:提交后能否立即删除

undo 页的回收规则取决于提交后还有没有人需要这个版本。

类型对应操作提交后其他人的需要提交后能否立即删除
insert undoINSERT无:其他事务看不到未提交的插入,提交后更不需要旧版本可以,提交即回收
update undoUPDATE / DELETE有:活跃的 ReadView 可能仍要读旧版本不可以,必须等 purge

update undo 的回收由 purge 线程完成。它判断"这个版本还有没有人需要"的依据是最老的活跃 ReadView:如果一个 undo 记录对应的 trx_id 已经不早于任何活跃事务可见需要的时间点,它就可以被清理。innodb_purge_threads(默认 4)控制 purge 线程数,undo 表空间由 innodb_undo_tablespaces 控制(8.0 默认 2 个,可增长)。

长事务的危害链在这里闭环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 长事务长期不提交
      │  该事务的 ReadView 一直活跃
      ▼
 purge 无法越过"最老活跃 ReadView"推进 → 历史版本一个都不能删
      │
      ▼
 undo 表空间持续膨胀 + 版本链越来越长 → 每次快照读都要沿链回溯比较 trx_id
      │
      ▼
 查询变慢、undo 页 I/O 变多、purge 追不上 → 回滚段耗尽风险

21.3.3 回滚与崩溃恢复中的 undo

崩溃恢复的重放阶段会把所有已落盘的 redo 都重新应用,包括尚未提交、甚至刚写入 prepare 的事务。所以重放结束后,库里必然存在一批"物理上已改、逻辑上不该存在"的事务,必须靠 undo 把它们回滚掉:redo 让数据前进到崩溃前一刻,undo 再让未提交的事务后退。这也解释了为什么 undo 必须在数据修改之前写入——若 undo 后写而崩溃在中间,恢复时既不知道原值是什么、也无法回滚。

21.4 binlog:Server 层的逻辑日志

21.4.1 与存储引擎无关

binlog 由 Server 层写入,记录"逻辑上发生了什么",与用哪个引擎无关。它有三个用途:主从复制(从库重放,机制见第 26 章 26.2 节)、归档、按时间点恢复(point-in-time recovery, PITR)——先用全量备份恢复,再重放备份点之后的 binlog。三种格式的对比是必考项(binlog_format 是会话级变量,可运行时切换,但链路上每一段——主库、从库 SQL 线程、级联从库——都必须能正确处理收到的格式,所以生产上是"统一用 ROW"的运维纪律):

维度STATEMENTROWMIXED
记录内容原始 SQL 语句每行修改前像/后像默认 STATEMENT,遇不确定函数自动切 ROW
日志量最小(一条 SQL 可能改百万行)最大(每行都要记)介于两者之间
可读性高,直接看懂 SQL低,需 mysqlbinlog -v 解码混合
NOW()/UUID()/RAND()会不一致(从库执行时刻/内容与主库不同)安全(值已固化在行像里)通常安全,但判定本身不完美
LIMIT 无 ORDER BY会不一致(两库返回行序可能不同)安全通常安全
数据恢复能力弱(无法精确还原行)强(可反向生成补偿 SQL)中
主库额外开销需为安全重放加更多锁行像序列化开销 + 日志量带来的 I/O中

为什么互联网公司普遍选择 ROW:不是因为它"更先进",而是因为它在语义上排除了非确定性——从库不再"重新执行逻辑",而是"照着行像改",因此主从数据在行级别强一致。代价是日志量大,DELETE/UPDATE 大范围操作时可能比 STATEMENT 大几个数量级,需要用 binlog_row_image = MINIMAL 压缩:只记录主键(前像)与实际改变的列(后像)。

21.4.2 写入时机与 sync_binlog

binlog 的写入分两步,都发生在事务提交阶段:事务执行期间,SQL 语句(或行像)先写进每个会话私有的 binlog cache(超出 binlog_cache_size 会溢出到临时文件);提交时把 cache 内容 write 进 binlog 文件,再按 sync_binlog 决定是否 fsync。

sync_binlog提交时动作掉电丢失窗口说明
0只 write,不 fsync由 OS 决定(可能丢多个事务)吞吐最高,风险最高
1每次提交 fsync不丢双一配置的一半,最安全
N(>1)累积 N 个事务后 fsync最多丢 N−1 个事务折中;末尾可能不完整,靠 binlog_checksum 检测

双一配置指 sync_binlog = 1 且 innodb_flush_log_at_trx_commit = 1,即"每个事务至少两次 fsync(binlog 一次、redo 一次)"。这两次 fsync 不丢,靠的正是组提交把并发事务合并——sync_binlog = 1 并不意味着"一次提交一次 fsync",而是"每个提交批次至少一次 fsync"。

binlog 的滚动由 max_binlog_size(默认 1 GB)触发,但单个事务超过该值时不会在事务中间切开(切开会让从库读到半个事务),所以实际文件可能超出。过期删除由 binlog_expire_logs_seconds(8.0.11 起,默认 2592000 秒即 30 天)控制,早期版本是 expire_logs_days。手工清理用 PURGE BINARY LOGS TO 'mysql-bin.000123',但从库还没消费到该位置就不能删,否则复制直接中断。

21.5 两阶段提交:崩溃点分析

21.5.1 时序图

两阶段提交(two-phase commit, 2PC)在这里不是分布式事务协议,而是 InnoDB 与 Server 层 binlog 之间的内部一致性协议:redo 与 binlog 分属两个写入者,没法放进同一个原子操作,一旦中间崩溃两者就会各说各话。注意要点:redo 先落盘、binlog 后落盘,顺序不能反;这也是为什么参数动词是 flush_log_at_trx_commit——它管的是 redo,而不是 binlog。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 事务执行              【阶段一 prepare】              【阶段二 commit】
 ─────────              ──────────────────             ──────────────────
 改 buffer pool  ──▶  redo 写盘(带 prepare 标记)──▶
 写 undo         ──▶  事务进入 prepare 状态      ──▶  binlog 写盘(write + fsync)
                                                         │
                                                         ▼
                                                     redo 写 commit 标记
                                                         │
                                                         ▼
                                                     引擎层提交、锁释放、事务可见

21.5.2 三种崩溃时机各自恢复成什么

崩溃点分析要盯住一个不变式:binlog 一旦完整落盘,就一定会被从库消费。所以恢复的目标是让主库与从库对齐,而不是让主库"尽量保留"。

时机 ①:写完 redo prepare 之后、写 binlog 之前崩溃

1
2
3
 redo 有 prepare、无 commit;binlog 中没有该事务
 恢复判断:prepare + binlog 不存在 → 回滚
 主库被回滚、数据不存在;从库没有收到它 ⟹ 一致(都"没做")

时机 ②:写完 binlog 之后、写 redo commit 之前崩溃

1
2
3
 redo 有 prepare、无 commit;binlog 中该事务完整
 恢复判断:prepare + binlog 完整 → 提交
 主库被提交、数据存在;从库重放后数据存在 ⟹ 一致(都"做了")

时机 ③:binlog 写了一半(或校验失败)崩溃

1
2
3
 redo prepare;binlog 末尾存在截断或校验不通过的 event
 恢复判断:binlog 不算"完整" → 回滚,尾部截断到最后一个完整 event
 从库只重放了已收到的完整 event ⟹ 一致(都"没做")
崩溃时机redo 状态binlog 状态恢复动作主库最终从库最终
① prepare 后、binlog 前prepare无该事务回滚无无
② binlog 后、commit 前prepare完整提交有有
③ binlog 写到一半prepare截断/校验失败回滚 + 截断尾部无无

21.5.3 为什么决断依据必须是"binlog 是否完整"

因为binlog 是"对外已经生效"的凭据——从库只认 binlog。恢复时主库只能"跟着 binlog 走":binlog 说做了,主库就必须做(否则主库比从库少数据,后续复制会报错或静默分叉);binlog 说没做,主库就必须回滚。用 redo 的状态决断是不行的:时机 ② 里 redo 只有 prepare,若按"没 commit 就回滚"处理,主库回滚而 binlog 已发给从库,从库执行了——主从立刻不一致。

binlog_checksum(5.6.2 起支持,5.6.6+ 默认 CRC32)是"完整性"落在字节上的实现:每个 event 尾部带 4 字节校验和,写入中断会留下校验失败的尾部事件,恢复据此认定 binlog 不完整、选择回滚并截断。注意它只保证单个 event 的字节完整性,不保证事务边界完整——后者靠 event 组末尾的 XID 事件是否存在。

21.5.4 不做 2PC 会怎样

做法崩溃/失败后的主库从库后果
先写 binlog,再写 redo commit(无 2PC)redo 未提交 → 主库回滚binlog 已发 → 从库执行从库多数据,且无法用主库数据修复
先写 redo commit,再写 binlog(无 2PC)已提交,数据存在binlog 没发 → 从库缺数据从库少数据,主从数据分叉且静默
两者都写但不管顺序(并行)随机随机灾难性:备份(PITR 靠 binlog)与数据可能互相矛盾

第一种更危险:它破坏了"从库可以被主库重建"这条底线;第二种虽也错,但至少能用主库重做从库。2PC 的代价是每个事务多一次 fsync 与一次状态写,收益是"binlog 与数据文件永远可以互相推导"。

21.6 redo log 与 binlog 的分工

维度redo logbinlog
层次InnoDB 存储引擎层MySQL Server 层(与引擎无关)
内容物理逻辑日志:页号 + 页内偏移 + 修改(含逻辑操作)逻辑日志:SQL 语句或行前像/后像
格式二进制、循环覆盖,不可人工解析二进制 + event 头,可用 mysqlbinlog 解析
写入时机事务执行中持续写,提交时按 innodb_flush_log_at_trx_commit 刷盘事务执行中写 binlog cache,提交时写文件
是否循环是,环形结构,空间固定否,追加增长,按大小滚动、按时间过期
唯一用途崩溃恢复(crash recovery)复制、归档、PITR
谁需要它只服务本实例服务本实例 + 所有从库 + 备份体系
丢失后果已提交事务丢失(违反 D)从库数据落后、备份无法补齐

重要

一句话记住分工:redo 让本机"不丢",binlog 让别人"跟上"。所以两者都必须在提交前落盘,且顺序必须是"先 redo prepare、再 binlog、最后 redo commit"。

21.7 崩溃恢复的完整流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 ① 找最近一次 checkpoint LSN(SHOW ENGINE INNODB STATUS 或 redo 文件头)
 ② 从 checkpoint 起前向重放 redo(redo apply / 前滚)
      ├─ 比对页头 FIL_PAGE_LSN,跳过"修改已刷盘"的页
      ├─ 重放范围包含未提交事务、含 prepare 事务的修改
      └─ 结束后:数据页 = 崩溃前最后一刻的物理状态
 ③ 扫描事务状态,挑出仍处于 prepare 的事务
 ④ 用 binlog 决断:完整 → 补写 redo commit 标记并提交;不完整/不存在 → 回滚
 ⑤ 回滚其余未提交事务(靠 undo 反向操作)
 ⑥ binlog 尾部若校验失败 → 截断到最后一个完整 event 组
 ⑦ 开始接受连接;undo 的后续清理交给 purge 线程

第 ③④ 步的实现细节值得记住:InnoDB 在 prepare 阶段会把事务的 XID 一并写进 redo,恢复时用这个 XID 去 binlog 里查找对应的事件组。找到且完整 → 提交;找不到或尾部不完整 → 回滚。这就是"用 binlog 决断"在代码层面的落地方式。

21.7.1 innodb_force_recovery 的分级

当 redo 或数据页本身损坏(重放到坏页导致启动失败)时,需要人工降级启动:

级别含义风险
0默认,正常启动并做完整恢复无
1SRV_FORCE_IGNORE_CORRUPT:忽略损坏页继续启动,可 SELECT/mysqldump 抢救查询到损坏页会报错
2不运行后台刷脏与 checkpoint日志空间很快写满
3不对未提交事务做回滚数据含未提交内容,逻辑不一致
4不执行 insert buffer 合并(实例近似只读)索引可能不完整,需 REBUILD
5不检查 undo log 一致性可能读到垃圾版本
6不重放 redo(跳过前滚)数据缺失且无法确定缺多少

警告

innodb_force_recovery ≥ 4 时实例实质上是只读的(官方限制),只能用于把数据导出来重建,绝不能当成"修好了"继续跑业务。级别越高越"能启动",也越高概率拿到一份逻辑上不成立的数据。

本章小结

关键结论一句话表述常见错误
WAL 的两条收益顺序写 + 批量合并解决性能,日志落盘即安全解决持久性只答"日志快",说不出随机写与合并
刷脏页的真正目的让 checkpoint 前进,使崩溃恢复的重放长度有界答"刷脏页是为了省内存"
flush_log_at_trx_commit1 不丢;0 连 write 都不做(进程崩溃也丢);2 进程崩溃不丢、掉电丢约 1 s把 0 与 2 混为一谈,或说 2 一定会丢
LSN 的三处记录页头 FIL_PAGE_LSN、全局写入位置、checkpoint LSN只答"LSN 是日志序号",说不出页头与 checkpoint 的用途
日志写满write pos 追上 checkpoint → 强制刷脏、期间写入停顿答"日志写满就报错退出"或"自动扩容"
组提交flush/sync/commit 三阶段各排队,用一次 fsync 覆盖一批事务答"组提交让单个事务变快"
insert vs update undoinsert undo 提交即回收;update undo 必须等 purge答"undo 提交后都立即删除"
binlog 格式选择ROW 用确定性换日志量;MINIMAL 是压缩手段答"Statement 更省所以更好"
2PC 的决断依据prepare + binlog 完整 → 提交;否则回滚用"redo 有没有 commit"来决断
崩溃恢复顺序checkpoint → 重放 redo → 处理 prepare → binlog 决断 → undo 回滚把回滚放在重放之前

自测题

Q1(推导)一台机器上的 MySQL 配置为 innodb_flush_log_at_trx_commit = 2、sync_binlog = 1。请分别说明:mysqld 进程被 kill -9、操作系统 panic、机房整体掉电三种情况下,一个刚刚 COMMIT 返回成功的事务会不会丢,为什么。

答案与解析

分两种情况看两个日志各自的状态:

(1) mysqld 进程被 kill -9:不丢。2 表示提交时 redo 已用 write 交给内核,数据在 OS page cache 里,进程死亡不会带走 page cache;sync_binlog = 1 更是在提交时就完成了 fsync。两者都在,事务不丢。

(2) 操作系统 panic:仍不丢。panic 时内核会尝试把脏页(含文件系统元数据)交给存储,但这是"通常能成功、不能保证"的动作;同时 sync_binlog = 1 保证 binlog 已在盘上,所以准确表述是"大概率不丢,且即使 redo 丢了,恢复时也能靠完整 binlog 把该事务提交回来"——这正是 2PC 的价值。

(3) 机房整体掉电:会丢。掉电后 OS page cache 中的脏页全部消失,2 的语义就是"每秒刷一次、掉电最多丢约 1 s",最近约 1 s 内提交的事务其 redo 已丢。但是否彻底丢取决于 binlog:binlog 因 sync_binlog = 1 还在,恢复时该事务处于 prepare 且 binlog 完整,于是被提交——redo 丢了但 binlog 在,事务被救回。所以真正会丢的是"binlog 也还没 fsync 的那批",在此配置下理论上是零。

错在哪:把所有崩溃类型混成一句"2 会丢 1 秒"。这题考的是你是否区分进程崩溃与掉电,以及是否知道两个日志的持久化是独立的、可以互相补救。追问方向:若把 sync_binlog 也改成 0,掉电时 redo 与 binlog 同时处于"未刷"状态,最近约 1 s 的事务彻底消失。

Q2(判断)有同事主张:"innodb_redo_log_capacity 设大一点没有坏处,反正日志是循环使用的,占的磁盘也不会变多。“请判断并说明调大与调小的各自代价,以及"日志写满"时的具体行为。

答案与解析

判断:部分正确但结论错。容量确实只占用固定磁盘(循环复用),但它不是"越大越好”,它影响刷脏节奏与崩溃恢复的重放长度。

调大的收益:日志不易写满,checkpoint 可以推进得更慢,刷脏更从容,能把随机写攒成顺序写,写入尖刺变少。代价:崩溃后从 checkpoint 起的重放范围更大(恢复更慢)、固定占盘更多。调小的利弊正好相反。

“日志写满"的确切行为:write pos 追上 checkpoint 时环形缓冲没有空闲,InnoDB 必须推进 checkpoint——把一批脏页刷盘以腾出可复用的日志空间,期间需要新日志空间的事务被阻塞等待。刷脏长期跟不上日志生成速度就是持续停顿(不是报错,是等待)。经验口径是让容量容纳 1~2 小时的写入量。

错在哪:把"日志循环"理解成"容量与性能无关”,忽略"写满 → 强制刷脏 → 写入停顿"这条链;或者答"日志满了会自动扩容"——8.0.30 起的 innodb_redo_log_capacity 也是预留空间,不会按需增长。追问方向:看 Log sequence number 与 Last checkpoint at 的差值是否长期贴近 capacity,且写入 RT 呈周期性尖刺而 CPU 不高。

Q3(分析)一次事务提交过程中,实例在写完 redo 的 prepare 标记、写完完整的 binlog 事务组、还没来得及写 redo commit 标记时掉电。请说明重启后该事务的最终状态、从库的状态,并解释为什么不能用"redo 里没有 commit 标记"作为回滚依据。

答案与解析

主库最终状态:提交。恢复从 checkpoint 重放 redo,把这个事务的修改全部应用(含 prepare 阶段已落盘的内容),此时事务处于 prepare;恢复逻辑检测到它处于 prepare,就按 XID 去 binlog 查对应事件组,发现完整存在,于是补写 commit 标记完成提交。

从库状态:也会执行。binlog 已完整落盘,从库(或级联从库)正常收到并重放它。两边都"做了",一致。

为什么不能用"redo 里没有 commit 标记"回滚:从库的唯一依据是 binlog,它不管主库 redo 长什么样。若主库因"没有 commit 标记"回滚,主库没有这个事务而从库已经有——主从立刻不一致,且是静默的(复制线程不报错,因为从库执行的是合法事务)。所以决断必须站在"binlog 是否完整"这一侧。

错在哪:把 2PC 理解成"只看 redo 的两阶段",忽略决断权在 binlog。典型错误是"没有 commit 标记就回滚,保证一致性"——这说法在单机无复制语境下听着对,一有从库就制造不一致。追问方向:若 binlog 写了一半呢?binlog 不完整 → 回滚并把尾部截断到最后一个完整事件组,从库从未收到半截事务,仍一致;第二层追问是 binlog_checksum 提供了单 event 的字节完整性判据。

Q4(设计)线上一个报表任务在事务里跑了 40 分钟才提交,期间磁盘上 undo 目录持续增长,并且其他会话的快照读逐渐变慢。请解释这条因果链,并给出至少三条处置措施(含具体参数或 SQL)。

答案与解析

因果链:长事务持有自己的 ReadView 且长期活跃 → purge 无法越过"最老活跃 ReadView"推进 → 所有 update undo 版本都不能回收 → undo 表空间持续膨胀、版本链越来越长 → 其他会话快照读要沿链回溯比较 trx_id,CPU 与 undo 页 I/O 上升(见第 22 章 22.3 节)→ 表现为"查询变慢",并有回滚段耗尽风险。

1
2
3
4
5
-- 找出运行时间最长且未提交的事务:元凶是"最老"的那个,不是"改行最多"的那个
SELECT trx_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_rows_modified, trx_state
FROM information_schema.INNODB_TRX ORDER BY trx_started ASC LIMIT 10;
-- History list length 长期只增不减,就说明 purge 被最老的活跃 ReadView 钉住了
SHOW ENGINE INNODB STATUS\G

措施一:缩短事务。改成"分批小事务 + 走只读从库",或报表读用一致性快照 START TRANSACTION WITH CONSISTENT SNAPSHOT 读完立即提交——这是根因修复。

措施二:给长事务设闸门。应用侧设事务超时,配合 max_execution_time 与中间件超时,对 History list length 与最长事务时长告警。

措施三:清理与回收。先 KILL 最老的那个事务让 purge 得以推进,观察 History list length 是否回落;undo 涨得过大时用 innodb_undo_log_truncate = ON 自动截断(需 purge 完成),必要时按官方流程重建 undo 表空间。

错在哪:只答"undo 表空间会自动清理,等一会儿就好",说不出"purge 被最老活跃 ReadView 钉住"这个根因;或者把问题归因到"磁盘满了"。追问方向:为什么"提交后 undo 也不能立刻删"?因为 update undo 要给 MVCC 用,必须等 purge 判定没有活跃 ReadView 需要它——这条回答把 21.3 与 22.3 串起来了。

Q5(设计)你的团队要在 MySQL 8.0 上扛住每秒上万次的单行 INSERT,且不能丢已提交数据。请给出从参数到应用的完整优化清单,说明每一项为什么有效,以及它的代价。

答案与解析

目标是"不丢 + 高吞吐",所以两个 fsync 都不能取消,只能摊薄和减少。

参数建议值为什么有效代价
innodb_flush_log_at_trx_commit1D 要求不丢,必须保持每提交批次一次 fsync(靠组提交摊薄)
sync_binlog1binlog 是复制与 PITR 的凭据,不能丢又一次 fsync(同样靠组提交摊薄)
binlog_group_commit_sync_delay / ..._no_delay_count数百微秒到 1 ms / 几百故意等待攒批,一次 fsync 覆盖更多事务,同时给延迟设上界单事务平均延迟上升,低并发时纯粹变慢
binlog_format + binlog_row_imageROW + MINIMAL减少 binlog 字节量 → 减少写盘与网络传输MINIMAL 下部分行像回滚工具需额外配置才能还原完整行
innodb_redo_log_capacity能容纳 1~2 小时写入量降低"日志写满 → 强制刷脏 → 写入停顿"的频次恢复时间变长、固定占盘更多
innodb_flush_methodO_DIRECT避免同一页在 buffer pool 与 page cache 双份缓存失去内核写合并与预读,需扇区对齐
innodb_io_capacity / ..._max按设备真实 IOPS 设刷脏速度与设备匹配,避免日志写满时刷不动设太低拖慢 checkpoint,设太高挤占正常 I/O

应用层:用批量插入把 N 次提交合并成 1 次(INSERT ... VALUES (...),(...),(...) 或 rewriteBatchedStatements=true),这是收益最大的一项——fsync 次数从 N 降到 1,而参数层只是把 N 次摊薄成若干次;同时提高并发连接数,让组提交有"批"可攒(单线程串行提交时组提交只能攒到 1,所有延迟优化都无效)。

错在哪:一上来就答"把 flush_log_at_trx_commit 改成 0、sync_binlog 改成 0"——这直接违反题目的"不能丢数据"约束;另一类错误是只在参数上打转,忽略了"组提交需要并发才有效"这一前提,于是把参数调完发现单连接压测毫无提升。追问方向:为什么单连接压测看不到组提交的收益?因为组提交攒的是并发到达的事务,队列里永远只有 1 个事务时,三阶段排队退化成串行,每次提交仍然各自 fsync。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
为什么数据库要先写日志再写数据?直接写数据不行吗?是否理解 WAL 的两条独立理由错误回答:“因为日志小”——只说了一半,且说不出 5 个脏页 5 次随机写 vs 1 次顺序写、以及部分页写成功造成的物理不一致;加分:点出"日志落盘后脏页可以随便延迟刷"才是 D 的来源21.1.1
innodb_flush_log_at_trx_commit 设成 0、1、2 分别会丢多少数据?崩溃类型与持久化层次错误回答:“0 和 2 都丢 1 秒,没区别”——说不出 0 连 write 都不做(进程崩溃也丢),而 2 进了 OS page cache(进程崩溃不丢、掉电丢);加分:说明 redo 与 binlog 的持久化互相独立、可以互相补救21.1.2
redo log 到底是物理日志还是逻辑日志?是否知道 physiological logging错误回答:“物理日志,记录数据页的修改”——被追问"页分裂怎么记"就答不上;加分:说清"页内物理 + 操作逻辑",并解释为什么必须这样(重放幂等 + 日志短)21.2.1
LSN 有什么用?它在哪些地方出现?对页、日志、checkpoint 三者关系的掌握错误回答:“LSN 就是日志的序号”——说不出页头 FIL_PAGE_LSN 用于判断该页是否已被刷盘、checkpoint LSN 用于界定重放起点;加分:画出 write pos/checkpoint 环形图21.2.2、21.2.3
redo log 写满了会发生什么?对 checkpoint 与刷脏耦合的理解错误回答:“报错停止写入"或"自动扩容”——真实行为是推进 checkpoint、强制刷脏,期间新写入阻塞等待;加分:给出"日志容量应容纳 1~2 小时写入量"与卡顿的观测方法21.2.3
组提交是怎么提升性能的?为什么三个阶段要分开?是否理解 flush/sync/commit 各自能合并什么错误回答:“组提交让一个事务变快”——恰恰相反,单事务延迟不降反升;加分:指出 commit 阶段若不排队会让前两阶段的收益被吃掉,并解释 binlog_group_commit_sync_delay 是拿延迟换吞吐21.2.4
undo log 有什么作用?是否漏掉 MVCC 这一半错误回答:“用来回滚事务”——只答一半;加分:补上"为 MVCC 快照读提供历史版本",并由此推出长事务导致 undo 膨胀与版本链变长的因果链21.3.1、21.3.2
为什么 insert undo 提交后能立刻删,update undo 不行?对可见性与 purge 判定的理解错误回答:“因为 update 的 undo 更大”——与大小无关,区别在于活跃 ReadView 是否可能还需要旧版本;加分:说出 purge 以"最老活跃 ReadView"为推进依据21.3.2
binlog 的三种格式怎么选?为什么互联网公司都用 ROW?主从一致性与日志量的权衡错误回答:“Statement 日志量小所以更好”——说不出 NOW()/UUID()/RAND()/无 ORDER BY 的 LIMIT 在从库重放会不一致;加分:提到 binlog_row_image = MINIMAL 作为日志量压缩手段21.4.1
两阶段提交过程中崩溃,MySQL 怎么知道该提交还是回滚?本章核心难点错误回答:“看 redo 有没有 commit 标记,没有就回滚”——在 binlog 已完整落盘 的时机里这么做会让主从静默不一致;加分:说清决断依据是 prepare 状态 + binlog 完整性,以及 binlog_checksum 提供的校验判据21.5.2、21.5.3
为什么必须做两阶段提交?不做会怎样?能否给出两种不一致的具体形态错误回答:“为了保证原子性”——太笼统;加分:分别说明"先 binlog 后 commit → 从库多数据"与"先 commit 后 binlog → 从库少数据",并指出前者更危险(破坏"从库可由主库重建"的底线)21.5.4
崩溃恢复的完整流程是什么?步骤顺序与 2PC 的衔接错误回答:把"回滚未提交事务"放在重放 redo 之前,或漏掉"处理 prepare 事务"这一步;加分:按 checkpoint → 前滚 → 处理 prepare → binlog 决断 → undo 回滚的顺序复述,并说明第 ③④ 步靠 prepare 阶段写入 redo 的 XID 去 binlog 查事件组21.7
innodb_force_recovery 设成 6 是不是就能修好数据?对"能启动"与"数据正确"的区分错误回答:“级别越高恢复得越彻底”——恰恰相反,级别越高丢的数据越多(6 直接跳过 redo 前滚);加分:说明 ≥4 时实例近似只读,只能用于导出重建21.7.1

延伸阅读

  • MySQL 官方手册 “InnoDB On-Disk Structures → Redo Log” 与 “Undo Logs”:innodb_redo_log_capacity 与 innodb_undo_tablespaces 的行为口径、8.0.30 前后的参数差异以它为准。
  • MySQL 官方手册 “Server Administration → The Binary Log”:binlog_format、binlog_row_image、sync_binlog、binlog_expire_logs_seconds 的定义与默认值来源。
  • MySQL 官方手册 “InnoDB Startup Options and System Variables” 中 innodb_flush_log_at_trx_commit、innodb_flush_method、innodb_force_recovery 三节:本章 21.1.2、21.2.5、21.7.1 节的参数语义依据。
  • MySQL 官方手册 “MySQL Server Logs → Binary Log → Binary Logging Formats”:三种格式差异与"不确定函数"清单的规范出处。
  • MySQL 官方博客 “MySQL 8.0.30: New redo log capacity” 与 “Binary Log Group Commit”:环形容量与组提交三阶段队列的第一手说明。
  • MySQL 官方手册 “InnoDB Recovery” 与 innodb_force_recovery 的分级表:各级允许的操作与限制。
  • man 2 fsync、man 2 open:O_DIRECT 的对齐要求与 fsync 的持久化语义,21.2.5 节讨论的基础。
  • 第 20 章 20.4、20.5 节(页结构与一条 UPDATE 的生命周期)、第 22 章 22.3 节(版本链与可见性判断)、第 26 章 26.2 节(binlog 如何被从库消费):本章的前置与后继。