事务提交了数据就安全了吗?一次断电事故揭开WAL的真相

各位,我是老张。今天从一次实战出发,往下拆一层。

上周排查一个线上问题。电商系统做秒杀,Java服务报事务提交成功。订单号返回给前端,用户付款了。结果机房跳电,数据库重启后发现那笔订单没了。应用层的事务明明返回了成功。日志里也打了commit done。数据却凭空消失。我顺着配置翻了一遍,才发现这个库的innodb_flush_log_at_trx_commit设成了2。commit的时候,redo log只进了操作系统的page cache。数据页也还在Buffer Pool里。跳电的时候,两边缓存一起清空。恢复阶段找不到redo log,那笔订单就真丢了。

这就是WAL的做事方式。Write-Ahead Logging叫预写式日志。修改数据之前先把变更日志写到磁盘,数据页可以延迟刷盘。提交成功和数据落盘之间有个时间差,这就是代价。

当年我也以为commit了就万事大吉。Java里一个@Transactional注解加上TransactionManager.commit(),方法正常返回就觉得数据稳了。那次事故之后才明白,应用层的提交和磁盘上的持久化是两回事。

今天把WAL的完整机制拆开讲清楚。各位看完应该能搞懂数据到底丢在哪,以及刷盘策略怎么选。


一、先说结论

对比维度 innodb_flush_log_at_trx_commit=1 innodb_flush_log_at_trx_commit=2 innodb_flush_log_at_trx_commit=0
提交时行为 写redo log并fsync刷盘 写redo log但不fsync 不写,后台线程每秒刷一次
断电安全性 最多丢一个事务 OS缓存不丢,断电全丢 最多丢一秒数据
OS崩溃安全性 安全 安全 丢一秒数据
写入延迟 高(每次等磁盘IO) 中(等page cache) 低(不等待)
适用场景 金融、支付等核心业务 可接受断电丢失的场景 日志采集等低价值数据

说白了,这个参数就是在安全性和性能之间做取舍。设1最安全但性能损耗最大,设0性能最好但数据可能丢,设2介于两者之间。

二、WAL的完整流程

以InnoDB为例,看看一次写入从开始到落盘到底经过了几步。

事务开始后,数据修改发生在Buffer Pool的内存页上,不直接写磁盘。同时,变更内容写入redo log buffer。redo log buffer是内存里的一块环形缓冲区,容量由innodb_log_buffer_size控制,默认16MB。

完整写入流程:

事务开始
  → 修改Buffer Pool中的数据页(标记为脏页)
  → 写redo log buffer(内存操作)
  → 事务提交
    → 根据innodb_flush_log_at_trx_commit参数决定刷盘策略
    → fsync将redo log buffer刷到redo log file
  → 返回客户端(事务提交成功)
  → 后台刷脏线程择机将脏页写入数据文件

redo log file才是持久化的关键。它记录的是物理级别的页面变更,精确到哪个表空间、哪个页号、哪个偏移量、改了什么字节。redo log是追加写入,顺序IO,磁盘性能吃得消。

后台的刷脏线程负责把Buffer Pool里的脏页写回数据文件。刷盘时机由checkpoint和LRU算法共同决定,跟事务提交没有直接关系。这就是提交成功但数据可能还在内存里的根因。

WAL的思路很简单。用redo log的顺序写替代数据页的随机写。每次修改都要定位到具体的页偏移,磁盘调度器吃不消这个。追加写redo log,性能高出几个数量级。

崩溃恢复的时候,数据库拿redo log把未落盘的变更重新应用一遍,数据就回来了。redo log刷了盘就不丢。当然前提是它确实刷了盘。

三、redo log的结构

redo log分三层,配合起来才能用。

redo log buffer在内存中,大小可配置,默认16MB。写入遵循LSN单调递增原则。缓冲满时触发刷脏,推进checkpoint。redo log file在磁盘上,由多个文件组成,总大小由innodb_redo_log_capacity控制。LSN是Log Sequence Number,每条redo记录的全局单调递增序号。

checkpoint记录的是某个LSN对应的脏页已经全部刷盘。恢复的时候从最新的checkpoint开始重放就行,不需要从头扫整个redo log。

redo log结构:

[redo log buffer] (内存,16MB默认)
  ↓ 刷盘
[redo log file 1] [redo log file 2] ... (磁盘,环形复用)
  ↑ LSN单调递增
  ↑ checkpoint标记已知安全位置

checkpoint含义:
  该LSN之前的所有脏页已刷盘
  恢复时从checkpoint位置开始重放即可

LSN涨得快,说明写入压力大。10万TPS的单行UPDATE,每秒产生约10万条redo记录,每条平均50到200字节。算下来每秒redo log增长大约5到20MB。如果redo log总容量是2GB,大约100到400秒就会写满一圈,触发checkpoint。

checkpoint触发的时候,所有在checkpoint LSN之前的脏页必须刷盘。这会带来一次集中IO,延迟会有一个短暂的尖峰。压测报告里那些偶尔冒出来的200ms延迟,很多时候就是checkpoint触发的集中刷脏在干活。

四、三种刷盘策略的压测对比

测试环境:16核64G,NVMe SSD,MySQL 8.0.32。混合读写7比3,单行UPDATE为主,逐步增加并发到500。

并发数 TPS(设1) TPS(设2) TPS(设0) 平均延迟(设1) 平均延迟(设2) 平均延迟(设0)
50 38,000 46,000 49,000 2.8ms 2.1ms 1.9ms
100 52,000 65,000 68,000 4.5ms 3.2ms 2.8ms
200 60,000 78,000 82,000 7.2ms 5.1ms 4.5ms
500 55,000 72,000 76,000 15ms 9ms 8ms

设1全程比设2慢约20%到25%,500并发时延迟差距拉到接近一倍。设0比设2又快了5%到8%,但断电风险高一个量级。

500并发是设1的拐点。P99延迟从15ms跳到40ms,TPS开始回落。根因是每次提交都要fsync,NVMe SSD的fsync延迟约0.05到0.1ms。10万TPS就是每秒10万次fsync,磁盘调度器扛不住这个频率。

设2绕过了每次提交的fsync,只写操作系统的page cache。真正的刷盘由操作系统的脏页回写机制处理,延迟大约5秒一次。这个间隔内断电,redo log没落盘,数据就丢了。

设0由InnoDB后台线程每秒刷一次。高并发下,一秒内几百次commit的redo log合并成一次刷盘,磁盘IO效率最高。代价是系统崩溃丢一秒的数据,这个窗口在实际业务中可能意味着几千条记录。

五、崩溃恢复的完整推导

数据库突然断电重启,数据页还没刷盘。WAL是怎么保证数据不丢的?这就要看崩溃恢复的完整流程。

InnoDB恢复走ARIES算法,三步走。分析阶段先重建脏页表和事务表,确认哪些页面需要重做、哪些事务未提交。再从最新checkpoint开始,按LSN顺序重放所有redo记录,把没刷盘的变更补回来。最后逆序撤销所有没提交的事务。

redo记录的是物理级别的页面变更,精确到页号和偏移量。undo记录的是逻辑级别的行级旧值,用于回滚未提交的事务。两个配合才能完成完整的恢复。

用一个具体例子推导一遍。假设最新checkpoint在LSN 50000,此时所有脏页已刷盘,没有活跃事务。

时间线推导:

LSN 50000:  checkpoint,脏页已刷盘,无活跃事务
LSN 52000:  事务1开始,修改行A
LSN 53000:  事务2开始,修改行B
LSN 54000:  事务1提交
LSN 55000:  事务2修改行C(未提交)
LSN 56000:  事务3开始并提交,修改行D

断电。数据页停留在checkpoint状态。

恢复流程:
分析:  从LSN 50000扫描到56000
        脏页:行A、行B、行C、行D所在页
        活跃事务:事务2
重做:  重放LSN 52000到56000的所有redo记录
        行A、行B、行C、行D都恢复到断电前状态
回滚:  事务2未提交,用undo log回滚行B和行C
        行B、行C恢复到LSN 50000的状态

最终结果:
  行A:事务1的修改保留(已提交)
  行B:事务2的修改撤销(未提交)
  行C:事务2的修改撤销(未提交)
  行D:事务3的修改保留(已提交)

恢复完成看一条标准就行。已提交的都应用了,未提交的都撤了,数据页和redo log一致。

但这事有个前提。redo log必须刷了盘。设成2的话,断电时redo log还在操作系统缓存里,整个重做阶段没东西可重放。设成0的话,最后一秒的redo记录可能丢失,恢复只能从更早的LSN开始。

六、对Java开发者的启示

我写了十年Java,习惯从应用层看问题。@Transactional注解一层封装。Hibernate的Session管理又加一层。HikariCP的连接池再包一层。这些封装让你觉得数据提交了就安全。但底层存储的持久性保障是另一层抽象。ORM再怎么封装也帮不了你。

commit成功不等于数据落盘。设成2的时候,commit成功只保证redo log进了操作系统的page cache。数据页可能还在Buffer Pool里,操作系统崩溃就没了。你以为事务提交了数据就安全,其实差得远。

连接池也会影响刷盘行为。HikariCP默认最大连接10个。高并发下连接复用频繁。WAL的刷盘时机变得不可预测。你以为每秒刷一次。实际可能半秒刷三次,也可能五秒才刷一次。压测和生产环境的连接池配置不同。刷盘表现完全不同。

K8s环境下更麻烦。Pod重建时数据库挂载持久卷。WAL太大时,恢复时间直接影响Pod启动。10GB的redo log重放要两三分钟。健康检查早就超时了。启动失败,无限重启,循环下去。

选型就一个判断:你的业务能不能接受数据丢?金融、支付、订单,答案是不能,设1。日志采集、统计中间表,能接受秒级丢失,设2或设0都行。中间地带的业务,看你能承受多大的数据窗口。

业务类型 数据价值 推荐设置 理由
支付、账务 极高 设1 一笔都不能丢
订单、库存 设1 丢单影响用户体验
用户行为日志 设0或设2 丢少量不影响分析
会话、缓存 极低 设0 丢了重建就是
中间结果表 设2 可以重算,但不想全丢

国产数据库在这个问题上做法不同。KES底层也有类似的预写机制,但刷盘策略更灵活,不只有0、1、2三个选项。WAL的文件管理和刷盘触发条件跟MySQL有差异。迁移前要把这块单独验证,不能光看参数名一样就直接用。

七、踩过的坑

开发环境设成0跑了一个月,上线忘了改回1,机房跳电丢了十分钟的订单。那次之后,我的部署清单里专门加了一项检查这个参数。数据丢了就是丢了,没有后悔药,这个坑太痛了必须记下来。

长事务开着不提交,undo log清不掉。redo log是环形的,满了就checkpoint刷脏页。undo log不一样,它要等所有读到旧版本的快照都不用这份数据了才能释放。生产环境见过报表查询没关连接,跑一下午,undo log从2G涨到80G,磁盘直接报警。

K8s重启后数据库恢复特别慢,查了半天发现是redo log太大重放要几分钟。后来把redo log容量调到2GB,限制上限,恢复时间压到30秒内,Pod启动正常了。这个坑在容器化部署时特别容易踩,本地测试根本复现不了。

WAL刷盘策略跟binlog sync不是同一个东西。sync_binlog控制binlog刷盘,innodb_flush_log_at_trx_commit控制redo log刷盘。两个都设1最安全但也最慢,只设一个等于没做持久性保障。我之前一直以为这两个是一回事,调了一个就觉得够了,结果数据安全根本没保障。

国产数据库迁移时不能只看参数名。KES的刷盘策略配置跟MySQL不完全一样,文件管理方式也不同。参数名一样不等于行为一样,这块我一开始没重视,迁移上线后才发现刷盘频率差了一倍,延迟表现跟预期对不上。后来把每个参数的实际行为逐条验证了一遍才踏实。

结语

事务提交了,数据安不安全,看你设的什么值。设1就安全,redo log刷了盘,崩溃能恢复。设2和0就不一定,redo log可能还在内存里,断电就是真丢了。WAL给了你性能,代价就是这个时间差。

后来我从Java后端转做架构,就是因为这类问题越来越多。写了十年代码才意识到,数据库那层才是真命门。应用层封装得再漂亮,也绕不开磁盘上那点事。

各位生产环境用的什么刷盘策略?有没有被断电坑过?一起聊聊。

我是底层玩家老张。咱不聊概念,只聊源码和压测跑出来的东西。

posted @ 2026-08-05 10:47  底层玩家老张  阅读(40)  评论(0)    收藏  举报