MySQL ACID 与三大日志¶
💡 一句话概述
原子性靠 undo log(后悔药),持久性靠 redo log + WAL(备忘录),隔离性靠锁 + MVCC,一致性是前三者加约束共同达成的「结果」而不是一个独立模块。 而 redo log(引擎内部保命)和 binlog(对外广播的账本)之间必须靠**两阶段提交**对账,否则主从数据会不一致。
🔑 核心概念¶
- WAL(Write-Ahead Logging,预写日志) — 先写日志再写数据页。因为日志是顺序追加写、数据页是随机写,一次提交只需要 fsync 一个日志文件,脏页可以延迟批量刷盘。这是 InnoDB 高性能的地基。
- undo log(后悔药) — 逻辑日志,记录「怎么把数据改回原样」。两个用途:① 回滚保证原子性;② 撑起 MVCC 的多版本链。
- redo log(备忘录) — 物理日志,记录「第几号页、偏移多少、改成什么」。InnoDB 引擎层独有,固定大小的**环形缓冲**循环写,专门用于崩溃恢复。
- binlog(对外广播的账本) — MySQL Server 层**的逻辑日志(所有引擎都有),**追加写、写满换新文件、永不覆盖。用于主从复制、PITR 时间点恢复、审计、CDC。
- 两阶段提交(内部 XA) — redo 写 prepare → 写 binlog → redo 写 commit。崩溃恢复时**以 binlog 是否完整为唯一裁决依据**,保证两份日志记录的事务集合完全一致。
- Buffer Pool / 脏页 / checkpoint / doublewrite — 内存里改完的页叫脏页;checkpoint 是「这之前的 redo 对应的脏页已刷盘,日志可以被覆盖」的水位线;doublewrite 是防止「16KB 页只写了一半」的物理损坏兜底。
📝 详解¶
1. 先把总纲钉在脑子里¶
面试问「MySQL 怎么保证 ACID」,90% 的人会背出一堆名词然后卡住。正确的答法是**先给分工,再给机制**:
| ACID | 靠什么保证 | 关键机制 | 一句话 |
|---|---|---|---|
| A 原子性 | undo log | 记录反向操作,回滚时逆序执行 | 要么全成,要么当没发生过 |
| C 一致性 | A + I + D + 约束 + 业务代码 | 主键/唯一/非空/外键/CHECK | 不是独立模块,是结果 |
| I 隔离性 | 锁 + MVCC | 行锁/间隙锁/next-key;undo 版本链 + ReadView | 并发事务互不干扰 |
| D 持久性 | redo log + WAL | 提交时 fsync redo,脏页延迟刷 | 提交了就永远不丢 |
一致性最容易被答错
一致性(C)没有对应的单一机制。它是「A、I、D 三个都成立」+「数据库约束(主键、唯一、非空、外键、CHECK)」+「你的业务代码没写错」三者合力得到的**最终状态**。原子性被破坏会留下半截数据,隔离性被破坏会读到中间态,持久性被破坏会让已提交状态回退——任何一个塌了,一致性都塌。所以「一致性由 redo log 保证」这种回答是错的。
给 Go 后端同学的类比:
undo log = 你在内存里做业务时留的 rollback 函数(defer 里执行)
redo log = 你自己进程的 WAL,保证 crash 后能重放(类似 etcd 的 WAL、Kafka 的 log segment)
binlog = 你对外发的变更事件流(类似 Canal 订阅的 CDC、Kafka topic)
2. 一切的地基:Buffer Pool 与脏页¶
要理解三大日志,先得理解 InnoDB 的写路径**根本不是「改内存 → 立刻写磁盘」**。
InnoDB 在内存里维护一块 Buffer Pool(默认 128MB,生产通常给到物理内存的 50%~70%),数据以 16KB 的页(page) 为单位缓存。你执行 UPDATE 时:
- 把目标页读进 Buffer Pool(如果不在);
- 在内存里直接改;
- 把这个页标记为**脏页(dirty page)**——「内存里的版本比磁盘上的新」;
- 至于什么时候把脏页刷回
.ibd数据文件?不着急,以后再说。
┌───────────────── 内存 ─────────────────┐
UPDATE ... ──▶ │ Buffer Pool │
│ ┌────────┐ ┌────────┐ │
│ │ page 7 │ │ page 9 │ ← 改了没落盘 │
│ └───┬────┘ └───┬────┘ = 脏页 │
│ │ │ │
│ redo log buffer undo log buffer │
└───────┼────────────┼───────────────────┘
│ COMMIT 时 fsync(顺序追加,很快)
┌───────▼────────────▼───────────────────┐
│ #ib_redo 文件 undo 表空间 │ 磁盘
│ (redo log) (undo log) │
│ │
│ ✗ 脏页【不】在这一步落盘 │
└────────────────────────────────────────┘
⋮
后台刷脏线程 / checkpoint 推进时才批量刷
⋮
┌────────────────────────────────────────┐
│ 表空间 .ibd 数据文件 │
└────────────────────────────────────────┘
那么问题来了:脏页还在内存里,机器断电了怎么办? 这就是 WAL 要解决的事。
3. WAL:为什么「先写日志」反而更快¶
WAL 的完整名字是 Write-Ahead Logging(预写式日志),规则只有一句:
任何数据页的修改落盘之前,必须先把它对应的 redo log 记录写入磁盘。
直觉上「多写一份日志」应该更慢才对,为什么反而快?三个原因:
| # | 原因 | 展开说明 |
|---|---|---|
| ① | 随机写 → 顺序写 | 一个事务可能改 20 个分散在不同表、不同位置的页,刷盘就是 20 次**随机 IO**(机械盘寻道 ~10ms,SSD 也远慢于顺序)。而 redo log 是**追加写同一个文件**,纯顺序 IO,快一到两个数量级。 |
| ② | 一次提交只 fsync 一个文件 | fsync 是最贵的系统调用(要把 OS page cache 强行推到磁盘介质)。有了 WAL,一次 COMMIT 只需要保证**一个 redo log 文件** fsync 成功,不需要碰任何数据文件。 |
| ③ | 脏页延迟 + 批量 + 合并 | 同一个页在内存里被改 100 次,最终只需要刷盘**一次**(写的是最终状态)。而且可以攒一批脏页一起顺序刷、可以放到业务低峰期刷。 |
配合 MySQL 5.6+ 的 redo log 组提交(group commit):多个并发事务的 redo 可以合并成一次 fsync,写入吞吐再上一个台阶。
一句话总结 WAL
用「一次小的顺序写」换掉「N 次大的随机写」,同时把持久性的责任从数据页转移到日志上。 数据页什么时候落盘已经不影响正确性了——只要 redo 在,页就能重建。
4. undo log:后悔药¶
4.1 它是逻辑日志,记录的是「反向操作」¶
undo log 不记录字节,它记录的是**语义上如何撤销**:
| 你执行的操作 | undo log 记录的内容 | 回滚时执行 |
|---|---|---|
INSERT INTO t VALUES (1, 'a') |
这行的主键 (1) |
DELETE FROM t WHERE id = 1 |
DELETE FROM t WHERE id = 1 |
被删行的**完整旧值** (1, 'a') |
INSERT INTO t VALUES (1, 'a') |
UPDATE t SET name = 'b' WHERE id = 1 |
被改列的**旧值** name = 'a' |
UPDATE t SET name = 'a' WHERE id = 1 |
回滚时**从最新的 undo 记录逆向执行回事务起点**。注意 UPDATE 只记被修改的列的旧值,不记整行——所以 undo 的体积通常比 redo 小。
4.2 用途一:回滚,保证原子性¶
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- undo 记下 id=1 的旧余额
INSERT INTO transfer_log VALUES (NULL, 1, 2, 100); -- undo 记下新插入行的主键
-- 这里业务代码报错 / 你手动 ROLLBACK / 连接断开
ROLLBACK; -- InnoDB 逆序执行 undo:删掉 transfer_log 那行 → 把 id=1 的余额加回 100
显式 ROLLBACK、语句执行出错、客户端连接断开、甚至**崩溃恢复时发现事务未提交**,走的都是同一条 undo 回滚路径。这就是原子性的实现。
4.3 用途二:MVCC 版本链(隔离性的另一半)¶
这是 undo log 最容易被忽略的价值。InnoDB 的每行数据都有隐藏列:
DB_TRX_ID— 最后修改这行的事务 IDDB_ROLL_PTR— 回滚指针,指向 undo log 里这行的**上一个版本**DB_ROW_ID— 没定义主键时用的隐藏自增 ID
于是多次更新会把旧版本串成一条**版本链**:
当前行(Buffer Pool 里) undo log 中的历史版本
┌──────────────────────┐
│ balance = 700 │
│ DB_TRX_ID = 300 │
│ DB_ROLL_PTR ─────────┼──▶ ┌──────────────────────┐
└──────────────────────┘ │ balance = 800 │
│ DB_TRX_ID = 200 │
│ DB_ROLL_PTR ─────────┼──▶ ┌──────────────────────┐
└──────────────────────┘ │ balance = 900 │
│ DB_TRX_ID = 100 │
│ DB_ROLL_PTR = NULL │
└──────────────────────┘
快照读(普通 SELECT)时,事务拿自己的 ReadView(记录「我看到这个快照时,哪些事务还活跃」)沿版本链**从新往旧找**第一个对自己可见的版本,全程不加锁。这就是为什么 InnoDB 能做到「读不阻塞写、写不阻塞读」。
- RR(可重复读,默认):事务内第一次快照读时生成 ReadView,之后一直复用 → 可重复读。
- RC(读已提交):每次快照读都重新生成 ReadView → 能读到别人新提交的数据。
- 当前读(
SELECT ... FOR UPDATE/UPDATE/DELETE/INSERT):读最新版本并加锁,不走 MVCC。
长事务的真正代价
一个跑了 10 分钟不提交的事务,会让它启动时刻之后的**所有 undo 记录都无法被 purge 线程清理**(因为可能还有人需要那些历史版本)。结果是 undo 表空间暴涨、版本链越来越长、别的快照读要遍历一长串版本才能找到可见数据,查询越来越慢。查长事务:
SELECT * FROM information_schema.INNODB_TRX ORDER BY trx_started LIMIT 10;
5. redo log:备忘录¶
5.1 它是物理日志¶
redo log 记录的不是 SQL,而是**对页的物理修改**,概念上长这样:
意思是:「34 号表空间的 17 号页,偏移 112 处,改成 900」。崩溃恢复时,InnoDB 只要**照着这条记录把字节写回去**,页就恢复了——这叫**前向重放(redo apply)**,重放是**幂等**的。
redo 只往前,从不往后
redo log 只负责重做已提交的修改,它从不回滚任何东西。回滚是 undo log 的活。这个分工在崩溃恢复流程(第 9 节)里体现得最清楚。
5.2 环形缓冲:write pos 与 checkpoint¶
redo log 不是无限增长的日志文件,它是一个**固定大小的环形缓冲**:
write pos(当前写到哪)
↓
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ │ │ ▓▓ │ ▓▓ │ ▓▓ │ │ │ │ │ ▓▓ │ ▓▓ │ ▓▓ │ ↺
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
↑
checkpoint(此位置之前的日志,
对应的脏页已刷盘 → 空间可被覆盖)
─────────── 顺时针循环推进 ───────────▶
✅ 正常: checkpoint ··········· write pos 中间还有空闲空间
🚨 危险: write pos 追上了 checkpoint 必须【阻塞所有写入】,
强制刷脏页把 checkpoint 推上去
- write pos 到 checkpoint 之间:空闲,可以写新日志。
- checkpoint 到 write pos 之间:还没被覆盖的 redo,对应的脏页**可能还没刷盘**,所以不能擦。
- write pos 追上 checkpoint:redo 满了,MySQL 会**卡住所有更新**,拼命刷脏页推进 checkpoint。这就是线上偶发的「MySQL 突然全库卡顿几秒」的经典原因之一——redo log 配得太小。
日志文件位置与大小(注意版本差异,别记错参数名):
| MySQL 版本 | 文件 | 控制参数 | 默认值 |
|---|---|---|---|
| ≤ 8.0.29 | ib_logfile0、ib_logfile1 |
innodb_log_file_size × innodb_log_files_in_group |
48MB × 2 |
| ≥ 8.0.30 | #innodb_redo/ 目录下的一组 #ib_redoN |
innodb_redo_log_capacity(前两个参数已废弃) |
100MB |
生产上一般给到 1~4GB(写入量大的实例更大),目标是让 redo 能覆盖住「刷脏页所需的时间窗口」,避免 checkpoint 追不上。
5.3 innodb_flush_log_at_trx_commit:0 / 1 / 2 的取舍¶
这是面试和生产调优都绕不开的一个参数。先分清三层存储:
InnoDB redo log buffer → OS page cache → 磁盘
(mysqld 进程内存) (内核内存) (真正的介质)
↑ write() 越过这条线 ↑ fsync() 越过这条线
| 取值 | COMMIT 时做什么 | 后台每秒做什么 | mysqld 崩溃 | OS 崩溃 / 断电 | 性能 | 适用场景 |
|---|---|---|---|---|---|---|
| 0 | 什么都不做,redo 留在 InnoDB 用户态 buffer | write + fsync | 🔴 丢约 1 秒 | 🔴 丢约 1 秒 | 最高 | 可重跑的日志表、统计、ETL 临时库 |
| 1(默认) | write + fsync | — | 🟢 不丢 | 🟢 不丢 | 最低 | 金融、订单、账户、库存——必须是 1 |
| 2 | write 到 OS page cache,不 fsync | fsync | 🟢 不丢(OS 还活着,cache 里的数据在) | 🔴 丢约 1 秒 | 中 | 一般业务库、从库、可容忍秒级丢失的场景 |
**0 和 2 的关键区别**在于数据停在哪一层:0 停在 **mysqld 进程内存**里,进程一崩就没了;2 已经交到了 **OS 内核**手里,进程崩了内核还活着,数据不丢,只有整机断电/内核 panic 才丢。
配套还有 binlog 侧的 sync_binlog:0 = 由 OS 决定何时刷;1 = 每次提交都 fsync;N = 每 N 个事务 fsync 一次。
传说中的「双 1 配置」
innodb_flush_log_at_trx_commit = 1 + sync_binlog = 1,这是**唯一能保证「提交即绝对不丢」**的组合,也是 MySQL 5.7.7+ 的默认值。任何一项调低,都在拿数据换性能。
5.4 doublewrite:redo log 治不了「页写一半」¶
一个 InnoDB 页是 16KB,而文件系统的块通常是 4KB。如果刷一个脏页刷到第 8KB 时断电,这个页就处于**半新半旧的撕裂状态**(partial page write / page corruption)。
为什么 redo log 救不了它? 因为 redo 是**物理日志**,它记录的是「在偏移 112 处改成 900」——它**假设页的其余部分是正确的**。页本身已经损坏(checksum 校验不过),在这个坏页上重放 redo,得到的还是一坨垃圾。
所以 InnoDB 加了 doublewrite buffer(双写缓冲):
graph LR
A["Buffer Pool<br/>脏页 16KB"] -->|"① 顺序追加写 + fsync<br/>(便宜:顺序 IO)"| B["doublewrite 区域<br/>2MB,.dblwr 文件"]
B -->|"② 再随机写到真实位置<br/>(贵:随机 IO)"| D["表空间 .ibd"]
D -.->|"崩溃重启<br/>checksum 校验失败"| E["③ 从 doublewrite<br/>取出完整页副本"]
E -.->|"覆盖坏页"| D
D -.->|"④ 页已完整<br/>再重放"| F["redo log<br/>前向重放"]
顺序写 doublewrite 区域很便宜(顺序 IO),然后再随机写到真实位置。恢复流程是:先用 doublewrite 修复物理损坏的页 → 再用 redo log 重放逻辑修改。 两层兜底,缺一不可。
控制参数是 innodb_doublewrite(默认 ON)。MySQL 8.0.20 起,doublewrite 数据从系统表空间 ibdata1 挪到了独立的 .dblwr 文件(由 innodb_doublewrite_dir 控制),减少了系统表空间的争用。
别为了性能关掉 doublewrite
只有在**底层存储自带原子写保证**(如某些企业级 SSD 的 atomic write、FusionIO、ZFS 这类自带 COW 的文件系统)时,关掉 innodb_doublewrite 才是安全的。普通 ext4/xfs + 常规 SSD 关掉它,一次断电就可能让整张表损坏到无法启动。
5.5 脏页什么时候刷盘¶
| 触发时机 | 说明 |
|---|---|
| 后台刷脏线程 | InnoDB 根据脏页比例、redo 生成速度自适应地持续刷(innodb_page_cleaners) |
| checkpoint 推进 | redo 快写满时,必须刷脏页把 checkpoint 往前推(这时会**阻塞用户写入**) |
| 脏页比例超阈值 | 超过 innodb_max_dirty_pages_pct(默认 90)时加速刷 |
| Buffer Pool 空间不足 | 要读新页但 LRU 上没有干净页可用,得先刷脏页腾位置 |
| 正常关机 | innodb_fast_shutdown 默认配置下会把脏页全刷干净 |
6. binlog:对外广播的账本¶
6.1 Server 层,所有引擎共享¶
binlog(binary log,归档日志)由 MySQL Server 层**产生,**不管你用 InnoDB、MyISAM 还是 Memory 都有。它记录的是「这个实例上发生过哪些逻辑变更」,是一份**归档**——这是它和 redo log 最本质的定位差异。
redo log 是**引擎的保命机制**(你不主动碰它,InnoDB 自动用),binlog 是**给外面的人看的**(从库、Canal、DBA、审计系统都要主动去读)。
6.2 三种格式¶
由 binlog_format 控制(MySQL 8.0 默认 ROW):
| 格式 | 记录什么 | 优点 | 缺点 |
|---|---|---|---|
| STATEMENT | 原始 SQL 文本 | 日志量小、人眼可读、省空间 | ⚠️ 可能主从不一致:NOW()、UUID()、RAND()、不带 ORDER BY 的 LIMIT、触发器,在主从上的执行结果可能不同 |
| ROW(默认) | 每一行**改动前后的镜像**(前像 before image + 后像 after image) | 精确、可恢复、CDC 友好(能知道具体哪行从什么变成什么) | 日志量大:一条 UPDATE ... WHERE 1=1 影响 100 万行,就记 100 万行的变更 |
| MIXED | MySQL 自动判断:可能不一致的语句用 ROW,其余用 STATEMENT | 折中 | 仍有边界情况,且切换逻辑不透明 |
ROW 格式还有个 binlog_row_image 参数:FULL(默认,记录整行所有列的前后值)、MINIMAL(只记主键 + 变更列)、NOBLOB。做 CDC / 数据同步时必须是 FULL,否则下游拿不到完整行。
6.3 追加写,写满换文件¶
binlog.000001 ← 写满 max_binlog_size(默认 1GB)
binlog.000002
binlog.000003
...
binlog.000042 ← 当前正在写
binlog.index ← 索引文件,记录所有 binlog 文件名
binlog 永远不会覆盖旧内容,写满就切一个新文件(FLUSH LOGS 也会强制切)。这跟 redo log 的环形覆盖是根本区别,也正是 binlog 能做 PITR 的前提。
空间管理靠过期策略,不靠覆盖:
SHOW VARIABLES LIKE 'binlog_expire_logs_seconds'; -- 8.0,默认 2592000(30 天)
SHOW VARIABLES LIKE 'expire_logs_days'; -- 5.7 及之前
PURGE BINARY LOGS BEFORE '2026-08-01 00:00:00'; -- 手动清理
SHOW BINARY LOGS; -- 列出所有 binlog 文件
SHOW MASTER STATUS; -- 当前正在写的文件 + position(8.4 改名 SHOW BINARY LOG STATUS)
6.4 四大用途¶
| 用途 | 怎么用 |
|---|---|
| 主从复制 | 主库写 binlog → 从库 IO 线程拉过来写进 relay log → SQL 线程(8.0 支持多线程 MTS)重放 |
| PITR 时间点恢复 | 全量备份 + 重放从备份点到目标时刻之间的 binlog |
| 审计 | mysqlbinlog 解析出「谁在几点改了什么」 |
| CDC | Canal / Maxwell / Debezium 伪装成从库,解析 binlog 把变更投递到 Kafka、ES、Redis、数仓 |
7. redo log vs binlog:别再混为一谈¶
这是面试**最高频**的对比题,一张表背下来:
| 维度 | redo log | binlog |
|---|---|---|
| 产生层级 | InnoDB 引擎层(只有 InnoDB 有) | MySQL Server 层(所有引擎都有) |
| 日志类型 | 物理日志(页号 + 偏移 + 字节) | 逻辑日志(statement / row / mixed) |
| 写入方式 | 环形缓冲,循环覆盖 | 追加写,写满换新文件,不覆盖 |
| 写入时机 | 事务执行过程中**持续写**(每改一个页就写) | 事务**提交时**一次性写 |
| 核心用途 | 崩溃恢复(crash-safe) | 主从复制、PITR、审计、CDC |
| 能否恢复到任意时间点 | ❌ 不能,只保留最近一小段(已被 checkpoint 覆盖的没了) | ✅ 能(配合全量备份) |
| 是否需要主动读 | 不用,InnoDB 自动重放 | 要,mysqlbinlog / 从库 IO 线程 / Canal |
| 空间管理 | 自动循环复用 | FLUSH LOGS + PURGE / 过期时间自动删 |
| 能不能只用它保 crash-safe | ✅ 能 | ❌ 不能(见下方陷阱) |
为什么两个都需要,不能只留一个?
- 只有 binlog 不行:binlog 是 Server 层的逻辑日志,它**不知道 InnoDB 的页状态**,无法判断「哪些修改已经刷到数据文件、哪些还是内存脏页」,所以做不了崩溃恢复。而且 binlog 是在**提交时**才写的,事务执行中途崩溃的部分修改它根本没有记录能力。
- 只有 redo log 不行:redo 是环形覆盖的,没有归档能力,无法恢复到任意时间点;而且它是 InnoDB 私有的物理日志,别的系统(从库、Canal、异构存储)根本读不懂。
一句话:redo log 是「让 InnoDB 自己活下来」,binlog 是「让别人知道发生了什么」。
8. 两阶段提交:redo 和 binlog 必须对得上账¶
8.1 为什么需要它¶
一个事务提交时,两份日志都要写。假设没有协调机制,简单粗暴地按顺序写:
反例 A:先写 redo,再写 binlog
重启后:主库靠 redo 恢复出了这笔数据(主库有),但 binlog 里没有 → 从库永远同步不到(从库没有),用 binlog 做 PITR 恢复出来的库也没有。主从不一致。
反例 B:先写 binlog,再写 redo
重启后:主库因为 redo 缺失,这笔事务**等于没发生**(主库没有),但 binlog 已经广播出去了 → 从库重放 binlog 后**多出了这笔数据**。主从不一致,而且方向相反。
结论:只要「写两份日志」这件事不是原子的,中间任何一个点崩溃都会导致两份日志记录的事务集合不一致。 这正是分布式事务里经典的原子提交问题,MySQL 的解法就是 内部 XA / 两阶段提交(2PC)。
8.2 流程¶
sequenceDiagram
autonumber
participant C as 客户端
participant E as InnoDB 引擎层
participant R as redo log
participant S as MySQL Server 层
participant B as binlog
C->>E: "BEGIN, UPDATE ..., COMMIT"
Note over E: 执行过程中:改 Buffer Pool 页 + 写 undo log<br/>同时把物理变更写入 redo log buffer
rect rgb(235, 245, 255)
Note over E,R: 【阶段一 · Prepare】
E->>R: 写 redo log,事务状态标记为 prepare<br/>(带上本事务的 XID)
R-->>E: redo 已按 flush_log_at_trx_commit 落盘
end
rect rgb(240, 255, 240)
Note over S,B: 【阶段二 · 写 binlog】
E->>S: 通知 Server 层:prepare 完成
S->>B: 写入本事务的全部 binlog event<br/>最后一条是 XID_event(同一个 XID)
B-->>S: binlog 按 sync_binlog 落盘
end
rect rgb(255, 248, 235)
Note over E,R: 【阶段三 · Commit】
S->>E: binlog 写成功,可以提交
E->>R: 写 redo log,事务状态标记为 commit
end
E-->>C: COMMIT 返回 OK
XID 是串起两份日志的钥匙:redo log 的 prepare 记录里带 XID,binlog 事务末尾的 XID_event 也带同一个 XID。崩溃恢复时就是靠它对账。
8.3 崩溃恢复的裁决规则¶
| 崩溃发生在 | redo log 状态 | binlog 状态 | 裁决 | 理由 |
|---|---|---|---|---|
| 阶段一之前 | 无记录 / 未 prepare | 无 | 回滚 | 事务压根没提交 |
| ① 之后、② 之前 | prepare | 不完整/缺失 | 回滚 | binlog 没有 → 从库也不会有 → 主库回滚才能保持一致 |
| ② 之后、③ 之前 | prepare | 完整(有 XID_event) | ✅ 提交 | binlog 已完整且可能已被从库消费 → 主库必须提交才能一致 |
| ③ 之后 | commit | 完整 | ✅ 已提交 | 正常情况 |
记住这条铁律
binlog 是唯一的裁决者(authority)。 只要 binlog 完整,哪怕 redo 停在 prepare 状态,也判定为提交;只要 binlog 不完整,哪怕 redo 已经 prepare,也判定为回滚。 为什么是 binlog 说了算而不是 redo?因为 binlog 一旦写完就可能已经被从库/Canal 消费掉了,是不可撤销的既成事实;而 redo 还在本机,怎么处理都行。让本机迁就外部,才能保证全局一致。
8.4 顺带一提:组提交¶
MySQL 5.7+ 把两阶段提交做了**组提交(group commit)**优化:把多个并发事务分成一批,合并它们的 fsync 调用(redo 一次、binlog 一次),并且拆成 flush / sync / commit 三个队列阶段流水线化。所以「两阶段提交」在高并发下**不是**每个事务两次 fsync 的串行开销,实际代价比想象中小很多。
9. 崩溃恢复三阶段¶
实例重启后,InnoDB 自动执行的恢复流程:
graph TD
A["实例崩溃重启"] --> B["① 扫描 redo log<br/>从 checkpoint LSN 读到日志末尾<br/>确定需要恢复的 LSN 范围"]
B --> C["② 前向重放 redo(REDO APPLY)<br/>把所有物理页修改应用到 Buffer Pool<br/>⚠️ 此阶段【不区分】事务是否已提交"]
C --> D{"③ 逐事务裁决<br/>检查 redo 中的事务状态"}
D -->|"状态 = commit"| E["✅ 保留<br/>(第②步已重做完毕)"]
D -->|"状态 = prepare"| F{"拿 XID 去 binlog 里找<br/>是否有完整的 XID_event?"}
F -->|"找得到 → 已提交"| G["✅ 标记 commit,保留"]
F -->|"找不到 → 未提交"| H["🔄 用 undo log 回滚"]
D -->|"无 commit 记录<br/>(执行中途崩溃)"| H
E --> I["恢复完成<br/>刷脏页,开放服务"]
G --> I
H --> I
三个阶段各自的职责:
| 阶段 | 用什么日志 | 做什么 | 方向 |
|---|---|---|---|
| ① 扫描 | redo log | 从上一次 checkpoint 的 LSN 开始,扫到日志末尾,收集出所有页修改记录 + 事务状态 + XID | — |
| ② 重做 | redo log | **前向重放**所有物理修改(包括未提交事务的修改,先无脑应用,反正后面会撤),把「已提交但脏页没来得及刷盘」的数据找回来 | ⏩ 向前 |
| ③ 回滚 | undo log | 对第 ② 阶段裁决为「未提交」的事务,沿 undo 版本链**逆向执行**,把它们在内存里造成的修改撤销掉 | ⏪ 向后 |
为什么第 ② 阶段连未提交事务的修改也一起重放?
因为 redo 是**页级别**的物理日志,同一个页上可能同时有事务 A(已提交)和事务 B(未提交)的修改,物理上没法只挑 A 的部分应用。所以策略是「先全部重放,再用 undo 把 B 撤掉」——简单、正确、可幂等重入。
LSN(Log Sequence Number) 是贯穿整个恢复过程的坐标:它是单调递增的字节量,标记 redo log 写到哪了。SHOW ENGINE INNODB STATUS 的 LOG 段能看到一组关键 LSN:
---
LOG
---
Log sequence number 14834495638 ← 当前 redo 写到的 LSN
Log buffer flushed up to 14834495638 ← 已 write 到 OS 的 LSN
Persisted to disk up to 14834495638 ← 已 fsync 落盘的 LSN
Last checkpoint at 14834495629 ← checkpoint 水位(差值 = 待刷脏的量)
10. 隔离性:锁 + MVCC 的分工¶
把 undo log 那节铺垫好的 MVCC 和锁合起来看:
| 冲突类型 | 靠什么解决 | 说明 |
|---|---|---|
| 读 vs 写 | MVCC | 快照读走 undo 版本链 + ReadView,不加锁,读不被写阻塞 |
| 写 vs 写 | 行锁(record lock) | 同一行的并发更新必须串行 |
| 幻读(RR 级别) | 间隙锁 gap lock + 临键锁 next-key lock | next-key = 行锁 + 间隙锁,锁住一个**左开右闭**区间,别人无法在区间内 INSERT |
-- RR 隔离级别下,这条语句会锁住 (10, 20] 这个区间
SELECT * FROM orders WHERE id > 10 AND id <= 20 FOR UPDATE;
-- 别的事务此时 INSERT INTO orders VALUES (15, ...) 会阻塞 → 幻读被防住
四种隔离级别快速对照:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | ⚠️ 会 | ⚠️ 会 | ⚠️ 会 | 直接读最新页,不走 MVCC |
| READ COMMITTED | ✅ 防 | ⚠️ 会 | ⚠️ 会 | **每次**快照读生成新 ReadView |
| REPEATABLE READ(默认) | ✅ 防 | ✅ 防 | ✅ 防* | 事务内**复用**同一个 ReadView + next-key 锁 |
| SERIALIZABLE | ✅ 防 | ✅ 防 | ✅ 防 | 读也加共享锁,全部当前读 |
* RR 下快照读能防住大部分幻读,但「先快照读、再当前读/更新」的混合场景仍可能出现幻读现象,需要显式加锁。
11. binlog 做 PITR:真刀真枪的操作¶
PITR(Point-In-Time Recovery,时间点恢复) 的标准配方是:全量备份 + binlog 增量重放。
场景:2026-09-07 14:00 做了全量备份,15:23 有同事手滑执行了 DELETE FROM orders;,现在要把数据恢复到 15:22:59。
# ── 第 ① 步:定位。先人工翻 binlog,找到备份结束点和误操作点 ────────────
# ROW 格式的 binlog 是 base64 的,必须加 --base64-output=DECODE-ROWS -v 才可读
mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -v \
--start-datetime="2026-09-07 14:00:00" \
--stop-datetime="2026-09-07 15:30:00" \
/var/lib/mysql/binlog.000042 /var/lib/mysql/binlog.000043 | less
# 输出里会长这样,记下关键 position:
# # at 12345 ← 备份结束后的第一条(start-position)
# #260907 14:00:03 server id 1 end_log_pos 12456 GTID ...
# ...
# # at 67890 ← 那条 DELETE 的起点(stop-position)
# #260907 15:23:11 server id 1 end_log_pos 67999 Query DELETE FROM orders
# ── 第 ② 步:恢复全量备份 ─────────────────────────────────────────────
mysql -uroot -p < full_backup_20260907_1400.sql
# ── 第 ③ 步:用 position 精确重放到误操作【之前】────────────────────────
mysqlbinlog --no-defaults \
--start-position=12345 \
--stop-position=67890 \
/var/lib/mysql/binlog.000042 /var/lib/mysql/binlog.000043 \
| mysql -uroot -p --disable-log-bin
# └─ mysql 客户端选项:别把重放的语句再写一遍本实例的 binlog
# ── 第 ④ 步:校验 ────────────────────────────────────────────────────
mysql -uroot -p -e "SELECT COUNT(*) FROM orders;"
position 才是精确的,datetime 只是过滤器
--start-datetime / --stop-datetime 的实现只是**按事件时间戳做过滤**,它有几个坑:
- 同一秒内可能有几十上百个事务,你**切不到事务边界中间**;
- 事务的 binlog 是在**提交时刻**一次性写入的,一个长事务的所有 event 时间戳都相同,跨事务切分会不稳定;
- 主从时间戳、SET TIMESTAMP 等会干扰判断。
真正精确的边界是 --start-position / --stop-position(文件内字节偏移),它能精确停在某个事务的开始/结束。datetime 只适合用来**粗筛出要人工查看的范围**,找到 position 后必须换成 position 来做真正的恢复。
备份时如何拿到起始 position?用 mysqldump --source-data=2(8.0.26 前叫 --master-data=2),它会在导出的 SQL 文件里以注释形式写下 CHANGE MASTER TO MASTER_LOG_FILE='binlog.000042', MASTER_LOG_POS=12345;。用 xtrabackup 则在 xtrabackup_binlog_info 文件里。
💻 代码示例¶
SQL:观察三大日志与关键参数¶
-- ① 检查持久性配置(生产环境这两项都应该是 1,即「双 1」)
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW VARIABLES LIKE 'sync_binlog';
-- ② 检查 binlog 配置
SHOW VARIABLES LIKE 'log_bin'; -- ON 表示开启
SHOW VARIABLES LIKE 'binlog_format'; -- ROW / STATEMENT / MIXED
SHOW VARIABLES LIKE 'binlog_row_image'; -- CDC 场景需要 FULL
-- ③ 检查 redo log 容量(按版本选一个)
SHOW VARIABLES LIKE 'innodb_redo_log_capacity'; -- 8.0.30+
SHOW VARIABLES LIKE 'innodb_log_file_size'; -- 8.0.29 及以下
-- ④ 检查 doublewrite
SHOW VARIABLES LIKE 'innodb_doublewrite';
-- ⑤ 一个事务在三大日志里都留下了什么
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- undo log:记下 id=1 的 balance 旧值(逻辑)
-- redo log buffer:记下「X 号页 Y 偏移改成新值」(物理),尚未 fsync
-- Buffer Pool:该页变成脏页
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 1) redo log 写 prepare 并 fsync
-- 2) binlog 写入本事务全部 event + XID_event 并 fsync
-- 3) redo log 写 commit
-- ⚠️ 此刻 .ibd 数据文件里可能还是旧值!脏页等着后台刷
-- ⑥ 实时观察 redo / checkpoint / 脏页
SHOW ENGINE INNODB STATUS\G -- 看 LOG 段的 LSN 差值
SELECT POOL_ID, POOL_SIZE, FREE_BUFFERS,
DATABASE_PAGES, MODIFIED_DATABASE_PAGES -- 脏页数量
FROM information_schema.INNODB_BUFFER_POOL_STATS;
-- ⑦ 抓长事务(undo 无法 purge 的元凶)
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_modified, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started
LIMIT 10;
-- ⑧ binlog 运维
SHOW BINARY LOGS; -- 列出所有 binlog 文件及大小
SHOW MASTER STATUS; -- 当前文件 + position(8.4: SHOW BINARY LOG STATUS)
FLUSH BINARY LOGS; -- 强制切换新文件
PURGE BINARY LOGS BEFORE '2026-08-01 00:00:00'; -- 清理旧文件
Go:database/sql 里正确处理事务提交¶
package account
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
)
// ErrCommitUncertain 表示 COMMIT 的结果不确定(网络抖动 / 连接被杀)。
// 这种情况【绝不能】盲目重试业务,必须靠幂等键或对账兜底。
var ErrCommitUncertain = errors.New("commit result uncertain")
// Transfer 转账。演示 COMMIT 失败的语义,以及为什么不能重试 COMMIT。
func Transfer(ctx context.Context, db *sql.DB, from, to, amount int64) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead, // InnoDB 默认就是 RR
ReadOnly: false,
})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
// 关键写法:defer Rollback 兜底。
// Commit 成功后再调 Rollback 只会返回 sql.ErrTxDone,是无害的。
// 这样任何一条 return 路径(panic、err、业务失败)都能保证事务被清理,
// 避免连接泄漏和长事务把 undo log 撑爆。
defer func() {
if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
slog.Error("rollback failed", "err", rbErr, "from", from, "to", to)
}
}()
// 扣款:把「余额是否充足」的判断下推到 SQL,避免先 SELECT 再 UPDATE 的竞态
res, err := tx.ExecContext(ctx,
`UPDATE account SET balance = balance - ?
WHERE id = ? AND balance >= ?`, amount, from, amount)
if err != nil {
// 返回后 defer 触发 ROLLBACK,InnoDB 用 undo log 逆序撤销
return fmt.Errorf("debit: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("余额不足或账户不存在") // 业务失败,同样靠 ROLLBACK 保证原子性
}
if _, err = tx.ExecContext(ctx,
`UPDATE account SET balance = balance + ? WHERE id = ?`, amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
// ── COMMIT 的语义 ────────────────────────────────────────────────
// 返回 nil 意味着:redo log 已按 innodb_flush_log_at_trx_commit 的策略落盘,
// 且 binlog 也已按 sync_binlog 落盘(两阶段提交完成)。
//
// ⚠️ 但它【不代表】数据页(.ibd)已经落盘 —— 那些页很可能还是
// Buffer Pool 里的脏页,等着后台线程慢慢刷。这不影响正确性(WAL 保证了),
// 但如果你有「COMMIT 后立刻去文件系统层面备份 .ibd」的想法,那是错的。
if err := tx.Commit(); err != nil {
// COMMIT 失败时事务已经结束,【不能】再调 tx.Commit() 重试,
// 也不能假设「一定回滚了」—— 有可能是 redo/binlog 已落盘、
// 只是返回 OK 的网络包丢了(结果不确定)。
slog.Error("commit failed", "err", err, "from", from, "to", to, "amount", amount)
return fmt.Errorf("%w: from=%d to=%d amount=%d", ErrCommitUncertain, from, to, amount)
}
return nil
}
// TransferWithIdempotency 用幂等键兜住「COMMIT 结果不确定」的场景。
// 上游重试时带同一个 requestID,插入唯一索引冲突就说明上次其实成功了。
func TransferWithIdempotency(ctx context.Context, db *sql.DB,
requestID string, from, to, amount int64) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// 幂等表:request_id 上有 UNIQUE 索引
_, err = tx.ExecContext(ctx,
`INSERT INTO transfer_request(request_id, from_id, to_id, amount, status)
VALUES (?, ?, ?, ?, 'SUCCESS')`, requestID, from, to, amount)
if err != nil {
if isDuplicateKey(err) {
return nil // 上一次其实提交成功了,直接当成功返回
}
return fmt.Errorf("idempotency check: %w", err)
}
if _, err = tx.ExecContext(ctx,
`UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?`,
amount, from, amount); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err = tx.ExecContext(ctx,
`UPDATE account SET balance = balance + ? WHERE id = ?`, amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("%w: request_id=%s", ErrCommitUncertain, requestID)
}
return nil
}
func isDuplicateKey(err error) bool {
// 实际项目里用 go-sql-driver/mysql 的 *mysql.MySQLError,判断 Number == 1062
return err != nil && strings.Contains(err.Error(), "Error 1062")
}
Go 侧的三个高频错误
- 忘了
defer tx.Rollback()→ 出错路径上事务悬挂,连接不归还池,InnoDB 侧变成长事务,undo 暴涨。 - COMMIT 失败后重试 COMMIT → 事务已结束,只会拿到
sql.ErrTxDone,真正的数据可能已经提交了。 - 把
Rollback返回的sql.ErrTxDone当错误打日志告警 → 噪音,应该显式忽略。
⚠️ 常见陷阱¶
陷阱一:以为 COMMIT 成功 = 数据页已经落盘
错误认知:「事务提交了,.ibd 文件里就一定是新值了,我可以放心去拷贝数据文件做备份。」
真相:COMMIT 只保证 redo log(和 binlog)落盘,数据页此刻**极大概率还是 Buffer Pool 里的脏页**。这是 WAL 的设计初衷——用小的顺序写换掉大的随机写。
规避:物理备份必须用 xtrabackup(它会在备份期间持续抓取 redo 并在恢复阶段重放),或者 FLUSH TABLES ... FOR EXPORT 配合刷脏。绝不能直接 cp 运行中的数据文件。另外,「COMMIT 后立刻查从库能看到吗」也是另一回事——那取决于复制延迟,跟脏页无关。
陷阱二:为了性能把 innodb_flush_log_at_trx_commit 调成 0 或 2
错误认知:「压测发现 TPS 上不去,把 innodb_flush_log_at_trx_commit 改成 0,性能翻倍,上线!」
真相:改成 0 或 2 后,OS 崩溃 / 整机断电会丢失最近约 1 秒内所有「已向客户端返回成功」的事务。客户端收到 COMMIT OK,你以为数据在,其实它还在 mysqld 进程内存(0)或 OS page cache(2)里。对账时你会发现「用户扣款成功了但订单没生成」这类脏账,极难追查。
规避:金融、订单、账户、库存类库必须是 1(配合 sync_binlog = 1 的「双 1」)。真要提性能,方向应该是:升级 SSD/NVMe、开组提交、合并小事务为批量事务、优化 SQL 减少写入量,而不是牺牲持久性。只读从库、可重跑的 ETL 临时库才适合调低。
陷阱三:混用 redo log 和 binlog 的作用
错误认知:「binlog 也能重放,所以崩溃恢复靠 binlog 就行」/「redo log 记录了所有修改,主从复制用它就够了」。
真相:两者职责**完全不重叠**——
| 需求 | 该找谁 |
|---|---|
| MySQL 崩溃/断电后数据不丢 | redo log(InnoDB 自动,你碰不到) |
| 搭主从复制、读写分离 | binlog |
| 误删数据回到某个时间点(PITR) | binlog + 全量备份 |
| 审计「几点谁改了什么」 | binlog(mysqlbinlog) |
| CDC 同步到 Kafka/ES/Redis | binlog(Canal/Maxwell/Debezium) |
| redo 空间不够、checkpoint 卡顿 | redo log(调 innodb_redo_log_capacity) |
规避:记死一句话——崩溃恢复找 redo,复制和时间点恢复找 binlog。redo 是环形覆盖的没有归档能力,永远做不了 PITR;binlog 是 Server 层逻辑日志不知道页状态,永远做不了 crash-safe。
陷阱四:只开 binlog、关掉/忽视 redo,以为就能保证 InnoDB 崩溃恢复
错误认知:「我把 binlog 开得好好的,sync_binlog=1,InnoDB 的 redo log 调小甚至想办法关掉也无所谓吧?」
真相:binlog 根本不具备崩溃恢复能力。 原因有三: 1. binlog 是 Server 层逻辑日志,它不知道 InnoDB 的哪个页刷过盘、哪个页还是脏页,无法判断该重放哪些; 2. binlog 只在**事务提交时**才写,事务执行**中途**崩溃的那些部分修改,binlog 里一个字都没有; 3. binlog 描述的是「逻辑变更」,重放它需要重新执行 SQL(涉及索引、约束、自增),而不是「把字节写回页」——这在恢复语义上既慢又不幂等。
另外,redo log 在 InnoDB 里**关不掉**(innodb_redo_log_capacity 只能调大小)。真正常见的事故是**把它调得太小**:写入高峰时 write pos 迅速追上 checkpoint,MySQL 被迫**阻塞所有更新**去刷脏页,表现为「整库无规律卡顿几秒」。
规避:redo log 容量按「实例峰值写入速率 × 期望的刷脏窗口」估算,生产常见 1~4GB。同时**不要关 innodb_doublewrite**——redo 是逻辑修改的重放,治不了 16KB 页只写了 4KB 的物理撕裂,那一层必须由 doublewrite 兜底。
🏋️ 练习题¶
练习 1:客户端收到 COMMIT 返回成功,此时 .ibd 数据文件里一定是新值吗?为什么 InnoDB 敢这么设计?
提示:想想 WAL 的三条好处,以及脏页是什么时候刷盘的。
答案
不一定,而且大概率不是。
COMMIT 成功只保证 redo log 已经按 innodb_flush_log_at_trx_commit 的策略落盘(=1 时是 fsync 到磁盘),以及 binlog 按 sync_binlog 落盘。数据页此刻通常还是 Buffer Pool 里的脏页,等着后台刷脏线程或 checkpoint 推进时才批量写回 .ibd。
InnoDB 敢这么设计,是因为 WAL 把持久性的责任从数据页转移到了日志上:
1. 随机写变顺序写——一个事务可能改多个分散的页,刷数据页是多次随机 IO;redo log 是追加写单个文件,纯顺序 IO。
2. 一次提交只 fsync 一个文件——fsync 是最贵的系统调用,WAL 让每次 COMMIT 只需要保证一份日志落盘。
3. 脏页可以合并 + 批量 + 延迟——同一页在内存改 100 次只需刷盘 1 次,还能攒到业务低峰期刷。
崩溃时靠**重放 redo log** 就能把脏页的修改重建出来,所以延迟刷盘不破坏持久性,只影响「物理备份不能直接 cp 文件」这类运维约束。
练习 2:一个事务的 redo log 已写成 prepare,binlog 也完整写入并 fsync 了,但还没来得及把 redo 标记成 commit,此时机器断电。重启后这个事务是提交还是回滚?为什么裁决权在 binlog 而不是 redo?
提示:想想 binlog 写完的那一刻,可能已经有谁把它拿走了。
答案
判定为已提交。
崩溃恢复时,InnoDB 扫描 redo log 发现该事务处于 prepare 状态,就拿它记录的 XID 去 binlog 里查找对应的 XID_event: - 找到且完整 → 判定已提交,把 redo 标记为 commit,保留第 ② 阶段前向重放的结果; - 找不到或不完整 → 判定未提交,用 undo log 回滚。
本例中 binlog 完整,所以**提交**。
为什么以 binlog 为准? 因为 binlog 一旦写完并 fsync,就可能已经被从库的 IO 线程拉走、被 Canal 消费掉,这是不可撤销的既成事实。如果主库这时选择回滚,从库重放 binlog 后就会多出这笔数据,主从永久不一致,用 binlog 做 PITR 恢复出的库也会和主库不一样。而 redo log 只在本机,怎么处理都不影响外部——所以让本机迁就外部,才能保证全局一致。
反过来,如果 redo 已 prepare 但 binlog 缺失,主库必须回滚,因为从库那边压根没有这笔数据。
练习 3:既然有了 redo log 就能崩溃恢复,为什么还需要 doublewrite buffer?
提示:redo log 是「物理日志」,它记录的粒度是什么?它假设了什么前提?
答案
因为 redo log 治不了「页只写了一半」的物理损坏(partial page write)。
InnoDB 的页是 16KB,而文件系统的块通常是 4KB。刷一个脏页的过程中如果断电,这个页就可能只写进去了前 8KB——处于**半新半旧的撕裂状态**,checksum 校验直接失败。
redo log 是物理日志,它记录的是「第 X 号页、偏移 Y 处、改成 Z」,这**默认假设页的其余部分是正确完整的**。在一个已经损坏的页上重放 redo,只能修复那几个字节,整页依然是垃圾,甚至可能让损坏扩散。
doublewrite 的工作方式:刷脏页时先把页**顺序追加写**到 doublewrite 区域(2MB,8.0.20 起是独立的 .dblwr 文件)并 fsync,然后才随机写到表空间的真实位置。恢复时如果检测到某个页损坏,就**先从 doublewrite 里找到那个页的完整副本覆盖回去,再重放 redo log 应用后续修改**。
顺序写 doublewrite 的开销很小(顺序 IO),换来的是页级别的原子性兜底。所以恢复流程是两层的:doublewrite 修物理损坏 → redo log 重放逻辑修改,缺一不可。 除非底层存储自带原子写保证(某些企业级 SSD、ZFS 这类 COW 文件系统),否则不要关闭 innodb_doublewrite。
🔗 相关链接¶
- MySQL 官方文档 · Redo Log / WAL — redo log 与预写日志的权威说明
- MySQL 官方文档 · The Binary Log — binlog 格式、生命周期与复制
- MySQL 官方文档 · innodb_flush_log_at_trx_commit — 0/½ 三种取值的官方定义
- MySQL 官方文档 · Doublewrite Buffer — 部分页写入与双写缓冲
- MySQL 官方文档 · Undo Logs — undo 表空间与 purge
- MySQL 官方文档 · mysqlbinlog — PITR 用到的全部命令行参数
- MySQL 官方文档 · Point-in-Time Recovery — 全量备份 + binlog 增量恢复的标准流程
- alibaba/canal — 伪装成 MySQL 从库、解析 binlog 做 CDC 的阿里开源项目
- 多级缓存与读写策略 — 本站文章:缓存层的持久化与一致性取舍,可与本文的 WAL 思路对照阅读