问题的起点
前面提到过,InnoDB 改数据是改 buffer pool 里的页,页什么时候刷回磁盘由后台线程决定,和事务提交没有关系。
这样做换来了性能,留下一个问题:一个事务已经 COMMIT 返回成功了,它改的脏页还留在内存里,这时候机器掉电,这个事务的修改不就没了?
如果改成每次提交都强制把脏页刷盘,性能又承受不了,那就是前面说的随机 IO。两边都走不通,所以需要第三个东西。
WAL:把"提交成功"的标准换掉
思路是不要求数据页落盘,只要求 redo log 落盘。
事务提交的时候,只要 redo log 里记着"我在某页某处把 name 从 a 改成了 b",机器立刻断电也没关系,重启之后照着 redo log 把这个修改重做一遍,数据就回来了。提交成功的判定标准从"数据页落盘"变成了"日志落盘",这就是 WAL(Write-Ahead Logging),Write-Ahead 指的是日志一定先于数据页落盘,这个顺序不能颠倒。
redo log 比刷数据页快在哪,两个原因:
- 顺序写。追加到文件末尾,不用寻道,和随机写差的是数量级
- 小。一条 redo 记录可能就几十个字节,而一个数据页是 16KB,改一行也得写整页
没刷盘的页叫脏页。崩溃恢复要做的,就是把脏页里丢掉的那部分修改从 redo log 里补回来。
redo log 的结构
循环写
redo log 的空间是固定的。MySQL 5.7 里由这两个参数决定:
innodb_log_file_size = 48MB # 每个文件多大
innodb_log_files_in_group = 2 # 几个文件
总容量 96MB,文件从 ib_logfile0 开始编号,当成一个环来用。MySQL 8.0.30 之后换成了 innodb_redo_log_capacity 一个参数(默认 100MB),文件个数由 InnoDB 自己管,但循环写这个模型没变。
check point write pos
↓ ↓
┌──────────────┐ ┌──────────────┐
│ ib_logfile0 │ │ ib_logfile1 │
└──────────────┘ └──────────────┘
├── 可以覆盖 ──┤ ├─── 待重放 ───┤
两个指针:
write pos:当前写到哪了,一边写一边往后推,写到文件末尾就绕回第一个文件开头check point:这个位置之前的记录,对应的脏页都已经刷盘了,所以这部分空间可以被新记录覆盖
write pos 和 check point 中间那段,就是"还没刷盘但 redo 记着"的数据,也是崩溃恢复真正要用到的部分。这段空隙越大,说明积压的脏页越多。
如果 write pos 追上了 check point,意思是 redo log 写满了,这时候必须停下来,强制刷一批脏页把 check point 往前推,腾出空间再继续写,这个动作就叫 checkpoint。
所以 redo log 不能设置得太小。设小了会频繁触发刷脏页,写入性能一阵一阵地抖,线上的表现就是"MySQL 忽然卡一下"。这是很多线上问题的根因,调大 innodb_log_file_size 往往能直接缓解。
记的是什么东西
redo log 记的是对数据页的修改,比如"页号 5 的偏移 100 处,写入这 8 个字节"。
说它是物理日志,因为它定位到了具体的页和偏移。但它也不完全是物理的,有些操作记的是逻辑描述,比如"在页 5 上插入一条主键为 100 的记录",因为插入一条记录会引发页内其他记录移动甚至页分裂,用纯物理的方式描述太麻烦。
所以准确的说法是物理逻辑日志(physiological log),物理负责定位到页,页内的改动用逻辑描述。
LSN
每条 redo 记录都带一个 LSN( Log Sequence Number ),全局单调递增。
LSN 有两个地方在用:
- redo log 文件里当前写到哪个位置了
- 每个数据页的头部也存着一个 LSN,记录"最后一次修改我的那条 redo 的 LSN 是多少"
崩溃恢复的时候,拿数据页上的 LSN 和 redo log 里的 LSN 一比就知道哪些记录还需要重放:页上的 LSN 比日志里的小,说明这条修改还没应用到页上。有了这个,恢复不用从头扫,从最近一次 check point 的位置开始就行。
什么时候刷盘
redo log 不是直接写文件的,中间还隔着一层内存缓冲:
事务执行 → redo log buffer(innodb_log_buffer_size)→ OS page cache → 磁盘
从 redo log buffer 到磁盘要过两步,这两步经常被混在一起说:
write:把数据从redo log buffer拷进操作系统的 page cache,还没落盘fsync:让操作系统把 page cache 真正写到磁盘上
write 完就返回的话,机器掉电照样丢,只有 fsync 返回了才算安全。
事务提交时走到哪一步,由 innodb_flush_log_at_trx_commit 决定:
| 取值 | 提交时的动作 | 掉电会丢什么 |
|---|---|---|
0 | 什么都不做,等后台线程每秒刷一次 | 可能丢 1 秒内的事务 |
1 | write + fsync | 不丢,默认值 |
2 | 只 write 到 page cache | MySQL 进程崩溃不丢,机器掉电可能丢 1 秒 |
0 和 2 的差别在于谁在刷。0 是 MySQL 自己的后台线程在刷,MySQL 进程挂了就没人刷了,所以进程崩溃也会丢数据;2 是交给操作系统,OS 还活着数据就在 page cache 里。
默认值 1 是唯一一个"提交返回了就不会丢"的配置。改成 0 或者 2 都是为了吞吐,代价是接受最坏丢 1 秒的事务,埋点、日志这类可以这么配,订单、支付不行。
崩溃恢复做了两件事
机器重启,InnoDB 启动的时候按顺序做两步:
1. 重放 ( redo )
从最近一次 check point 开始,把 redo log 里的记录一条条重做
把数据页恢复到崩溃那一刻的状态
2. 回滚 ( undo )
第一步会把没提交的事务也重做进去了
重放完之后,把这些事务用 undo log 撤销掉
顺序不能反。必须先把物理状态恢复到崩溃那一刻,再谈哪些逻辑上不该留。
为什么未提交的事务也要重放
这是最容易想不通的地方:既然最后要回滚,为什么不一开始就跳过它。
原因是恢复的时候没办法只重放一半。redo log 记录的是页级别的字节修改,同一个数据页上混着好几个事务的修改,页是恢复的最小单位,没法把一个页上的改动按事务拆开。而且后面那些已提交事务的 redo 记录,可能依赖前面未提交事务改过的页结构,比如页分裂之后的记录位置,跳过前面的,后面的也应用不上去。
所以 InnoDB 的做法是全都重放,重放完了再按事务维度回滚。undo log 里记着每个事务的反向操作,顺着做一遍就撤掉了,用的正是 MVCC 里提及到的版本链。
和 binlog 的分工
redo log 管崩溃恢复,binlog 管主从复制和数据恢复,两个都需要,谁也替不了谁。
它们写入的时间点不一样,redo 在执行过程中就在写,binlog 在提交时才刷到文件。但有一点必须保证:一个提交了的事务,两份日志里都得有。如果 redo 写了而 binlog 没写,主库上有这个数据、从库上没有;反过来 binlog 写了而 redo 没写,从库上多出了主库没有的数据。两种都会导致主从不一致。
怎么保证两份日志要么都写要么都不写,这是两阶段提交要解决的问题,放在 Redo Log:MySQL 为什么能够实现崩溃恢复?Redo Log 和 Binlog 为什么需要两阶段提交?那篇讲。