数据库小白最容易把持久化理解成“把数据写进磁盘”这句话在MySQL里只对了一小半。MySQL崩溃之后某个已提交事务的数据还能找回来靠的是WALWrite-Ahead Logging也就是预写式日志。这五个字母你八成听过但真被问到“事务提交成功那一刻到底什么写进了磁盘”不少人会卡壳。我刚做DBA那会儿也吃过这个亏一台测试库突然掉电起来后我满脑子都是“刚才的数据页刷没刷盘”后来才明白InnoDB的持久性承诺根本不是“数据页必须落盘”而是“描述变更的redo log必须先落盘”。这篇就从这里开始把WAL从概念到机制完整拆开尤其适合两类人一类是写业务代码、对事务隔离级别很熟却搞不清redo log和binlog区别的后端同学另一类是遇到实例异常重启、需要快速判断数据是否会丢的运维朋友。1. 数据页写完才叫持久化先聊聊这条老路为什么走不通1.1 你理解的“写磁盘”和InnoDB的“写磁盘”不是一回事刚开始接触MySQL内部原理的同学很容易把“事务提交”想象成这样应用程序提交了MySQL把数据写进磁盘了所以持久了。实际上InnoDB的一切增删改查最先发生在内存里这个内存区域叫Buffer Pool里面的数据以页为单位管理默认一个页16KB。你执行一条UPDATE t SET c 100 WHERE id 1InnoDB先定位到包含id1这条记录的页如果这个页不在Buffer Pool里会先从磁盘读进来然后在内存页上修改。注意此时磁盘上那个页还是旧数据。如果事务提交完之后数据库突然崩溃或者整机断电内存里的改动物理上直接消失磁盘上的老数据又没有变那这条更新就等于丢了。所以任何一个数据库都必须回答一个问题用户说“提交成功”我凭什么保证这个结果在机器重启之后依然存在答案不能是“把数据页立刻写回磁盘”原因见下。WAL的思路是我不保证数据页立刻落盘但我保证“描述这次页面变更的日志”已经落盘了。以后不管数据页变成什么样我都能用日志把修改重新做一遍把数据恢复到崩溃那一刻的样子。这就是Write-Ahead Logging日志永远先于数据页。1.2 随机写和顺序写差着几个数量级假如没有WAL每次提交事务时都强制把涉及到的数据页刷到磁盘会发生什么你的一次UPDATE可能只改了记录里某个字段几十个字节但InnoDB落盘的最小单位是页至少16KB。如果这个事务还改了索引页、回滚段页那要刷的脏页可能不止一个。更麻烦的是业务并发更新时脏页分布在不同数据文件的各个位置这种刷盘是典型的随机IO。磁盘随机IO和顺序IO的差距有多大常规机械盘上随机读写每秒可能只有几百次IOPS顺序写却能到几千甚至更高即便是SSD随机小IO同样会损失吞吐。如果每个事务提交都来一次随机页刷盘数据库吞吐会被IO直接掐死。WAL把这个问题转化成顺序IOredo log是追加写到日志文件末尾的日志文件本身是顺序分配的写入位置连续。事务提交时真正需要立刻保证的是这段顺序日志落盘而不是一个或几个16KB的随机数据页。刷盘请求从“随机写一堆页”变成“顺序写一段日志”等待时间大幅缩短。这也是很多数据库教材里常说的“日志先行”的核心理由。1.3 脏页与flush list数据页的落盘本来就可以迟到既然日志已经先落盘了数据页什么时候写回磁盘就不那么着急。被修改过、还没写回磁盘的内存页叫脏页。InnoDB会在后台通过后台线程按照一定策略把这些脏页慢慢刷到磁盘。这个刷脏动作对应一个叫flush list的结构简单理解就是“脏页链表”按最早被修改的顺序排队。这里有个很反直觉的点一个事务已经提交但它的数据页可能还在内存里好几分钟之后才刷盘。如果这中间数据库又正常运转内存页会继续被后续事务修改每产生一次新修改又会生成新的redo log。等到最终刷盘时刷出去的可能是多个事务累积的变更。WAL给了数据页“迟到”的资格代价就是需要redo log兜底以及靠checkpoint来界定“哪些日志已经可以被覆盖”。这些我们后面细说。2. redo log长什么样InnoDB落盘前到底写了什么2.1 redo log buffer、日志文件和LSN的三角关系要理解WAL绕不开三个对象redo log buffer、redo log file和LSN。redo log buffer是内存中的一块日志缓冲区默认大小由innodb_log_buffer_size控制通常是16MB。事务执行过程中每修改一个页对应的redo记录先生成并写进这个buffer。buffer是共享的多个事务的日志记录会按顺序混在一起。redo log file是磁盘上的日志文件。老版本默认是两个文件循环使用大小由innodb_log_file_size和innodb_log_files_in_group控制MySQL 8.0.30之后更推荐直接用innodb_redo_log_capacity管理总容量默认100MB文件个数和单文件大小不再需要手动纠结。日志文件是循环写的写到末尾会回头覆盖最早的、已经不再需要的日志段。LSNLog Sequence Number是贯穿全程的“进度标尺”。你可以把它理解成一个按字节递增的日志游标每次往redo log里追加记录LSN就会变大。比如当前LSN是1000某条日志记录占60字节写完后LSN变成1060。InnoDB内部所有和日志相关的环节都拿LSN对表当前写到哪个位置了、刷到哪个位置了、数据页刷到哪个位置了、检查点推进到哪个位置了全用LSN表示。这也是为什么面试里提到WAL一定会带出LSN因为它就是日志的“地址本体”。2.2 日志记录不是一条SQL一行而是页内的物理变化很多同学误以为redo log类似binlog记的是“执行了哪条SQL”这是错的。redo log记录的是页级别的物理变化更准确地说是一种“物理逻辑日志”。InnoDB内部把一次底层操作拆成Mini-TransactionMTR。一条UPDATE语句可能涉及B树定位、记录修改、二级索引变更、回滚段写入甚至因为页满了发生B树分裂这一系列动作会拆成多个MTR。每个MTR内部会产生一组redo记录记录内容简化来说就是哪个表空间、哪个页号、页内偏移量、写入的内容是什么、长度是多少。回放时不需要关心当时的SQL上下文直接根据表空间ID和页号找到目标页按偏移量覆盖即可。为什么叫“物理逻辑日志”物理是因为它直接定位到页和偏移重放时是幂等的哪怕一个页已经被之前的redo刷过再覆盖一次也不会有问题。逻辑是因为每条记录描述的还是一个有业务含义的“变更”而不是简单的内存字节拷贝这种设计让redo在不同版本之间更容易兼容也便于在崩溃恢复时判断哪些操作需要跳过。理解这点对后面看崩溃恢复流程特别重要。2.3 “先写日志”不等于“日志只在提交时写”还有一层容易被忽略事务在运行过程中redo记录就开始往buffer里写了并不是等到COMMIT才统一生成。事务回滚时这些日志不会马上删除它们依然留在日志流里。是不是听起来有点浪费其实不浪费。redo log本质是“页修改的流水账”它不关心事务最终是提交还是回滚。回滚事务的修改之所以不会变成最终数据是因为崩溃恢复时InnoDB会去检查事务的提交状态没有提交的事务即便redo回放把页改到了中间状态后续也会通过undo表里的信息把它回滚掉。因此“redo已经生成”和“事务最终提交”是两个独立事件把两者拆开理解后面的两阶段提交才看得懂。3. 真正决定“多久落盘一次”的是三个参数组合3.1innodb_flush_log_at_trx_commit0、1、2三档盘点WAL本身是必有的机制但“日志多久落一次盘”可以由DBA配置。核心参数就是innodb_flush_log_at_trx_commit它有三个常用取值几乎每个和MySQL持久化相关的工单最后都会落到这个参数上。参数取值事务提交时的日志行为崩溃场景下的表现0提交时不主动写日志文件只改内存由后台线程定期刷MySQL崩溃时最多丢1秒的已提交事务1每次提交都把redo log从buffer写到文件并强制fsync不丢任何已提交事务最安全2每次提交把redo log写到操作系统缓存但不立刻fsyncMySQL进程崩溃不丢整机断电最多丢1秒这里需要解释一个底层细节事务提交时“日志已经写入日志文件”和“日志真正写到磁盘介质”不一样。操作系统有文件页缓存write()系统调用只是把数据交给了OS真正落到磁盘还需要fsync()来强制刷盘。1和2的差别恰恰就在这里1会等fsync完成2只到OS缓存就返回。所以2在MySQL进程崩溃后通常没问题——OS还活着缓存里的数据最终会写下去但如果是机房断电或者宿主机宕机OS来不及落盘就可能丢掉最近1秒左右的事务。参数0就更激进事务提交完全不碰日志文件全靠InnoDB后台以innodb_flush_log_at_timeout指定的周期默认1秒去刷。它的风险不只是进程崩溃而是一个事务提交成功后它的redo可能还躺在内存里进程一挂就没了。生产环境压测时有人为了提吞吐偷偷设0我见过不止一次因此丢数据的例子强烈不建议在任何有真实业务的实例上这么干。3.2 Group Commit牺牲了哪一点换回什么看到这里你可能会问既然1比0安全那么多为什么高并发下数据库没有被fsync拖死原因是InnoDB做了组提交Group Commit。组提交的思路很简单多个事务几乎同时进入提交阶段InnoDB把它们凑成一批用一次fsync把这批事务的redo log一起刷下去而不是每个事务单独刷一次。这样fsync的系统调用次数就大幅度减少从“每事务一次”变成“每批次一次”。你观察SHOW GLOBAL STATUS LIKE Innodb_os_log_fsyncs时能看到即使并发事务成千上万fsync次数并没有和事务数同比上涨这就是组提交在起作用。组提交的代价是少量延迟第一个到的事务要稍微等等后面的兄弟攒够一批再刷。但这种延迟通常只有几百微秒到几毫秒级别换来的吞吐提升非常可观。所以1并不是性能毒药真正应该优化的往往是日志容量、刷脏参数和binlog相关配置而不是一上来就把WAL的控制参数调成0或2。3.3 主从环境下“双1”是基线如果在主从复制环境里谈持久化情况会更严格一些。除了redo logMySQL Server层还有个binlog它是逻辑日志负责主从复制和时间点恢复。为了让主库崩溃后从库数据和主库一致通常要求sync_binlog1让每个事务的binlog也强制fsync。配合innodb_flush_log_at_trx_commit1就成了大家口中的“双1配置”。双1是绝大多数满足数据安全要求的默认基线。它的成本是每次提交至少两次fsync一次redo log、一次binlog。如果你想做高可用切换且不想丢数据这个配置基本不能降。若性能确实扛不住正确路线是先看组提交参数、磁盘IO能力、是否开了半同步再决定是否调整而不是简单粗暴把双1改掉。4. checkpoint、崩溃恢复与LSN的接力赛4.1 崩溃之后InnoDB怎么知道从哪里开始回放数据库崩溃后重启InnoDB要做崩溃恢复。它手里有大量的redo log如果从头到尾全回放启动时间会非常长。所以它需要checkpoint来划一条线。所谓checkpoint就是InnoDB记录的一个LSN位置表示“这个位置之前的redo log其对应的脏页都已经刷到数据文件了”。有了这条线崩溃恢复只需要从Last checkpoint at之后的redo开始扫描并重放之前的都可以跳过。这也是为什么日常监控里需要关注Log sequence number - Last checkpoint at这个差值差值越大说明“还没有形成检查点”的日志越多一旦崩溃恢复时需要重放的范围就越大。而且redo log是循环写入的旧日志只有当它对应的脏页全部刷盘、checkpoint越过它之后才能被覆盖。如果日志容量太小checkpoint推进速度跟不上新日志生成速度InnoDB会被迫停下正常的写入主动去刷脏表现为IO尖峰和性能抖动。很多人遇到过“一个大批量事务跑完后数据库突然变卡”的问题十有八九就是日志容量和checkpoint的配合出了问题。4.2 回放时遇到“事务没提交完”的记录怎么办崩溃恢复有一个很常见的误解只要redo log里记录了某个事务的修改这个事务就一定会被恢复成永久数据。不对redo回放根本不在乎事务是否提交。InnoDB的崩溃恢复大体是两个阶段。第一阶段是前滚把所有存在于redo log里的页修改重放到数据页上让数据库回到崩溃时刻的“物理状态”。这时无论是已提交事务还是未提交事务只要它的修改写过redo log都会被重放。第二阶段是回滚根据undo日志把那些在崩溃前没有提交的事务产生的修改全部回滚掉。最终对外呈现的结果才是已提交事务全部保留未提交事务全部消失。这里有个隐藏前提undo日志本身也是页修改同样受redo保护。也就是说回滚操作不是凭空发生的它依赖的undo页也是WAL机制的一部分。把redo和undo理解成两个配合的组件会更准确redo负责把物理页推到崩溃点undo负责把不该出现的东西拉回去。4.3 如果redo里明明有prepare状态commit却还没写全崩溃恢复时还会遇到一种“半提交”状态InnoDB已经写了redo log并且把事务标记为prepare但binlog还没写或者没写完事务的最终提交状态不明确。这就要引出MySQL“两阶段提交”的设计。严格说这种情况已经不单纯是InnoDB内部的问题而是Server层binlog和InnoDB redo之间的协调问题放在下一节讲。5. 容易被绕晕的邻居binlog、double write与WAL的边界5.1 redo log和binlog是两套完全不同的东西聊WAL不把binlog讲清楚迟早会在排查时踩坑。redo log是InnoDB存储引擎层的日志记录的是物理页面的修改文件循环使用主要用途是本地崩溃恢复binlog是MySQL Server层的逻辑日志记录的是语句或行级别变更文件是追加归档的主要用途是主从复制和按时间点恢复PITR。两者所属层级、文件格式、生命周期都不一样只是提交时被MySQL用一套机制强行协调到一致。对比项redo logInnoDBbinlogServer层记录内容物理页级变更逻辑SQL或行变更主要用途崩溃恢复、页重建主从复制、误删恢复文件形式循环复用追加写、可持续归档落盘参数innodb_flush_log_at_trx_commitsync_binlog高可用角色保证本地不丢保证复制链路不丢5.2 两阶段提交两份日志不可能同时写到底redo log和binlog是两个文件写一个文件的操作不可能和另一个文件原子地同时完成。如果先写binlog成功、后写redo失败或者反过来崩溃恢复时就可能看到“主库有这条数据从库没有”或者“从库有主库没有”的分裂。为了解决这个问题MySQL把“提交”分成了prepare和commit两个阶段。简化流程是事务执行完后InnoDB先写redo log并把事务标记为prepare接着Server层写binlog并刷盘最后InnoDB把redo log中的事务标记为commit。崩溃恢复时如果发现一个事务在redo里处于prepare状态就会拿事务的XID去binlog里查如果binlog里有完整对应的事件说明这个事务的binlog已经成功写出可以把事务视为已提交如果binlog里没有或事件不完整说明事务在复制链路上没有真正成功统一回滚。所以主从环境下“持久化”实际上由redo log加binlog共同承担。有人觉得WAL就只是redo log忽略binlog主库断电后照样可能在主从切换时丢复制数据。这个坑我后面还会提。5.3 double write是不是WAL不是还有个经常被混淆的概念是double write双写。WAL解决的是“事务修改能否被重放”但重放的前提是数据页本身完整可读。假如某次刷盘恰好写到一半系统断电16KB的数据页可能只有前8KB被写入整个页处于“撕裂”状态。这种损坏不是redo log能解决的——redo并不知道这个页原来应该是什么样它只知道后面的修改怎么覆盖。InnoDB的double write机制是在刷脏页之前先把一整页的完整副本写到double write区域然后再把这个页写到实际的数据文件位置。崩溃恢复时如果发现某个数据页校验失败且它存在于double write区域InnoDB可以先用这个副本把页还原成写入前的完整状态然后再用redo log重放后面的修改。因此double write是“保证页完整”的装甲WAL是“保证修改可重放”的引擎。两者对着干的方向不同完全是两码事。6. 在一台MySQL上亲手观察WAL的活动6.1 用SHOW ENGINE INNODB STATUS抓LSN变化纸上谈兵再多不如在测试实例上亲眼看一次LSN的增长。执行SHOW ENGINE INNODB STATUS\G输出里有很重要的一段LOG部分大致长这样LOG --- Log sequence number 3194579 Log flushed up to 3194579 Pages flushed up to 3193556 Last checkpoint at 3193456Log sequence number是当前最大LSN可以理解为日志流写到哪了Log flushed up to是已经刷到日志文件的LSNPages flushed up to是脏页已经刷到数据文件的LSNLast checkpoint at是检查点位置。正常情况下这四个数字会保持一个顺序checkpoint落后于pages flushpages flush又落后于flush up toflush up to再落后于log sequence number。想验证WAL可以在测试实例上执行一个大循环更新比如建一个只有几十行的小表循环执行更新然后每隔几秒看一次Log sequence number能看到它稳定增长。再配合SHOW GLOBAL STATUS LIKE Innodb_os_log_fsyncs;分别在innodb_flush_log_at_trx_commit1和0下跑同一批任务你会发现fsync次数差不少。我实测时一个有意思的发现是在批量大事务场景下0让fsync次数下降明显但总耗时未必有质的提升因为后台线程每秒还是会刷日志日志本身的写入量没有变。想要靠降WAL档位换性能往往只是把“确定发生的开销”变成“不确定发生的风险”值不值要想清楚。6.2 三个真实踩坑记录第一个坑把从库的innodb_flush_log_at_trx_commit设成2觉得“从库丢点数据没关系”。结果宿主机半宕半起OS页缓存没来得及刷下去redo也丢了一部分从库恢复后复制线程报错最后只能重建从库。记住参数2在MySQL进程崩溃下安全但在操作系统或宿主机这一层失去保障。凡是真的不能丢的系统不要赌。第二个坑旧版本默认redo log容量太小业务写入量大时Log sequence number - Last checkpoint at长期顶着上限InnoDB为了推进checkpoint会激进刷脏。表现就是每隔一段时间出现一次IO尖峰写入慢半拍。把日志容量调大之后刷脏分布平缓了很多。在新版本就用innodb_redo_log_capacity调整这个比纠结innodb_log_file_size更直观。第三个坑排查慢提交时很多人只看redo的fsync却忘了binlog的sync_binlog1。MySQL 5.7以上引入了binlog组提交相关参数像binlog_group_commit_sync_delay和binlog_group_commit_sync_no_delay_count可以控制binlog刷盘的批次策略。如果发现“每次提交都很慢”先分清楚慢的是redo fsync还是binlog fsync可以通过SHOW ENGINE INNODB STATUS里Log flushed up to的推进速度以及错误日志里的binlog group commit统计来分析别一上来就乱降安全参数。6.3 一个值得保留的自查思路我现在处理“会不会丢数据”类工单时基本固定走这么几步先看innodb_flush_log_at_trx_commit和sync_binlog到底是多少再看SHOW ENGINE INNODB STATUS里Log sequence number和Last checkpoint at的差距有没有持续增长然后看fsync次数是否异常频繁最后才动手调参数。多年下来绝大多数“数据丢失”最后都不是WAL机制失效而是有人把安全参数调低、binlog没同步、或者日志容量配太小导致checkpoint极度滞后。WAL是InnoDB持久化的底牌但它不是万能保险它保护的是“日志先落盘”这个流程不受破坏。只要流程被参数改动绕过再精妙的WAL也救不回来。