事务提交返回成功,数据就一定在磁盘上了吗?这个问题我在不同场合被问过不下十次,问的人里既有刚接手线上库的运维,也有写了几年业务代码的后端。多数人的答案停在“MySQL 会保证的”这一层,再往下追问 redo log 什么时候 write、什么时候 fsync、binlog 又是在哪个瞬间落盘,就说不清了。说不清的代价很实在:把innodb_flush_log_at_trx_commit从 1 改成 0 换来四倍 TPS,跑了两周很开心,某次机房供电抖动之后发现订单表少了一小段自增区间,回头查半天才发现丢的那部分连 binlog 都没有。这篇文章就把 MySQL 三大日志——redo log、undo log、binlog——的写入与刷盘时机,按“谁在什么时候把数据从哪一层推到哪一层”这条线索彻底拆一遍,顺带把两阶段提交、组提交、崩溃恢复对账这些绕不开的机制串起来。适合已经会写 SQL、但想搞清楚存储引擎底线在哪的人看,也适合正准备调整刷盘参数的人先读一遍再动手。
1. 先拆开“写入”和“刷盘”:一个被大面积混淆的前提
1.1 write 与 fsync 之间隔着一整个操作系统页缓存
很多人把“日志写下去了”当成一个动作,实际上从 MySQL 的内存到磁盘介质,中间至少横跨三层:第一层是 MySQL 自己维护的用户态缓冲区,比如 InnoDB 的 log buffer、每个会话的 binlog cache;第二层是操作系统的 page cache,也就是内核态的那份内存副本;第三层才是真正的磁盘介质,可能是机械盘、SSD,也可能是带掉电保护的阵列卡缓存。
write()系统调用做的事情,是把第一层的数据拷进第二层,然后立刻返回。这个动作很快,因为不涉及真正的磁盘 IO,它只是内存到内存的拷贝。真正让数据落到介质上的是fsync(),它才会把 page cache 里的脏页推给存储设备并等待完成确认。两者的时间差可以达到两个数量级——一次 write 是微秒级,一次 fsync 在普通 SSD 上是几百微秒,在机械盘上可能直接是几毫秒到十几毫秒。
这个分层直接解释了 MySQL 那两个经典参数的差别所在。当innodb_flush_log_at_trx_commit=2时,事务提交只做到 write,数据已经进了 page cache,此时如果只是 MySQL 进程崩溃(比如被 kill -9、OOM 被干掉),内核还在,page cache 还在,重启之后数据一点不少;但如果是整机断电或者操作系统崩溃,page cache 就一起没了,那部分数据就真丢了。而=0的情况连 write 都不做,进程一崩就直接丢。理解了这个分界,后面所有参数的取舍都有了统一的判断标准:你在拿“进程崩溃”和“整机掉电”这两种故障模式的容忍度做交换。
1.2 三条日志各自的物理落点和责任方
三条日志并不是同一套机制,它们的缓冲区归属、写入触发者、刷盘责任方都不一样。先把这张底表记住,后面的时序才不容易糊。
| 维度 | redo log | undo log | binlog |
|---|---|---|---|
| 归属层 | InnoDB 存储引擎 | InnoDB 存储引擎 | MySQL Server 层 |
| 内存缓冲 | log buffer(全局共享) | 直接改 buffer pool 中的 undo 页 | binlog cache(每会话独立) |
| 物理文件 | #innodb_redo目录下多个文件 | undo 表空间(undo_001、undo_002 等) | binlog.0000xx系列文件 |
| 写入触发者 | mtr 提交、事务提交、后台线程 | 事务执行过程中随 DML 持续写入 | 事务提交时一次性写入 |
| 刷盘责任方 | 提交线程 + 后台 master thread | 脏页刷盘线程(page cleaner) | 提交线程 + 组提交 leader |
| 主要作用 | 崩溃恢复时前滚已提交数据 | 回滚未提交事务、支撑 MVCC 读 | 主从复制、数据归档与恢复 |
这张表里最容易被忽略的是 undo log 那一行。undo 的“落盘”其实根本不是提交时决定的动作,它跟普通数据页一样躺在 buffer pool 里等着被 checkpoint 推下去,只是它的修改同样会被 redo 保护起来。把 undo 当成“另一份日志文件”去理解写入时机,方向从一开始就偏了。
2. redo log 的三级推进:从 log buffer 到磁盘的完整路径
2.1 一条 redo 记录要经过几个人的手
InnoDB 里最小的原子操作单位叫 mini-transaction,简称 mtr。一次 DML 会拆成若干个 mtr,每个 mtr 在提交时会把自己产生的 redo 记录拷进全局的 log buffer。这一步是纯粹的 memcpy,速度取决于innodb_log_buffer_size有多大,8.0 的默认值是 16MB。
数据进了 log buffer 之后,并不会老老实实等到事务提交才动。InnoDB 在这里设了一道闸:当写入的 redo 量超过 log buffer 容量的一半时,会唤醒后台线程把已经攒下的部分异步 write 到 page cache。这个设计的意图很朴素——防止 log buffer 被撑爆导致写入线程阻塞。注意这里只做 write 不做 fsync,因为此时事务还没提交,就算 fsync 了也没有意义,反而白白浪费 IO。
真正决定 fsync 时机的,是事务提交那一刻。这个时刻的行为完全由innodb_flush_log_at_trx_commit决定,这个参数是整个 MySQL 里性价比最高的一个开关,也是最容易被乱动的一个。
2.2 innodb_flush_log_at_trx_commit 三个取值到底动了哪一步
把三个取值放到“提交时”和“后台每秒”两个时间维度上看,差别一目了然:
| 取值 | 事务提交时做什么 | 后台每秒做什么 | 进程崩溃是否丢 | 整机掉电是否丢 |
|---|---|---|---|---|
| 1(默认) | write + fsync | 无额外动作 | 不丢 | 不丢 |
| 2 | 只 write 到 page cache | fsync | 不丢 | 最多丢 1 秒 |
| 0 | 什么都不做 | write + fsync | 最多丢 1 秒 | 最多丢 1 秒 |
这里有个细节值得单独说:=0的“每秒”是由后台 master thread 驱动的,间隔受innodb_flush_log_at_timeout控制,默认就是 1 秒。也就是说=0和=2在整机掉电这个故障模式下的风险其实是一样的,两者真正的差别只在进程崩溃场景。很多资料把=2描述成“比 0 安全一点”,严格讲,安全在的是进程崩溃这一层,不是掉电那一层。搞清楚这个边界,才能跟业务方把风险讲明白。
还有一个更隐蔽的点:即使设成=1,也不代表每个事务都要独立做一次 fsync。InnoDB 内部有 log group commit 机制,多个并发提交的事务会被合并成一批,由其中一个线程做一次 fsync,其余线程等待结果。所以高并发场景下=1的实际 fsync 次数远小于事务数,这也是为什么“双 1”配置在一台像样的 SSD 上依然能跑到几千 TPS 的原因。判断实际压力不能看 QPS,得看 fsync 次数。
2.3 提交之外,还有谁在推着 redo 往前走
只盯着事务提交是不够的,redo 还有几条被推动落盘的路径,任何一条出问题都会表现为“莫名其妙的 IO 抖动”。
最核心的一条是 WAL 规则本身。InnoDB 修改数据页时只改内存里的 buffer pool,脏页什么时候刷下去由 checkpoint 决定。但刷脏页之前有一个硬性前提:这个脏页对应的所有 redo 必须已经 fsync 到磁盘。否则一旦崩溃,磁盘上有一个新的数据页版本,却没有对应的 redo 可以用来前滚,数据就彻底坏了。所以你会看到在写入压力大、脏页堆积的时候,redo 的 fsync 频率会跟着脏页刷盘节奏一起上去,这跟事务提交频率是两回事。
第二条是 checkpoint。InnoDB 是循环使用 redo 空间的,当写位置快要追上 checkpoint 位置时,必须强制推进 checkpoint,把对应的脏页刷下去,才能复用前面的 redo 空间。innodb_redo_log_capacity(8.0.30 之前是innodb_log_file_size)设得太小,就会出现 checkpoint 频繁被追赶、脏页集中刷盘、redo 写入被阻塞的连锁反应。这个现象在监控上表现为innodb_log_waits上升,本质上是“redo 空间不够用了,写入线程得等”。
第三条容易被忽略:innodb_flush_method的取值会影响 log 文件的打开方式。设成O_DIRECT时,数据文件绕过 page cache 直接写介质,但 redo log 的写入路径相对独立,还会受innodb_log_write_ahead_size影响(默认 4KB,用来对齐块大小,避免写放大)。这个参数调错了会出现“redo 写入量不大但 IO 很高”的怪现象,排查时值得看一眼。
3. binlog 的落盘链路:一次原子写加三重提交阶段
3.1 binlog cache 与 sync_binlog 的配合关系
binlog 走的是完全不同的路径。事务执行过程中产生的事件先写进当前会话私有的 binlog cache,大小由binlog_cache_size控制,默认 32KB。事务体量一旦超过这个值,cache 就会溢写到磁盘临时文件,这个动作在监控上有专门的口径:Binlog_cache_disk_use。这个计数器持续增长,说明你有大事务,而大事务在 binlog 这一层的代价不只是慢,还会拖累组提交的合并效率。
事务提交时,binlog cache 里的内容会被一次性write进 binlog 文件。这个“一次性”非常关键:一个事务的所有 binlog 事件必须连续地、不被打断地写进文件,中间不能插入别的事务的事件,否则从库回放时就会解析错乱。这也是为什么 binlog 的写入没法像 redo 那样零敲碎打地做。
写进文件之后的 fsync 行为由sync_binlog控制:
=1:每次提交都 fsync,最安全,配合trx_commit=1就是常说的“双 1”=0:完全不主动 fsync,交给操作系统决定,通常几十毫秒到几秒不等=N(N>1):累积 N 个事务后 fsync 一次,这是 5.7 之后才真正好用的取值
sync_binlog=N在早期版本是有坑的:5.6 之前,设成大于 1 会导致 binlog 和 redo 的两阶段提交在崩溃恢复时出现不一致,可能造成主从数据错位。5.7 引入 binlog group commit 之后这个组合才变得可用。所以看到网上有“sync_binlog 千万别设大于 1”的老帖子,要注意它的年代背景。
3.2 组提交的三个阶段:flush、sync、commit
理解了组提交,才能理解“双 1 为什么还能跑得快”。MySQL 5.7 起把 binlog 的提交流程拆成了三个可以被合并的阶段:
- flush 阶段:把各个事务的 binlog cache 依次写进 binlog 文件。这一步是纯 write,不进磁盘,所以很快。
- sync 阶段:对 binlog 文件做 fsync。这一阶段才是真正的瓶颈。组提交的魔法就在这里——所有在这一刻排队的事务共用一次 fsync。
- commit 阶段:通知 InnoDB 存储引擎做真正的提交,释放行锁、写 redo 的 commit 标记。
binlog_order_commits为 ON(默认)时,这一阶段由 leader 按顺序串行执行,保证提交顺序和 binlog 顺序一致。
每个阶段都有一个队列,队列里的第一个事务成为 leader,负责带着后面一串 follower 一起完成这一阶段,然后唤醒它们进入下一个队列。并发越高,一次 fsync 能带上的事务越多,摊到每个事务上的成本就越低。这也解释了一个反直觉的现象:单线程压测时“双 1”的 QPS 可能只有几百,但并发压到 64 线程时 QPS 能翻好几倍,因为组提交的合并率上去了。
如果你想知道自己环境里的合并效果,可以在压测前后对比两个计数器的差值:
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME IN ('Innodb_os_log_fsyncs', 'Innodb_data_fsyncs');用压测期间的事务总数除以Innodb_os_log_fsyncs的增量,得到的比值就是平均每次 fsync 合并了多少事务。这个数字如果接近 1,说明并发不够或者存在长事务阻塞,组提交没发挥作用。
3.3 redo 与 binlog 的两阶段提交:时序上到底谁先谁后
这是整个机制里最绕的一段,但只要抓住“redo 有两个状态”这一点就通了。InnoDB 的 redo 记录在事务提交时并不是一步到位的,它先写一条带 XID 的 prepare 记录,等 binlog 落盘之后,再写一条 commit 记录。中间这段时间,这个事务在 InnoDB 眼里处于“准备提交”的悬空状态。
完整时序是这样的:
| 步骤 | 动作 | 落盘情况 |
|---|---|---|
| 1 | InnoDB 写 redo prepare 记录(含 XID) | 按trx_commit决定是否 fsync |
| 2 | 把 binlog cache 写进 binlog 文件 | write 完成 |
| 3 | 对 binlog 文件 fsync | 按sync_binlog决定 |
| 4 | InnoDB 写 redo commit 标记 | 通常不再单独 fsync |
| 5 | 返回客户端提交成功 | — |
第 4 步不再单独 fsync 是有讲究的:如果第 1 步已经 fsync 过,那么第 4 步写在后面的 commit 标记即便丢了,崩溃恢复时也能根据“redo 处于 prepare + binlog 完整”这个组合判断出该事务应该提交,重新补上 commit 标记。所以这个设计不会因为省掉一次 fsync 而丢数据。
崩溃恢复时的判断逻辑就三句话:redo 里已经是 commit 状态的,直接前滚;redo 里是 prepare 状态,就去 binlog 里找有没有对应的 XID,找到且事务的 binlog 事件完整,就提交,否则回滚。这三句话就是两阶段提交存在的全部意义——它保证了 redo 和 binlog 这两个分属不同层的东西,在崩溃后能达成一致。
4. undo log 的落盘节奏:它压根不听提交的指挥
4.1 undo 页本质上是数据页,不是“日志文件”
把 undo 当成日志去理解写入时机是最常见的认知偏差。事实是:undo 记录在事务执行过程中就已经写下去了,写的是 buffer pool 里的 undo 页,跟普通的数据页没有本质区别。这些 undo 页同样会产生 redo(因为对 undo 页的修改也是页修改),同样由 checkpoint 决定什么时候刷到磁盘。
所以“undo log 的刷盘时机”这个问题,准确的答案是:它没有独立的刷盘时机,它的落盘节奏完全跟随脏页刷盘。你调innodb_io_capacity、innodb_max_dirty_pages_pct这些控制刷脏速度的参数,实际上也在调 undo 的落盘速度。
这个认知带来的直接推论是:undo 的写入量跟事务的执行时长强相关,而跟提交频率关系不大。一个跑了十分钟的批量更新,可能只提交一次,但它在这十分钟里产生的 undo 量是惊人的,这些 undo 页会把 buffer pool 里的脏页比例推高,进而触发更频繁的 checkpoint,把 redo 的写入压力也一起带上去。这就是长事务最隐蔽的伤害路径,它不像锁等待那样立刻可见,但它在后台持续放大 IO。
4.2 提交之后 undo 不会消失,purge 才是终点
事务提交之后,undo 并不会被删除,只是被标记为“可回收”,然后挂进 history list 等着 purge 线程来处理。purge 线程做的事情是:找到这些 undo 记录,把对应的delete-mark记录真正物理删除,然后把 undo 空间标记为可复用。
这里有两个指标值得长期盯着:
SELECT NAME, COUNT FROM information_schema.INNODB_METRICS WHERE NAME IN ('trx_rseg_history_len');history list length就是 history list 上待 purge 的 undo 记录数。这个值正常应该稳定在一个较小的范围内波动,如果它持续单调上涨,基本可以断定有长事务或者有大查询撑着 Read View 不放。另一种查法更直接:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.INNODB_TRX ORDER BY trx_started ASC LIMIT 10;按开始时间排序,最上面那个跑了最久的事务就是元凶。
purge 跟不上还有一层连带影响:undo 表空间会持续膨胀。8.0 支持在线 truncate undo 表空间(ALTER UNDO TABLESPACE undo_001 SET INACTIVE),但前提是没有活跃事务在用这个表空间。所以空间爆了不要急着去 truncate,先把长事务清掉。
4.3 崩溃恢复时 undo 扮演的角色
崩溃恢复分两个阶段:先重放 redo 做前滚,把所有已经落盘的修改重做一遍;然后处理那些在恢复时仍然处于活跃状态的事务——这些事务的修改已经被前滚了,必须回滚掉,靠的就是 undo。
回滚本身也是一个会产生 redo 的操作(因为回滚也要改数据页),所以恢复过程可能在“前滚—回滚”之间来回几轮。这也是为什么崩溃恢复的耗时往往比预期长,尤其在有大量未提交事务或者有超大事务的时候。有过一次经验:一个批量任务的单个事务改了上百万行,中途进程被杀,重启后的恢复时间接近二十分钟,全程都是 undo 回滚在跑。从那以后我对“大事务拆小”这条规范的理解就不只是性能优化了,它是恢复时间的保障。
5. 崩溃之后怎么对账:把丢数据边界算清楚
5.1 不同参数组合下的数据安全边界
把 redo 和 binlog 的参数交叉起来,能得到一张很有用的表。这张表的用途不是让你去选最差的那一格,而是当业务方问“最多丢多少”时,你能给出一个有依据的数字而不是“应该不会丢”。
| trx_commit | sync_binlog | 进程崩溃丢数据 | 整机掉电丢数据 | 典型用途 |
|---|---|---|---|---|
| 1 | 1 | 不丢 | 不丢 | 订单、支付、账务 |
| 1 | 0 | 不丢 | 最多丢若干秒 binlog | 可容忍主从短暂不一致的场景 |
| 2 | 1 | 不丢 | 最多丢 1 秒 redo | 折中方案,写密集业务常见 |
| 0 | 0 | 最多丢 1 秒 | 最多丢 1 秒 | 日志、埋点、可重算的派生数据 |
有个细节必须说清楚:trx_commit=0但sync_binlog=1这个组合其实没太大意义,因为 binlog 完整而 redo 可能丢,崩溃恢复时会出现“binlog 里有的事务,redo 里找不到 prepare 记录”的情况。这种组合不会导致数据错乱(binlog 完整的事务会被当作已提交,从库能正常回放),但主库自身的数据会和 binlog 对不上,做基于 binlog 的恢复时会出问题。要降就一起降,别只降一半。
5.2 一个可以自己复现的验证实验
想亲眼看到丢数据的边界,在测试环境做这个实验最快。步骤必须严格按顺序,中途不要做别的写操作。
第一步,先把当前的 LSN 和 binlog 位置记下来:
SHOW ENGINE INNODB STATUS\G -- 记录 LOG 段里的 Log sequence number SHOW MASTER STATUS; -- 记录 File 和 Position第二步,调整参数并造数据:
SET GLOBAL innodb_flush_log_at_trx_commit = 2; SET GLOBAL sync_binlog = 0; CREATE TABLE t_probe (id INT PRIMARY KEY AUTO_INCREMENT, c INT); INSERT INTO t_probe (c) VALUES (1); -- 重复插入若干次,每次记下 id第三步,用kill -9杀掉 mysqld 进程,然后重启,统计表里的行数和AUTO_INCREMENT值。你会发现:
- 进程崩溃场景下,
trx_commit=2的数据全在(因为 page cache 还在) - 但如果把机器直接断电,
trx_commit=2会丢掉最后一秒左右的数据,而sync_binlog=0丢的可能是好几秒的 binlog
提示:这个实验只在测试实例上做,且实验前后都要重新核对参数。生产环境上做类似验证必须走变更流程,并且要有从库可以重建。
实验里最有价值的一步不是看丢了多少,而是重启后用SHOW ENGINE INNODB STATUS里的 LSN 变化去对照。如果恢复后的Log sequence number和崩溃前记下的值不等,差值就是这段时间内没有被 fsync 的部分。这个数字会随着你的业务写入量变化,所以“最多丢 1 秒”这个说法,落到具体业务上可能是几十行也可能是几万行,得换算成业务语言讲给上面听。
6. 参数怎么定:从业务能承受什么倒推配置
6.1 三类场景的配置对照
调参数最怕的是抄别人的配置。别人的机器、别人的业务形态、别人的磁盘,照搬过来大概率不合适。更靠谱的做法是从业务能承受的损失倒推。
| 场景特征 | 推荐配置 | 理由 |
|---|---|---|
| 交易、账务类,一笔都不能错 | trx_commit=1、sync_binlog=1、用好一点的 SSD | 数据安全优先,靠组提交提升吞吐,不靠降配 |
| 业务类主库,允许极端情况下丢少量非关键数据 | trx_commit=2、sync_binlog=1 | 进程崩溃不丢,只有掉电才可能丢 1 秒,性价比最高 |
| 日志、埋点、可重算的派生表 | trx_commit=0、sync_binlog=0 | 数据可重放,把 IO 全部让给吞吐 |
表格中间那行是我个人最推荐的折中方案。trx_commit=2在绝大多数 SSD 环境下的收益非常明显,因为它把每个事务一次 fsync 变成了每秒一次,而风险只落在整机掉电这个概率很低的场景上。当然前提是这台机器的供电和数据保护做得足够好,如果机房环境本身就不可靠,那还是老老实实回到 1。
至于innodb_redo_log_capacity,8.0.30 之后默认值是 100MB,这个值对稍微有点写入量的库都偏小。一个经验算法是:按峰值写入速率估算每秒产生的 redo 量,乘以 60 到 120 秒,得到一个大致的容量,再确认磁盘空间够用。设得太小会频繁触发 checkpoint,innodb_log_waits上去;设得太大则崩溃恢复时要重放的 redo 变多,启动时间变长。这两者之间要权衡,没有标准答案。
6.2 监控上最容易误判的几个信号
第一个误判:把Log sequence number和Log flushed up to的差值当成“还没落盘的数据量”。这个差值确实是 log buffer 里还没 write 的部分,但它的单位是字节,而且它大不代表丢数据,因为只要事务提交时做了 fsync,这部分迟早会跟上。真正需要关注的是它在持续上涨且不回落,那才说明写入速度超过了后台刷盘能力。
第二个误判:看到Binlog_cache_disk_use有值就以为出了大问题。少量溢出是正常的,只有当它的增量跟事务数在同一个量级时才值得警惕,说明有大量事务超过了binlog_cache_size。这时候要么调大 cache,要么去拆事务——通常后者才是正解。
第三个误判:把 IO 打满归咎于刷盘参数。我见过把一个库的trx_commit从 1 降到 0,IO 还是打满,最后发现是一个每小时跑一次的全表更新任务,它产生的 undo 把脏页推高,checkpoint 被追着跑,redo 的写入就会阻塞。这种情况下调提交参数没有任何用,得去动那个任务。
第四个误判:忽略 redo 的写入放大。innodb_log_write_ahead_size默认 4KB,如果底层存储的块大小是 4KB,两者对齐就没问题;但如果存储块更大或者有特殊的对齐要求,就可能出现一个小的 redo 写引发一整块读写。压测时如果发现 redo 的 IOPS 远高于写入量推算出的值,值得查一下这个参数。
6.3 一个能长期用的排查顺序
线上出现“提交变慢”或者“IO 抖动”时,我一般按这个顺序走,基本能在十几分钟内定位到具体环节:
- 看
SHOW ENGINE INNODB STATUS的 LOG 段,算三个差值,判断是卡在 log buffer、还是卡在脏页刷盘、还是卡在 checkpoint。 - 看
INNODB_TRX,有没有跑得特别久的事务。有就先解决它,很多症状会自己消失。 - 看
Innodb_os_log_fsyncs的增速,对比事务提交速率,算组提交的合并率。合并率低就查是不是有长事务在阻塞 commit 队列。 - 看
Binlog_cache_disk_use和Binlog_cache_use的比值,判断是不是大事务在拖累 binlog。 - 最后才去看操作系统层,用
iostat看设备的w_await和%util,确认是不是存储本身到了瓶颈。
这个顺序的道理是:从 MySQL 内部指标往外走,先排除应用层的问题(长事务、大事务),再怀疑参数配置,最后才归因到硬件。反过来做,很容易一上来就开始怀疑磁盘,换了盘发现没用,成本就上去了。
我自己在这套机制上栽过的最大的一个跟头,是早年为了让一个报表库跑得快,把sync_binlog设成了 0,当时想的是“反正这个库不从主库同步,binlog 没用”。结果半年后要做数据审计,需要从 binlog 里回溯某天的变更,发现那段时间的 binlog 因为参数配置在主从切换时丢了一段。从那以后我的习惯是:任何参数变更前,先问一句“如果以后需要从这条日志里捞数据,这个配置会不会让我捞不到”。数据安全这件事,很多时候不是当下的性能问题,而是半年后的追溯问题。