跳转至

MySQL ACID 与三大日志

💡 一句话概述

原子性靠 undo log(后悔药),持久性靠 redo log + WAL(备忘录),隔离性靠锁 + MVCC,一致性是前三者加约束共同达成的「结果」而不是一个独立模块。 而 redo log(引擎内部保命)和 binlog(对外广播的账本)之间必须靠**两阶段提交**对账,否则主从数据会不一致。


🔑 核心概念

  1. WAL(Write-Ahead Logging,预写日志) — 先写日志再写数据页。因为日志是顺序追加写、数据页是随机写,一次提交只需要 fsync 一个日志文件,脏页可以延迟批量刷盘。这是 InnoDB 高性能的地基。
  2. undo log(后悔药) — 逻辑日志,记录「怎么把数据改回原样」。两个用途:① 回滚保证原子性;② 撑起 MVCC 的多版本链。
  3. redo log(备忘录) — 物理日志,记录「第几号页、偏移多少、改成什么」。InnoDB 引擎层独有,固定大小的**环形缓冲**循环写,专门用于崩溃恢复。
  4. binlog(对外广播的账本) — MySQL Server 层**的逻辑日志(所有引擎都有),**追加写、写满换新文件、永不覆盖。用于主从复制、PITR 时间点恢复、审计、CDC。
  5. 两阶段提交(内部 XA) — redo 写 prepare → 写 binlog → redo 写 commit。崩溃恢复时**以 binlog 是否完整为唯一裁决依据**,保证两份日志记录的事务集合完全一致。
  6. 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 时:

  1. 把目标页读进 Buffer Pool(如果不在);
  2. 在内存里直接改;
  3. 把这个页标记为**脏页(dirty page)**——「内存里的版本比磁盘上的新」;
  4. 至于什么时候把脏页刷回 .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 — 最后修改这行的事务 ID
  • DB_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,而是**对页的物理修改**,概念上长这样:

(space_id = 34, page_no = 17, offset = 112, len = 8, data = "900")
    ↑表空间号      ↑第几号页     ↑页内偏移     ↑改多长    ↑改成什么

意思是:「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 log 写完 ✅
② ← 此刻崩溃
③ binlog 没写 ❌

重启后:主库靠 redo 恢复出了这笔数据(主库有),但 binlog 里没有 → 从库永远同步不到(从库没有),用 binlog 做 PITR 恢复出来的库也没有。主从不一致。

反例 B:先写 binlog,再写 redo

① binlog 写完 ✅
② ← 此刻崩溃
③ redo log 没写 ❌

重启后:主库因为 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 侧的三个高频错误

  1. 忘了 defer tx.Rollback() → 出错路径上事务悬挂,连接不归还池,InnoDB 侧变成长事务,undo 暴涨。
  2. COMMIT 失败后重试 COMMIT → 事务已结束,只会拿到 sql.ErrTxDone,真正的数据可能已经提交了。
  3. 把 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。


🔗 相关链接