第 21 章 Redo Log、undo Log 与 Binlog
三种日志是 InnoDB 的一切承诺(不丢、可回滚、可复制)的物质基础;两阶段提交则是它们之间唯一的粘合剂。
学习目标
读完本章,你应当能够:
- 从"顺序写 vs 随机写"与"日志是紧凑表示"两条理由推导出 WAL 的收益,并据此判断
innodb_flush_log_at_trx_commit三种取值在进程崩溃与机器断电下各自的丢失窗口; - 说清 redo log 为什么是物理逻辑日志,画出
write pos/checkpoint的环形结构,推导"日志写满"时为什么会导致写入停顿; - 画出组提交(group commit)三个阶段的队列模型,解释
binlog_group_commit_sync_delay如何用延迟换吞吐,以及它与sync_binlog/innodb_flush_log_at_trx_commit的耦合关系; - 区分 insert undo 与 update undo 在提交后能否立即删除,说明 purge 线程、长事务与 undo 表空间膨胀的因果链;
- 对两阶段提交的三种崩溃时机逐个给出恢复结果,解释为什么决断依据必须是"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 的记录通常只有几十字节,比页小两三个数量级。
| |
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,每秒 fsync | OS 每秒刷 | 不丢(进程挂了,内核缓冲区还在) | 丢最近约 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)统一控制,但环形语义不变:
| |
写满时发生什么:新事务要写 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 代大家做一次真正的刷盘/落盘:
| |
三个阶段必须拆开,是因为每个阶段能合并的对象不同: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 有且只有两个作用:
- 事务回滚:显式
ROLLBACK,以及崩溃恢复时对未提交事务的回滚; - MVCC 快照读:为一致性读提供历史版本,版本链的构建与可见性判断细节见第 22 章 22.3 节。
小心
答"undo log 用来回滚"只对一半,会被追问"还有别的作用吗"。漏掉"给 MVCC 提供历史版本"是本章最典型的丢分点,而这个作用恰恰解释了后面所有关于"长事务为什么危险"的结论。
21.3.2 insert undo 与 update undo:提交后能否立即删除
undo 页的回收规则取决于提交后还有没有人需要这个版本。
| 类型 | 对应操作 | 提交后其他人的需要 | 提交后能否立即删除 |
|---|---|---|---|
| insert undo | INSERT | 无:其他事务看不到未提交的插入,提交后更不需要旧版本 | 可以,提交即回收 |
| update undo | UPDATE / DELETE | 有:活跃的 ReadView 可能仍要读旧版本 | 不可以,必须等 purge |
update undo 的回收由 purge 线程完成。它判断"这个版本还有没有人需要"的依据是最老的活跃 ReadView:如果一个 undo 记录对应的 trx_id 已经不早于任何活跃事务可见需要的时间点,它就可以被清理。innodb_purge_threads(默认 4)控制 purge 线程数,undo 表空间由 innodb_undo_tablespaces 控制(8.0 默认 2 个,可增长)。
长事务的危害链在这里闭环:
| |
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"的运维纪律):
| 维度 | STATEMENT | ROW | MIXED |
|---|---|---|---|
| 记录内容 | 原始 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。
| |
21.5.2 三种崩溃时机各自恢复成什么
崩溃点分析要盯住一个不变式:binlog 一旦完整落盘,就一定会被从库消费。所以恢复的目标是让主库与从库对齐,而不是让主库"尽量保留"。
时机 ①:写完 redo prepare 之后、写 binlog 之前崩溃
| |
时机 ②:写完 binlog 之后、写 redo commit 之前崩溃
| |
时机 ③:binlog 写了一半(或校验失败)崩溃
| |
| 崩溃时机 | 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 log | binlog |
|---|---|---|
| 层次 | 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 崩溃恢复的完整流程
| |
第 ③④ 步的实现细节值得记住:InnoDB 在 prepare 阶段会把事务的 XID 一并写进 redo,恢复时用这个 XID 去 binlog 里查找对应的事件组。找到且完整 → 提交;找不到或尾部不完整 → 回滚。这就是"用 binlog 决断"在代码层面的落地方式。
21.7.1 innodb_force_recovery 的分级
当 redo 或数据页本身损坏(重放到坏页导致启动失败)时,需要人工降级启动:
| 级别 | 含义 | 风险 |
|---|---|---|
0 | 默认,正常启动并做完整恢复 | 无 |
1 | SRV_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_commit | 1 不丢;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 undo | insert 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 节)→ 表现为"查询变慢",并有回滚段耗尽风险。
| |
措施一:缩短事务。改成"分批小事务 + 走只读从库",或报表读用一致性快照 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_commit | 1 | D 要求不丢,必须保持 | 每提交批次一次 fsync(靠组提交摊薄) |
sync_binlog | 1 | binlog 是复制与 PITR 的凭据,不能丢 | 又一次 fsync(同样靠组提交摊薄) |
binlog_group_commit_sync_delay / ..._no_delay_count | 数百微秒到 1 ms / 几百 | 故意等待攒批,一次 fsync 覆盖更多事务,同时给延迟设上界 | 单事务平均延迟上升,低并发时纯粹变慢 |
binlog_format + binlog_row_image | ROW + MINIMAL | 减少 binlog 字节量 → 减少写盘与网络传输 | MINIMAL 下部分行像回滚工具需额外配置才能还原完整行 |
innodb_redo_log_capacity | 能容纳 1~2 小时写入量 | 降低"日志写满 → 强制刷脏 → 写入停顿"的频次 | 恢复时间变长、固定占盘更多 |
innodb_flush_method | O_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 如何被从库消费):本章的前置与后继。