一条 UPDATE 到底写了什么:redo log、undo log 与 binlog 的三次落盘
UPDATE t SET c = c + 1 WHERE id = 1;这一行,在磁盘上至少产生了三类记录、四次写。
搞不清这三者的分工,就永远理解不了"为什么主从会不一致""为什么双 1 配置这么慢""为什么大事务会把主从延迟拉到十分钟"。
结论先说
| 日志 | 归属 | 类型 | 作用 | 关键参数 |
|---|---|---|---|---|
| redo log | InnoDB 引擎层 | 物理逻辑日志(页级) | 崩溃恢复:让已提交的修改不丢 | innodb_flush_log_at_trx_commit、innodb_redo_log_capacity |
| undo log | InnoDB 引擎层 | 逻辑日志(反向操作) | 回滚 + MVCC:版本链靠它 | innodb_undo_tablespaces、purge 线程 |
| binlog | Server 层 | 逻辑日志(SQL/行变更) | 主从复制 + 数据恢复 | binlog_format、sync_binlog |
一句话对比:redo 保证"我自己不丢",binlog 保证"别人跟我一样",undo 保证"能反悔"。
一、一条 UPDATE 的完整时序
假设执行 UPDATE t SET c = c + 1 WHERE id = 1,id=1 这行已经在 Buffer Pool 里。
① 连接器 校验权限、取连接
② 分析器 词法/语法 → 抽象语法树
③ 优化器 选索引(这里是主键等值,走聚簇索引)
④ 执行器 调 InnoDB 接口 read_row(id=1)
└─ InnoDB 从 Buffer Pool 取页(miss 则从磁盘读)
⑤ 执行器 判断 c 是否满足条件 → 调用 update_row
└─ InnoDB:
a. 记录 undo log(旧值 c=100) ← 逻辑日志,写 undo 页
b. 修改 Buffer Pool 中的数据页(c=101)← 页变脏
c. 写 redo log,状态 = prepare ← 物理日志,顺序写
⑥ Server 层 写 binlog(先写 binlog cache,提交时落盘)
⑦ InnoDB 写 redo log,状态 = commit
⑧ 返回 后台线程择机把脏页刷回磁盘(ibd 文件)
关键点:第 ⑤ 步返回时,数据页还在内存里,磁盘上的 .ibd 文件根本没动。 这条更新之所以"已经生效",靠的是 redo log 已经落盘。
为什么必须先写 redo,再写脏页
如果直接改磁盘页:一个页 16 KB,改一个字节也要写 16 KB,而且是随机写。1000 TPS 就是 1000 次随机 16 KB 写——机械盘直接跪,SSD 也会被写放大拖垮。
WAL 的做法是把"随机写 16 KB"换成"顺序追加几十字节":
| 方式 | IO 类型 | 单次量 | 每 TPS 成本 |
|---|---|---|---|
| 直接改数据页 | 随机写 | 16 KB | 16 KB 随机 IO |
| 写 redo log | 顺序写 | 几十~几百字节 | 几十字节顺序 IO |
这就是 InnoDB 敢把 innodb_flush_log_at_trx_commit 默认设成 1(每次提交 fsync)的底气——顺序 fsync 的代价远小于随机写页。
二、redo log:循环使用的"物理日志"
2.1 它记的是什么
redo log 记录的是"某个表空间的某个页的某个偏移量,被改成了什么"。它不是纯粹的物理日志(不记录整页),也不是纯逻辑日志(不记录 SQL),业界叫 physiological log(物理逻辑日志)。
一条 redo record 大致是:
type | space_id | page_no | offset | 修改内容(长度可变)
正因为带了 page_no,崩溃恢复才能精确重放到某一页;也正因为如此,redo 是幂等的——同一个 LSN 重放两次结果一样(靠页头 LSN 判断)。
2.2 循环写与 checkpoint
redo 是一组固定大小的文件,首尾相接循环写:
┌──────────┬──────────┬──────────┬──────────┐
│ file 0 │ file 1 │ file 2 │ file 3 │
└──────────┴──────────┴──────────┴──────────┘
↑ write pos ↑ checkpoint
└──── 可写区间 ────────────────┘
↑ 已经"安全"(对应脏页已刷盘)的部分可覆盖
write pos:当前写到哪checkpoint:哪些 redo 已经没必要了(对应的脏页已刷盘)- 两者之间的空隙就是可用空间
空隙不够时,InnoDB 必须停下来刷脏,推进 checkpoint——这一步是同步的,会阻塞所有用户线程。 这就是"数据库突然卡两秒"的经典成因。
2.3 容量怎么配
MySQL 8.0.30 起统一用 innodb_redo_log_capacity(默认 100 MB),之前是 innodb_log_file_size × innodb_log_files_in_group。
innodb_redo_log_capacity = 4G
经验值:峰值写入速率 × 30 秒。
写入 50 MB/s → 至少 1.5 G,给 2~4 G 更稳
太小 → 频繁强制刷脏、抖动;太大 → 崩溃恢复时间长(要重放更多 redo),但现代 SSD 上这个代价可以接受。
2.4 三个刷盘时机
innodb_flush_log_at_trx_commit 控制提交时 redo 怎么落盘:
| 值 | 行为 | 丢数据风险 | 适用 |
|---|---|---|---|
| 1 | 每次提交 write + fsync | 不丢(只要磁盘不坏) | 默认,金融/订单必须 |
| 0 | 每秒由后台线程刷 | 宕机丢最近 1 秒 | 日志类、可重放 |
| 2 | 每次提交 write 到 OS 缓存,每秒 fsync | 进程崩不丢,机器断电丢 1 秒 | 折中 |
注意 0 和 2 的差别:0 是连 OS 缓存都没进(还在 MySQL 自己的 log buffer 里),2 是进了 OS 缓存。所以 2 比 0 安全一档。
三、undo log:回滚与 MVCC 的同一份数据
undo log 记录的是反向操作:
-- 原操作
UPDATE t SET c = 101 WHERE id = 1; -- 原值 c = 100
-- undo 记录(逻辑上)
UPDATE t SET c = 100 WHERE id = 1;
它有两个完全不同的用途,共用同一份数据:
3.1 事务回滚
ROLLBACK 时按 undo 链逆序执行,把页改回去。注意:回滚本身也会写 redo(因为改页就要写 redo),所以回滚一个大事务可能比执行它还慢。
3.2 MVCC 版本链
每行记录有两个隐藏列:
| 隐藏列 | 含义 |
|---|---|
| DB_TRX_ID | 最后修改该行的事务 ID |
| DB_ROLL_PTR | 指向 undo log 中上一个版本的指针 |
对同一行的多次修改会串成一条链:
当前行 (c=103, trx=300)
↓ roll_ptr
undo: c=102 (trx=200)
↓ roll_ptr
undo: c=101 (trx=150)
↓ roll_ptr
undo: c=100 (trx=100)
一个 RR 隔离级别下的快照读,就是沿着这条链往回走,找到第一个"对当前 ReadView 可见"的版本。这也是为什么长事务会拖垮数据库:
一个开了 10 分钟没提交的事务,会让它之后的所有 undo 版本都不能被 purge 掉。
undo 表空间疯涨 → 磁盘满 → 整个实例只读。
3.3 purge:谁来清理
后台 purge 线程负责删除"已经没有事务需要看到"的 undo 记录。
innodb_purge_threads = 4 # 8.0 默认 4
innodb_max_purge_lag = 0 # >0 时会主动延迟 DML,给 purge 让路
监控:
SHOW ENGINE INNODB STATUS\G
-- TRANSACTIONS 段
-- History list length: 1234567 ← 这个数持续上涨就是长事务在作祟
History list length 是一个极好用的指标:正常情况下应该在几千以内,上万说明有长事务或 purge 跟不上。
四、binlog:Server 层的逻辑日志
4.1 与 redo 的本质区别
| 维度 | redo log | binlog |
|---|---|---|
| 归属 | InnoDB 引擎 | MySQL Server |
| 内容 | 页级物理修改 | 逻辑变更(SQL 或行) |
| 写入方式 | 循环写,会被覆盖 | 追加写,不覆盖,靠 PURGE BINARY LOGS 清理 |
| 用途 | 崩溃恢复 | 主从复制、按时间点恢复 |
| 是否幂等 | 幂等(带 LSN) | 不一定(statement 格式下依赖上下文) |
只有 InnoDB 有 redo,但所有引擎都写 binlog——这个分层差异正是两阶段提交存在的原因。
4.2 三种格式
| 格式 | 记录内容 | 优点 | 缺点 |
|---|---|---|---|
| STATEMENT | 原始 SQL | 日志小 | 函数、NOW()、自增不确定 → 主从不一致 |
| ROW | 每行改前/改后镜像 | 绝对一致 | 日志大(批量更新放大几十倍) |
| MIXED | 自动切换 | 折中 | 仍有不确定性 |
MySQL 5.7.7 起默认 ROW。请保持 ROW:主从一致性比磁盘重要,而且 ROW 格式下 binlog_row_image=MINIMAL 能把日志压回去。
binlog_format = ROW
binlog_row_image = MINIMAL # 只记被改的列,日志量能降 50%+
4.3 binlog 何时落盘
sync_binlog = 1 # 每次提交 fsync binlog
| 值 | 行为 | 风险 |
|---|---|---|
| 1 | 每次提交 fsync | 最安全,性能最差(多一次 fsync) |
| 0 | 交给 OS,由 fsync 时机决定 | 宕机丢日志 → 主从数据不一致 |
| N | 每 N 次提交 fsync | 折中,宕机丢最多 N 个事务 |
五、两阶段提交:为什么必须有
5.1 如果没有 2PC 会怎样
假设写完 redo 就返回,binlog 稍后再写:
时刻 T1:redo 落盘(事务已提交,数据改了)
时刻 T2:binlog 还没写,实例崩溃
恢复后:主库有这条数据,从库没有 → 永久不一致
反过来,先写 binlog 再写 redo:
binlog 落盘 → 崩溃 → 主库没有这条数据,从库有了 → 同样不一致
只要两个日志不是原子的,就必然存在不一致窗口。 2PC 就是把这个窗口压到最小。
5.2 三个阶段
① prepare InnoDB 写 redo,打上 prepare 标记,返回
(此时事务未提交,但 redo 已可恢复)
② commit Server 写 binlog 并 fsync
↓
③ InnoDB 写 redo commit 标记
5.3 崩溃恢复的判定规则(重点)
重启后,InnoDB 扫 redo:
| redo 中有 prepare | binlog 中事务完整 | 动作 |
|---|---|---|
| ✅ | ✅ | 提交(说明 ② 已完成) |
| ✅ | ❌ | 回滚(说明 ② 没完成) |
| ❌ | —— | 忽略(事务没到 prepare) |
这个判定的精妙之处在于:binlog 是否完整,成了唯一的裁决者。
所以 MySQL 在 binlog 里写事务时会带一个 XID(Xid = ... 事件),恢复时拿 redo 里 prepare 的事务 XID 去 binlog 里找,找到了就提交,找不到就回滚。
5.4 组提交:把 2PC 的开销摊薄
严格 2PC 每次提交要 2 次 fsync(redo 一次、binlog 一次),QPS 上不去。MySQL 用组提交(group commit)把多个事务的 fsync 合并:
flush 阶段:多个事务的 binlog 一起 write
sync 阶段:一次 fsync 刷多个事务的 binlog
commit 阶段:多个 redo 一起打 commit 标记
并发越高,一次 fsync 带上的事务越多,单位成本越低——这也是为什么单线程压测的 TPS 远低于多线程,很多人误以为是自己代码写得不好。
# 主动等一下,攒更多事务一起提交(用延迟换吞吐)
binlog_group_commit_sync_delay = 0 # 微秒,设 1000 表示最多等 1ms
binlog_group_commit_sync_no_delay_count = 0 # 攒够 N 个就不等了
生产建议:这两项保持 0,除非压测证明有收益。设了会增加每次提交的固定延迟,对延迟敏感的业务是负优化。
六、"双 1"配置到底意味着什么
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
这两项都是 1 时,一次事务提交至少 2 次 fsync(redo + binlog),加上 2PC 的额外开销。在普通 SSD 上是 0.5~2 ms 的固定成本,机械盘可能到 10 ms。
优化方向不是关掉它们,而是减少提交次数:
| 手段 | 效果 | 代价 |
|---|---|---|
| 批量插入(一次 500 行) | 500 次提交 → 1 次 | 需要业务配合 |
| 合并小事务 | 同上 | 事务变大,锁持有时间变长 |
| innodb_flush_log_at_trx_commit=2 | 少一次 fsync | 断电丢 1 秒 |
| 组提交(并发自然形成) | 摊薄 | 无 |
最不该做的就是直接关掉双 1。 关掉之后省下的那点 IO,会在某次机房断电时以"订单数据丢了"的形式还回来。
七、由这三份日志推导出的四个现象
1)为什么大事务会拉高主从延迟
binlog 是事务提交时才整段发送给从库的(ROW 格式下,一个 10 万行的 UPDATE 会产生几十 MB 的 binlog 事件)。主库执行花了 30 秒,从库回放也要 30 秒,而且这 30 秒里后续所有事务都排在它后面 → 延迟雪崩。
对策:把大事务拆成小批(WHERE id BETWEEN ... LIMIT 1000 循环),并在业务低峰执行。
2)为什么 DELETE 全表比 TRUNCATE 慢几个数量级
DELETE 逐行删,每行都要写 undo、写 redo、写 binlog,而且删完的空间不回收(只是标记删除)。TRUNCATE 是重建表空间,只写少量元数据。
3)为什么回滚一个大事务可能比执行还慢
回滚要把 undo 链从头到尾逆向执行一遍,每一步都要改页、写 redo。一个跑了 10 分钟的批量更新,回滚可能跑 15 分钟。
4)为什么要监控 History list length
它是"有多少 undo 版本因为还有事务需要看到而不能清理"的直接度量,也就是长事务的体温计。
-- 找长事务
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS seconds,
trx_rows_modified, LEFT(trx_query, 80) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 5;
八、小结
三条主线记住就够了:
- redo 是"重做",保证已提交的修改不丢——顺序写,循环用,靠 LSN 幂等重放。
- undo 是"反悔",同时支撑 MVCC——反向操作,版本链,靠 purge 清理。
- binlog 是"广播",保证别人跟我一样——逻辑变更,追加写,2PC 的裁决者。
而 2PC 的存在,本质上是因为 InnoDB 和 Server 层各写各的日志,没有共同的事务管理器。理解了这一点,"prepare / commit / 崩溃恢复判定"就不再是需要背的知识点,而是一个必然的设计。
系列内链
- 前置:[05-数据库与MySQL/01-InnoDB存储引擎与BufferPool](01-InnoDB存储引擎与BufferPool.md)
- 关联:[05-数据库与MySQL/04-事务隔离级别与MVCC](04-事务隔离级别与MVCC.md)——undo 版本链的具体读法
- 关联:[05-数据库与MySQL/09-主从复制与读写分离](09-主从复制与读写分离.md)——binlog 怎么变成从库的数据
- 关联:[05-数据库与MySQL/05-MySQL锁机制](05-MySQL锁机制.md)——大事务为什么容易死锁
- 点赞
- 收藏
- 关注作者
评论(0)