Skip to content
日志体系

日志体系

MySQL 的"不丢数据"不是一句口号,而是三条日志(redo / undo / binlog)加上两阶段提交与双写缓冲共同兜底的结果。这一章把写入路径、崩溃恢复的判定规则、以及"改了某个参数到底会丢什么"讲透。

基线:MySQL 8.4 LTS(文中以 8.4.13 为准;关键参数的版本分水岭会单独标注)。


1. 三条线:三种日志各管各的事

InnoDB 世界里同时存在三份"记账本",新手最容易把它们混为一谈。先用一张表建立坐标系:

维度 redo log undo log binlog
所在层次 InnoDB 引擎层 InnoDB 引擎层 Server 层
日志类型 物理日志(哪个页、哪个偏移、改成了什么) 逻辑日志(改动前的旧值,供反向操作) 逻辑日志(SQL 语句或行镜像)
写入方式 固定容量、环形顺序追加 写入 undo 表空间,可在线截断 文件顺序追加,写满滚动换新文件
写入时机 事务执行期间就写,提交时先落 prepare 修改每行之前先记旧值 执行时写会话缓存,提交阶段整体落盘
目的 崩溃恢复:把"没来得及落盘的页改动"补回来 回滚(原子性);MVCC 版本链的载体 主从复制、归档恢复、闪回
谁消费 InnoDB 崩溃恢复 InnoDB 回滚、一致性读 从库 SQL 线程、DBA 的 mysqlbinlog、备份/闪回工具

一个帮助记忆的类比:redo 是施工队的工程日志(“今天把 3 号楼 5 层的墙刷白了”,坏了照着重建),undo 是退料单(“刷之前是米黄色”,返工照着还原),binlog 是房产交易中心的过户记录(对外公示用,别人只认这份)。redo/undo 只服务于 InnoDB 自己,binlog 则服务于整个 MySQL 体系——所有存储引擎共享同一份,Server 层统一来写。

注意:undo 的"多版本"身份(版本链、ReadView 如何沿 undo 指针回溯)是 06-MVCC.md 的主题。本章只谈 undo 作为"日志"的两件事:回滚与崩溃恢复。


2. WAL:为什么先写日志,再写页

2.1 直接刷页行不行

假设没有日志,每次 UPDATE 直接改数据文件,会遇到三个问题:

  1. 随机 IO。InnoDB 以 16KB 页为单位管理数据。改一行,哪怕只动 10 个字节,也要把整个 16KB 页读出来、改掉、再写回去,而且不同行的页散落在表空间各处——一个热点写业务会打出满盘随机 IO,磁盘吞吐直接崩溃。
  2. 写放大。10 字节的改动放大成 16KB 的回写量,放大三个数量级。
  3. 部分页写危险。页写到一半断电,落盘的是"半新半旧"的脏页(第 7 节详述)。

2.2 WAL 的交换逻辑

Write-Ahead Logging 的核心是一次交换:把对数据文件的随机写,换成对日志文件的顺序写。日志条目只记录"改动差量",天然比整页小得多;顺序追加又是机械盘和 SSD 都最擅长的模式。数据页的落盘(刷脏)推迟到后台异步完成,只要日志先落盘,页丢了也能从日志里重建。

redo log 正是这份日志:内容是 (space_id, page_no, offset, 新内容) 级别的物理记录,按 mini-transaction(mtr)为单位追加写入。

2.3 环形结构与 LSN

redo 的总容量是固定的(innodb_redo_log_capacity),写满最老的空间后必须回头覆盖,因此它本质上是一个环。贯穿全程的坐标叫 LSN(Log Sequence Number):每写一条日志,LSN 单调递增;每个页、每个事务、每条日志记录也都带着自己"写到了哪个 LSN"的标记。

 LSN 0 ──────────────────────────────────► 当前 LSN(持续增长)

 [ 已刷脏、可回收 ][ 日志已落盘、页还没落盘 ][ 空闲 ]
                  ▲              ▲         ▲
            checkpoint_pos   flush_pos   write_pos
            (恢复起点)   (日志已进OS页缓存)(日志已进log buffer外)

三个位点的含义:

  • checkpoint(检查点)位点:这个位点之前的所有页改动都已安全落数据文件,redo 里对应空间可以回收复用;崩溃恢复也从这里开始往后重放。
  • flush 位点:日志已写入操作系统页缓存的位置。
  • write 位点:log buffer 里最新日志的位置(对应"当前 LSN")。

checkpoint age = 当前 LSN - checkpoint 位点,表示"崩溃后还要重放多少日志"。checkpoint 推不动(脏页刷得太慢)而 write 位点一路追上来,环就会写满——此时 InnoDB 会强行暂停前台写入、猛刷脏页来推进 checkpoint,表现为业务周期性卡顿。InnoDB 内部有一组提前加速刷脏的水位(5.7 时代约为 redo 用量 3/4 处进入"steady"状态、逼近写满时进入"danger"状态;8.x 改为异步 checkpoint 机制,行为更平滑,具体阈值属内部优化,版本间会变),思路始终是:别等环写满才刷页。

注意:redo 容量不是越大越好。容量大意味着卡顿少,但崩溃恢复要重放的日志也更多,恢复时间与 checkpoint 之后写入的 redo 量成正比。要的是"够用 + 能快速推进 checkpoint"的平衡,详见第 9 节。


3. redo 管理:8.4 的参数形态

3.1 文件与容量的演进

版本 redo 文件命名 容量控制方式
5.7 及以前 ib_logfile0 / ib_logfile1 innodb_log_file_size × innodb_log_files_in_group,均为静态参数
8.0 ~ 8.0.29 仍是 ib_logfile0 / ib_logfile1(本地 8.0.28 datadir 实测) 同上(旧参数仍是主口径)
8.0.30 起 改名 #ib_redo1、#ib_redo2…(可在线增删文件) 新增 innodb_redo_log_capacity;innodb_log_file_size / innodb_log_files_in_group 被标记弃用但未移除
8.4 #ib_redoN 统一使用 innodb_redo_log_capacity;旧参数仍在但已弃用,新配置不要再用

5.7 时代调整 redo 大小是出了名的折磨:参数是静态的,而且旧文件尺寸和新配置不匹配时 InnoDB 拒绝启动,运维手册上的标准动作是"关库 → 把 ib_logfile* 挪走 → 改配置 → 重新初始化开库"——一次容量调整等于一次停机窗口。innodb_redo_log_capacity 解决的正是这个痛点:动态参数,运行中随时改,InnoDB 自己创建新文件、旧文件用尽后自动退役:

-- 8.4:在线把 redo 扩到 512MB
SET GLOBAL innodb_redo_log_capacity = 512 * 1024 * 1024;

-- 观察 checkpoint 是否跟得上:关注两行
SHOW ENGINE INNODB STATUS\G
-- ... Log sequence number 12345678901
-- ... Last checkpoint at  12344000000

Log sequence number - Last checkpoint at 的差值就是 checkpoint age。如果它在写入高峰期逼近 redo 容量的大半,说明刷脏跟不上,要么扩容量,要么查磁盘能力(innodb_io_capacity,见 11-备份恢复与性能调优.md)。

8.0.16 起还有两个直接干预 checkpoint 的动态旋钮:innodb_log_checkpoint_now(临时置 ON 可强制推进一次检查点,适合"接下来有一段已知的高压写入,先把脏页刷掉"或把脏页清理留到窗口期)与 innodb_log_checkpoint_fps(给后台检查点设定目标推进速率,即希望 redo 位点每秒前移多少)。它们是精细调优工具,默认值(关闭/自动)适合绝大多数实例,动之前先用 checkpoint age 曲线证明默认策略确实跟不上。

3.2 log buffer 与提前刷盘

事务执行期间 redo 先进内存环形缓冲 innodb_log_buffer_size(8.0 起默认 16MB,5.7 时代默认更小;8.0 起为动态参数)。提交时按策略决定写不写透到磁盘——这正是 innodb_flush_log_at_trx_commit 的地盘。

buffer 本身还有另一个触发路径:当写入快于刷出、log buffer 使用量超过约一半(业界通行的说法是 60% 左右,属内部优化阈值,各版本实现不同)时,后台日志线程会提前把 buffer 里的日志写入 OS 页缓存,不等提交。若 log buffer 过小、大事务集中写入,前台事务会出现等日志刷完的排队(SHOW ENGINE INNODB STATUS 中日志段的状态可佐证),线上偶尔能看到调大此项后毛刺消失的案例。

3.3 三档刷盘:0 / 1 / 2

-- my.cnf(静态视角;该参数实际是动态的)
[mysqld]
innodb_flush_log_at_trx_commit = 1   # 默认值

提交时的两种动作——“写”(把 log buffer 内容交给 OS 页缓存)与"刷"(对文件调用 fsync,落物理盘)——按档位组合:

取值 每次提交:写 OS cache 每次提交:fsync mysqld 进程崩溃 主机/OS 崩溃
0 否 否 丢最近约 1 秒已提交事务 丢最近约 1 秒已提交事务
1 是 是 不丢 不丢
2 是 否 不丢 丢最近约 1 秒已提交事务
        mysqld 进程内                OS 页缓存                 磁盘
 log buffer ──"写"──► redo 文件映像 ──"刷(fsync)"──► 落盘
     ▲                    ▲
     │                    └── 只有"主机崩溃"才丢这段:进程崩溃时数据已在内核手里
     └── "进程崩溃"丢这段:内存全没

看懂这张图就看懂了 0/1/2:=2 防进程崩溃不防主机崩溃(提交时日志已交给内核,进程死了内核还替它保管着,重启 mysqld 后照常恢复);=0 两者都不防(提交时连内核都不给,只靠后台线程约每秒写一次),进程一死,buffer 里没交出去的日志随之消失。

注意:开启 binlog 时,=0 / =2 的刷盘时机与 binlog 组提交存在细微联动,不同小版本的实际行为存在差异。生产上要精确评估"到底丢多窗口",请在目标版本实测(见 11-备份恢复与性能调优.md 的演练方法),本章的表格描述的是经典且大体正确的行为模型。

3.4 delayed durability 的另一个旋钮

8.0.18 起还有 innodb_flush_log_at_timestamp:按"秒级时间戳"批量刷日志,把提交点的 fsync 摊到时间窗边界,属于 =2 思路的细化版(丢失窗口上限=指定时间窗)。它默认关闭、使用场景小众,且开启时与 innodb_flush_log_at_trx_commit 互斥(后者会被视为 0),了解即可,不建议在未压测的生产启用。


4. undo:它的"日志身份"

undo 记录的是改动前的旧值,用途只有两个:把没提交的事务撤干净(原子性),以及在崩溃恢复时回滚那些"日志里出现过但最终没提交"的事务。按操作类型:

对行做了什么 undo 里记什么 回滚时执行什么
INSERT 新行的主键(含事务 ID) 按主键删掉这行
DELETE 被删行的完整内容 重新插回
UPDATE 被修改列的旧值 写回旧值

undo 本身也要落盘、也怕丢——所以对 undo 的写入同样被 redo 保护。这是日志体系里容易忽略的递归:redo 不只保护数据页,也保护 undo 页和各类内部页的改动。

物理位置上,undo 存在专门的 undo 表空间(8.0 中默认为独立文件 undo_001、undo_002,不再挤在 ibdata 里)。8.0 的另一个关键改进是 undo 可以在线截断:当某个 undo 表空间体积超过 innodb_max_undo_log_size(默认 1GB)且长期不再被活跃事务引用时,innodb_undo_log_truncate=ON 允许 InnoDB 把它清空复用——5.7 时代 undo 只增不减、撑大 ibdata1 又无法收缩的死结就此解开。

-- undo 截断相关参数(8.4)
SHOW VARIABLES LIKE 'innodb_undo%';
-- innodb_undo_log_truncate          ON
-- innodb_max_undo_log_size          1073741824
-- innodb_undo_retention             0(超时保护,谨慎调整)

undo 膨胀的元凶永远是长事务:事务不结束,它需要的回滚段和版本链就得一直留着,purge 线程也无法清理。SHOW ENGINE INNODB STATUS 里的 History list length 持续走高,就该去 information_schema.innodb_trx 里抓那个跑了半天的会话了。版本链与 ReadView 的机制细节见 06-MVCC.md,长事务的更多危害见 05-事务与锁.md。

注意:崩溃恢复时间与 undo 量直接相关——恢复时 InnoDB 要先把所有事务的页改动 redo 重放一遍,再对未提交事务逐个 undo 回滚。一个开着几小时、改了百万行的巨型事务留下的 undo,会让重启后的库"看起来卡死"很久。


5. binlog:主从与归档的命脉

5.1 定位与文件管理

binlog 是 Server 层的追加式逻辑日志,记录"这个库发生过哪些改动",与存储引擎无关。它是主从复制的唯一数据源(10-主从复制与高可用.md)、归档/闪回的基础(11-备份恢复与性能调优.md),也常是 DBA 追责任的第一现场。

版本分水岭:8.0 起 binlog 默认开启;5.7 及以前默认关闭,主从配置时忘了开 log_bin 是经典事故。

# my.cnf(8.4 典型配置)
[mysqld]
log_bin                       = mysql-bin   # 8.0 默认已开,显式写更清晰
server_id                     = 1           # 主从架构内每个实例必须唯一
max_binlog_size               = 1G          # 单文件上限,写满滚动
sync_binlog                   = 1           # 每次事务提交 fsync binlog
binlog_expire_logs_seconds    = 2592000     # 保留 30 天(默认值)
binlog_format                 = ROW

管理要点:

  • 滚动:binlog 在事务边界处切新文件。一个巨型事务即使超过 max_binlog_size,也不会被拦腰拆到两个文件里。
  • 过期清理:binlog_expire_logs_seconds(默认 2592000,即 30 天)取代了已弃用的 expire_logs_days。过期是后台自动删,但删的是"最老的",不看从库有没有消费完。
  • 手动清理:
-- 回收磁盘:清理某时间点之前的 binlog(确保备份/从库已覆盖该区间再做)
PURGE BINARY LOGS BEFORE '2026-10-01 00:00:00';

注意:PURGE 掉的 binlog 若某从库的 IO 线程还没读完,复制立刻中断(Could not parse relay log event / master has purged binary logs);基于 binlog 的增量备份链也会断。删之前先确认所有消费方(从库、canal/Debezium 一类订阅工具、备份平台)的位点。RESET MASTER 直接清空全部 binlog 并重编号,生产主库严禁随手执行;8.4 提供了更受控的同类操作(如 PURGE BINARY LOGS AND GTIDS TO ...),用前查对应版本文档确认行为。

5.2 三种格式:statement / row / mixed

STATEMENT:把执行的原始 SQL 记下来,从库重放同样的 SQL。

#49 Query thread_id=7 exec_time=0 error_code=0
UPDATE orders SET status='paid' WHERE user_id=42;
  • 优点:日志小(一条改百万行的 UPDATE 也只记一句话)、可读、能直接看懂发生了什么。
  • 致命弱点:不确定性。NOW() / UUID() / RAND()(未用可复现种子时)在主库执行一次、从库重放时再算一次,结果不同;没有 ORDER BY 的 LIMIT 取哪几行取决于执行顺序;多表 UPDATE 的匹配行顺序不保证。这些"unsafe statement"在复制里会造成主从静默漂移——不报错,只是数据悄悄不一样了。
  • 另一个隐性成本:从库重放 SQL 是重新执行一遍,优化器可能选不同索引,复制延迟不可控。

ROW:不记 SQL,按行记录改动前后镜像。5.7 起成为默认格式(8.4 沿用)。

### UPDATE `shop`.`orders`
### WHERE
###   @1=1001
###   @2='pending'
### SET
###   @1=1001
###   @2='paid'
  • 优点:确定性。改了哪几行、从什么变成什么,白纸黑字,重放不依赖任何函数求值,天然杜绝主从不一致;before image 还是闪回(误删恢复)的原料;8.0 并行复制的 WRITESET 依赖追踪(binlog_transaction_dependency_tracking 自 8.0.26 起默认 WRITESET;8.0.35 起弃用,8.4 起内部恒为 WRITESET)也建立在行事件之上,并行度显著更好。
  • 代价:体积。UPDATE ... WHERE 圈中一千万行时,binlog 要写一千万组行镜像;宽表/大字段场景更夸张。控制手段是 binlog_row_image:默认 FULL 记录整行所有列,MINIMAL 只记主键(或第一个可用唯一键)加实际变更列,UPDATE/DELETE 的体积能小一个量级。
  • 8.0 对 JSON 大字段还有 binlog_row_value_options=PARTIAL_JSON,只记 JSON 内部改动的片段。

MIXED:默认走 STATEMENT,InnoDB 检测到 unsafe statement 时自动对该语句切 ROW。

SET GLOBAL binlog_format = 'MIXED';

折中意味着两头都不彻底:日志体积不好预估,同一个库的 binlog 一半语句一半行事件;按时间点恢复时对"到底是哪种格式"要格外小心,闪回工具也拿不到完整 before image。它是 5.1/5.5 时代"想省空间又怕复制不一致"的历史答案,如今磁盘便宜,生产直接 ROW(默认即是),没有特殊理由不必换成 mixed/statement。

注意:把 binlog_row_image 改成 MINIMAL 前想清楚——省的是磁盘,废的是闪回。误 DELETE 之后靠 binlog 反向生成 INSERT 的前提是 before image 里有完整行。

5.3 事件类型速览

ROW 格式下一个事务在 binlog 里是一串事件(event)。mysqlbinlog 是解析工具:

mysqlbinlog --base64-output=DECODE-ROWS -v ./data/mysql-bin.000042 | less
#48 BEGIN
#49 Table_map: `shop`.`orders` mapped to number 108
#50 Update_rows: table id 108 flags: STMT_END_F
### UPDATE `shop`.`orders`
### WHERE ... ### SET ...
#51 Xid = 12345
COMMIT/*!*/;

常遇到的几类:

事件 作用
Query_event 会话上下文(charset 等)、BEGIN/DDL 语句本体
Table_map_event 逻辑表 → table id 的映射(行事件靠它知道改的哪张表)
Write_rows / Update_rows / Delete_rows 行镜像:插入 / 前后镜像 / 删除
Xid_event 事务提交标志,携带 trx_id——与 redo 里 prepare 记录的那个 trx_id 配对,是两阶段提交对账的锚点(第 6 节)
Format_description / Previous_gtids 文件头类事件

-v(verbose)会把行事件反向"拼"成伪 SQL 注释,方便人读;SHOW BINLOG EVENTS IN 'mysql-bin.000042' LIMIT 20 可在库内快速看事件流与位置(position)。

5.4 binlog cache:每连接一份

事务执行期间的 binlog 内容并不直接写文件,而是先进会话级的 binlog cache(binlog_cache_size 默认 32KB,动态、可会话级调整)。提交时,整个事务的日志一次性刷入 binlog 文件(再由 sync_binlog 决定是否 fsync,见下节)。

为什么要有 cache:binlog 文件是所有会话共用的追加序列。若事务边执行边写,A、B 两个并发事务的事件会交错,从库根本无法还原事务边界。cache 把"写文件的原子性"攒到提交一刻,binlog 里的事务顺序 = 提交顺序,且每个事务完整连续。

大事务超过 cache 时,溢写到临时文件,提交时再从临时文件灌入 binlog——功能无损,但多了两份磁盘 IO。观察指标:

SHOW GLOBAL STATUS LIKE 'binlog_cache%';
-- Binlog_cache_use       事务用 cache 的次数
-- Binlog_cache_disk_use  溢写临时文件的次数

disk_use 持续增长,先别急着调大 binlog_cache_size——更该问的是"为什么要提交一个百万行的大事务",拆小永远是第一方案(第 10 节)。

非事务引擎的语句另有 binlog_stmt_cache_size,8.4 场景罕见,不展开。


6. 两阶段提交:不丢数据也不错乱的核心

6.1 为什么需要 prepare

现在局面清楚了:redo 归 InnoDB 管(决定页改动能否恢复),binlog 归 Server 层管(决定从库和归档能否看到这笔账)。两份日志,两套世界观,一次提交要在两边同时成立,要么都在、要么都不在——否则:

  • redo 有、binlog 无:主库恢复后这笔事务"存在",从库和备份里却"没发生过",主从漂移。
  • binlog 有、redo 无:从库执行了这笔事务,主库重启却把它"吐"了回去,同样漂移,且这次是"活见鬼"方向。

而崩溃随时可能落在两份日志写作的中间。解决办法是经典的两阶段提交(2PC,此处是 InnoDB 内部事务与 binlog 的协调,不是分布式 XA):给 redo 增加一个 prepare 中间状态,把"提交事实"的唯一裁判权交给 binlog。

为什么 prepare 能成立,关键在于两份日志的性质不同:

  • redo 允许"写到一半":它是引擎的私有账本,崩溃后从任意断点续写/续读都没问题,所以它可以携带 prepare 这种未完成状态,并保证恢复时能扫描出"哪些事务处于 prepared"。
  • binlog 不允许"半截事务":它是对外发布的流水,从库按顺序重放,绝不能出现残缺事务;所以 binlog 只在提交阶段一次性写入完整事务,写完(fsync 后)即为历史事实。

恢复对账(第 6.3 节)因此变得可能:拿着 redo 里的 prepared 名单去 binlog 里查 Xid,账对得上就提交,对不上就回滚。

注意:别把这里的 2PC 和 XA START 那套分布式事务混为一谈。本章说的是单机内部 Server 层(binlog)与 InnoDB(redo)两个参与者之间的协调,对用户透明、没有"prepare 后悬挂"的业务语义;而外部 XA 事务(XA PREPARE 后由应用决定 XA COMMIT)跨会话甚至跨实例,恢复时 prepared 分支会悬挂等待裁决,行为完全不同。两者共用 InnoDB 的 prepare 能力,但不是同一件事。

6.2 一条 UPDATE 的完整时序

以最直白的方式走一遍(innodb_flush_log_at_trx_commit=1、sync_binlog=1):

 客户端        Server 执行器          InnoDB(BP/undo/redo)           binlog
   │ UPDATE t SET ... │                      │                          │
   ├─────────────────►│                      │                          │
   │                  │ 1. 取页:命中 BP 直接用,否则读盘               │
   │                  ├─────────────────────►│                          │
   │                  │ 2. 修改前先把旧值写 undo 页                     │
   │                  ├─────────────────────►│                          │
   │                  │ 3. 更新 BP 中的内存页,同时生成 redo(mtr)       │
   │                  ├─────────────────────►│  (进 log buffer)       │
   │                  │ 4. 把本事务的 binlog 写入会话 cache             │
   │                  ├──────────────────────────────────────────────► │(cache)
   │                  │ ── 提交,进入 2PC ──                            │
   │                  │ 5. redo:事务标记 prepare,log buffer 落盘+fsync│
   │                  ├─────────────────────►│ [redo: prepared]         │
   │                  │ 6. binlog cache 整体写入 binlog 文件,fsync      │
   │                  ├──────────────────────────────────────────────►│ [binlog: BEGIN..Xid]
   │                  │ 7. redo:写 commit 标记(不单独 fsync,          │
   │                  │    搭后续日志的车落盘)                          │
   │                  ├─────────────────────►│ [redo: commit]           │
   │◄─────────────────┤ OK                                            │

两个常被追问的细节:

  • 第 7 步为什么可以不刷盘:commit 标记丢不了真相——只要第 6 步 binlog 落盘成功,恢复对账时"binlog 有完整事务"就足够裁定提交;redo 里那个 commit 标记只是省一次 fsync 的锦上添花。这正是"以 binlog 为准"的体现。
  • 为什么第 5 步先 fsync redo:prepare 必须先持久化,否则崩溃后 InnoDB 连"有过这个事务"的线索都没有,无从对账。

6.3 崩溃恢复判定表

恢复扫描 redo,把带 prepare 标记的事务挑出来,逐个去 binlog 对账:

redo 有 prepare binlog 有完整事务(Xid) 判定
无 ——(不该出现) 事务未完成,忽略
有 有 提交(即使 redo 没有 commit 标记,见上)
有 无 回滚(用 undo 撤销它的页改动)

顺序上,恢复流程是"先 redo 重放、后 undo 回滚":checkpoint 之后的所有 redo(含未提交事务的页改动)先全部重放到数据页,让状态回到崩溃瞬间,再按上表对未提交事务执行 undo 回滚。所以 redo 里出现脏事务的日志并不矛盾——它会被重放,再被撤销。

拿 6.2 的时序图逐步"拔电源",每种落点各推演一遍:

崩溃落在 redo 状态 binlog 状态 恢复判定 客户端视角
第 5 步之前 无 prepare 无 视作没发生(页改动即便已刷盘也会被 undo 撤掉) 未收到 OK,事务不存在,安全
第 5、6 步之间 有 prepare 无(或只有半截事件组,无 Xid) 判定回滚 未收到 OK,事务不存在,安全
第 6 步 fsync 之后、第 7 步之前 有 prepare,无 commit 标记 有完整事务 + Xid 判定提交 未收到 OK 但事务已生效——客户端必须重试幂等或由对账发现,见下注
第 7 步之后 有 prepare + commit 有完整事务 正常提交,无需对账 已收到 OK

注意 第三行是 2PC 唯一留下的语义缝隙:binlog 已落盘(从库也照常收到)而客户端没拿到 OK。MySQL 无法替你把"应答"补发,所以钱货两讫的业务必须以幂等键 + 事务内写入的 binlog 可查来兜底——重试同一笔操作时靠唯一键吸收,而不是假设"没应答就是没提交"。这不是配置能解决的问题,是协议层面的边界。

一句话总结本章灵魂:redo 保证"写过的不丢",binlog 保证"承诺过的算数",2PC 保证两者说的是同一件事。

6.4 组提交:两阶段提交为什么不慢

看起来每次提交要两次 fsync(redo prepare + binlog),吞吐应该很差——实际生产 1/1 配置下 InnoDB 依然能打出很高 TPS,秘密是组提交(group commit):把并发的许多事务攒成一组,摊薄两次 fsync。

binlog 侧的三段流水线(8.0 起常态化开启,无总开关,只有等待参数):

 事务1 ┐   flush 段              sync 段              commit 段
 事务2 ├─► 多个事务的 binlog     对 binlog 执行       按顺序推进
 事务3 ┘   cache 一次性写入      一次 fsync           InnoDB 提交状态
           OS 页缓存             (覆盖整组)         (组内 redo 随本段落盘)
           └ leader 干所有人的活,follower 排队等结果 ┘
  • flush:leader 线程把队列里所有事务的 binlog 一起写入 OS 页缓存。
  • sync:一次 fsync,整组日志同时 durable——原来 N 个事务 N 次 fsync,变 1 次。
  • commit:按 LSN 顺序逐个让 InnoDB 完成提交(并借这一次 redo 落盘覆盖整组 prepare,两阶段提交的 redo fsync 同样被组提交摊薄)。

想主动"多攒一会儿"提高组大小,可以调:

-- 提交后多等 500 微秒再 sync,攒更多事务(默认 0=不等待)
SET GLOBAL binlog_group_commit_sync_delay = 500;
-- 但攒够 100 个就提前出发,延迟不至于失控
SET GLOBAL binlog_group_commit_sync_no_delay_count = 100;

这是吞吐与延迟的交换,只对高并发写场景有意义,绝大多数实例保持默认 0。8.0 的并行复制依赖追踪(WRITESET)与组提交协同,让从库也能按行级依赖并行回放——属于 10-主从复制与高可用.md 的内容,本章只需知道:binlog 里的事务顺序仍然是提交顺序,从库并行化不影响一致性语义。


7. 双写缓冲:防的是"页坏了"而不只是"没写上"

redo 能修复"没落盘的改动",但修不了"落盘落坏了的页"。InnoDB 页 16KB,磁盘/文件系统的最小原子单位通常只有 512B 或 4KB——刷一页写到一半断电,盘上会留下半新半旧的页(torn page,部分页写)。页头有 checksum,InnoDB 能发现这页坏了,可 redo 是"在这个页的基础上做这些修改"的差量记录:底座都坏了,差量无从 apply(重放)。

双写缓冲(doublewrite buffer)的解法简单粗暴:刷脏页前,先把页连续地写一份到独立的公共区域并 fsync,再各自散写入数据文件。

 落盘方向                                恢复方向
 脏页 16KB                               读数据文件页
    │                                    ├─ checksum 通过 → 直接 apply redo
    ▼ ①  顺序批量追加写 dblwr + fsync     └─ checksum 失败 → 从 dblwr 找回完整页
 半路崩溃?dblwr 里有整页                        → 再 apply redo
    ▼ ②  分散写入数据文件对应位置(此后丢的是"没写完",redo 可补)

崩溃后果分析:① 之后②之前崩——数据文件里的坏页在 dblwr 有完整个备份;② 之后崩——页已正确落盘,redo 处理剩余增量。两个阶段各自都安全。

版本与开销:

  • 8.0 起 dblwr 独立成文件(数据目录下 #ib_16384_0.dblwr / #ib_16384_1.dblwr),不再占用 ibdata1 的系统表空间区域,也便于管理。
  • 8.0 还引入了针对 undo、临时表空间等对象更细粒度的双写控制,参数面在不同小版本间有调整(如 8.0.20 起的相关开关);多数场景保持默认即可,全局开关 innodb_doublewrite(默认 ON)仅建议存储层确认具备原子写能力时考虑关闭。
  • 性能账:双写让脏页落盘多写一份,但 dblwr 是顺序批量写,SSD 上这笔开销通常远小于直觉预期。它防的是"页损坏"级别的结构灾难,不是普通丢数据——关掉它换来的那点吞吐,赌的是整张表不可恢复。

注意:双写保护的是数据文件页。只有当存储栈能证明"16KB 整页写入要么全成要么不发生"(某些 NVMe/专用 RAID 方案的原子写保证)时,关闭才有讨论余地;普通云盘、机械盘上关闭 innodb_doublewrite 属于事故隐患,不要为了跑分好看这么干。


8. 一条 UPDATE 的生命周期全景

把全章串起来。前台同步路径(毫秒级,用户在等)+ 后台异步路径(决定系统长期健康):

UPDATE account SET balance = balance - 100 WHERE id = 1;

── 同步路径 ──────────────────────────────────────────────────────
 解析/优化/执行器(01、02 章)
   │
   ├─[1] 取页:buffer pool 命中?未命中读盘(01 章 buffer pool)
   ├─[2] 记 undo:旧 balance 写入 undo 表空间(第 4 节)
   ├─[3] 改内存页:BP 中页变"脏",同步产生 redo mtr → log buffer(第 2 节)
   ├─[4] 写 binlog cache(第 5.4 节)
   │  ── 进入 2PC ──
   ├─[5] redo prepare + fsync        (第 6.2 步⑤)
   ├─[6] binlog cache → binlog 文件 + fsync,事件组末尾带 Xid(步⑥)
   ├─[7] redo commit 标记(步⑦)
   └─[8] 返回 OK,行锁按锁协议处理(05 章)

── 异步路径 ──────────────────────────────────────────────────────
  脏页刷盘:BP 压力/checkpoint 推进触发 → 先双写缓冲 → 写数据文件 →
            checkpoint 位点前移(第 2、7 节)
  purge:   旧版本对所有人不可见后,清理 undo 与隔离记录(06 章)
  复制:    binlog 被从库 IO/SQL 线程拉走回放(10 章)
  归档/闪回:mysqlbinlog/备份平台按位点消费 binlog(11 章)
  过期:    binlog_expire_logs_seconds 到期的旧文件被自动删除

每个环节的可靠性闭环:页丢 → redo 补;页坏 → 双写补;事务状态丢 → undo 回滚 + 2PC 对账裁定;库丢 → 备份 + binlog 重放(11 章)。没有任何单点依赖"运气",这就是 InnoDB + MySQL 复制体系"不丢数据"的完整论据。


9. 参数速查与组合策略

速查表(8.4,标注默认值与能否动态调整):

参数 默认值 动态 一句话用途
innodb_redo_log_capacity 100MB 是 redo 总容量(8.0.30+)
innodb_log_buffer_size 16MB 是 日志内存缓冲(8.0 起默认)
innodb_flush_log_at_trx_commit 1 是 redo 提交刷盘档(0/1/2)
innodb_flush_log_at_timestamp 0(关) 是 秒级 delayed durability(8.0.18+,小众)
sync_binlog 1 是 binlog fsync 策略
binlog_format ROW 是 binlog 记录格式
binlog_row_image FULL 是 行镜像范围
max_binlog_size 1GB 是 单文件上限(事务边界处滚动)
binlog_cache_size 32KB 是 每连接 binlog cache
binlog_expire_logs_seconds 2592000 是 binlog 保留 30 天
binlog_group_commit_sync_delay 0 是 组提交 sync 等待窗口(µs)
binlog_transaction_dependency_tracking WRITESET 是 并行复制依赖标记(8.0.26 起默认;8.0.35 起弃用,8.4 内部恒为 WRITESET,详见 10 章)
innodb_doublewrite ON 否 双写全局开关
innodb_undo_log_truncate ON 是 undo 表空间在线截断
innodb_max_undo_log_size 1GB 是 多大的 undo 文件值得截断

innodb_flush_log_at_trx_commit × sync_binlog 的组合是"性能/安全"账本的核心页。给三种典型场景:

场景 组合 账
金融/订单主库 1 / 1 两份日志每笔都 fsync,唯一被官方语义保证"已应答 OK 的事务绝不丢"的配置,没有商量余地
通用 OLTP 1 / 1,把预算花在 redo 容量与 binlog 盘的稳定性上 双 1 是基线;吞吐问题交给组提交自动摊薄,而不是降耐久。观察 checkpoint age 与 fsync 延迟,扩容 redo、确保独立盘
分析库/ETL(单实例、无主从、可整批重跑) 2 / 1000 redo 提交只写到 OS cache、binlog 每 1000 事务 fsync,OS 崩溃的丢失窗口两者一致(都停在 fsync 边界),丢约 1 秒事务,吞吐显著提升——前提是崩了能重跑

注意:主库降级耐久参数有主从一致性的坑。从库的 IO 线程是实时读取主库 binlog 的——不等 fsync,OS cache 里的日志就能被发出去。所以 sync_binlog 调大后主机崩溃,主库丢的 binlog 可能已经进了从库,主从从此不一致(redo 侧同理,=2/=0 的窗口内也会撕裂)。上面第三行的组合明确限定了"无主从";带复制的链路想调耐久参数,先读 10 章的一致性章节再做决策。


10. 实战建议

防爆 binlog 盘。 ROW 格式 + 大事务是磁盘炸弹:一次 UPDATE big_table SET ... 圈中五千万行,binlog 要写五千万组前后镜像,几百 GB 很常见,还可能撑爆复制链路。规则:

  1. 批量改数据永远分批,按主键区间或 LIMIT 循环,每批几千到几万行:
-- 分批骨架(伪代码,循环至 affected_rows=0)
UPDATE orders SET status='archived'
WHERE status='done' AND id BETWEEN ? AND ? LIMIT 5000;
  1. 盯三个数:Binlog_cache_disk_use 增速、binlog 目录水位、从库 relay log 积压。disk_use 持续涨说明大事务还在来,堵源头比调 binlog_cache_size 有效。
  2. 过期时间按"备份链 + 从库消费能力"倒推,不要迷信默认 30 天占着的几百 GB。

两个 mysqlbinlog 恢复配方(完整 PITR 体系见 11 章,这里给最小可用版):

# 按时间区间导出某库的 SQL(重放前先想到第 3 条)
mysqlbinlog --database=shop \
  --start-datetime='2026-10-19 14:00:00' \
  --stop-datetime='2026-10-19 14:05:00' \
  ./data/mysql-bin.00004* | mysql -uroot -p shop_pit

# 按位置精确截取一个事务:先用 SHOW BINLOG EVENTS 找到 BEGIN 与
# 其后一个 Xid 事件的 position,再取 [begin_pos, xid_pos) 区间
mysqlbinlog --start-position=1500 --stop-position=2100 \
  ./data/mysql-bin.000042

--stop-position 取"下一事务开始前的位置",宁可多截一个完整事务,不要截进半截事务。新版本的 binlog 可能携带旧 mysqlbinlog 不认识的会话变量事件,工具报错时用 --skip-unknown-variables 跳过。

闪回的前提条件现在就记牢:靠 binlog before image 反推还原 SQL(误 DELETE → 生成 INSERT),要求 binlog_format=ROW 且 binlog_row_image=FULL——把 image 改 MINIMAL 的那一刻,闪回工具就瘸了。闪回工具链与时点恢复的完整流程在第 11 章。

把崩溃恢复当演练科目。 恢复时长 ∝ checkpoint 之后写入的 redo 量 + 待回滚的 undo 量。定期做:拔电源 → 计时重启 → 确认对账结果正确。顺带能验证两件事:redo 容量与刷脏是否健康(恢复别太久)、长事务习惯是否烂(undo 回滚别太久)。

最后收个尾:这一章的参数没有"最优解",只有"与你的丢失预算是否匹配"。把 1/1 当作默认契约,任何降级都要能指着上面那张判定表说清"崩在哪一步、各丢什么、谁兜底"。