日志体系
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 直接改数据文件,会遇到三个问题:
- 随机 IO。InnoDB 以 16KB 页为单位管理数据。改一行,哪怕只动 10 个字节,也要把整个 16KB 页读出来、改掉、再写回去,而且不同行的页散落在表空间各处——一个热点写业务会打出满盘随机 IO,磁盘吞吐直接崩溃。
- 写放大。10 字节的改动放大成 16KB 的回写量,放大三个数量级。
- 部分页写危险。页写到一半断电,落盘的是"半新半旧"的脏页(第 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 12344000000Log 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 很常见,还可能撑爆复制链路。规则:
- 批量改数据永远分批,按主键区间或
LIMIT循环,每批几千到几万行:
-- 分批骨架(伪代码,循环至 affected_rows=0)
UPDATE orders SET status='archived'
WHERE status='done' AND id BETWEEN ? AND ? LIMIT 5000;- 盯三个数:
Binlog_cache_disk_use增速、binlog 目录水位、从库 relay log 积压。disk_use 持续涨说明大事务还在来,堵源头比调binlog_cache_size有效。 - 过期时间按"备份链 + 从库消费能力"倒推,不要迷信默认 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 当作默认契约,任何降级都要能指着上面那张判定表说清"崩在哪一步、各丢什么、谁兜底"。