事务提交了数据就安全了吗?一次断电事故揭开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后端转做架构,就是因为这类问题越来越多。写了十年代码才意识到,数据库那层才是真命门。应用层封装得再漂亮,也绕不开磁盘上那点事。
各位生产环境用的什么刷盘策略?有没有被断电坑过?一起聊聊。
我是底层玩家老张。咱不聊概念,只聊源码和压测跑出来的东西。
浙公网安备 33010602011771号