☰
第36章:Redo、Checkpoint 与崩溃恢复源码剖析
2026/9/28 20:51:12 网站建设 项目流程

1. 项目背景

业务场景:某金融系统的核心交易库在一次异常宕机后启动时,错误日志中显示 “InnoDB: Page [page id: space=123, page number=456] log sequence number 789012345 is in the future!”——崩溃恢复失败——mysqld 无法启动。DBA 尝试了innodb_force_recovery=1到 3 都没有成功——最后只能接受数据全部清空重来。事后分析发现——双写缓冲(Doublewrite Buffer)中的某个页已经损坏——但在崩溃前被写入了错误的 LSN——导致恢复时 redo 重放到这个页时检测到 LSN 不一致——中断了整个恢复过程。

痛点:redo log 和 Checkpoint 是 InnoDB 持久性的根基——从源码层面理解它们——才能真正掌握数据安全:

  1. LSN 不一致即灾难:每个数据页的头部都有 LSN 标记——崩溃恢复时对比页头 LSN 和 redo LSN——如果不一致——恢复过程可能需要数小时甚至永久失败。
  2. Checkpoint 推进慢拖累一切:脏页太多 → Checkpoint Age 逼近 redo 总大小 → 激烈刷新 → 写入阻塞。
  3. 双写缓冲的微妙:它同时是"保护天使"和"性能杀手"——在 SSD 上开启后写入放大 2 倍——但不开就有 torn page 风险。
  4. log writer 线程的行为不透明:redo log 从 log buffer 到磁盘的过程由多个线程协作——理解它们的行为才能调优刷盘参数。

本章从源码层面解剖 redo log 的写入链路、Checkpoint 的推进机制和崩溃恢复的三阶段——让你真正理解 InnoDB 的持久性保障。


2. 项目设计

【场景:小胖的虚拟机强制关机后——MySQL 启动报错——LSN 不一致】

小胖:“大师,为什么 MySQL 崩溃后自己恢复不了?上次我们模拟 kill -9 不是都恢复成功了吗?”

大师:“Kill -9 走的是正常的崩溃恢复——redo log 是完整的——InnoDB 能够重放。但你的这次是硬件虚拟机的强制关机——在 InnoDB 正在写数据页时被中断——页面只写了一半——这叫做 torn page(撕裂页)。这个半页上记录的 LSN 和 redo log 中的 LSN 不匹配——恢复时就报错了。”

小白:“那双写缓冲(Doublewrite Buffer)是怎么防止 torn page 的?”

大师:“双写缓冲的原理很简单——在写入真正的数据页之前,先把这些脏页顺序写入一个小型共享表空间(ibdata1 中的 doublewrite segment)中。这个顺序写很快——而且如果写了一半掉电——要么全写成功、要么没写成功(因为顺序写)。等这个双写区写成功后——再随机写入到真正的数据页位置。如果恢复时发现某个数据页的校验和不匹配(torn page)——InnoDB 就从双写区中找到这个页的完整副本——覆盖回真正的位置。”

技术映射:Doublewrite Buffer = 脏页在写回真正位置之前——先顺序写入双写区。防止 torn page——是 InnoDB 在"不支持原子页写入的存储设备"上的关键安全保障。


3. 项目实战

3.1 环境准备

# 确认相关源码目录ls~/mysql-src/storage/innobase/log/ls~/mysql-src/storage/innobase/buf/buf0dblwr.ccls~/mysql-src/storage/innobase/buf/buf0flu.cc# 源码中的关键文件:# log/log0log.cc — redo log 核心管理(log_t 结构体)# log/log0write.cc — redo log 写入线程# log/log0recv.cc — 崩溃恢复# buf/buf0flu.cc — 脏页刷新(Checkpoint 推进)# buf/buf0dblwr.cc — 双写缓冲# os/os0file.cc — 文件 IO(O_DIRECT / O_SYNC)

3.2 分步实现

步骤一:redo log 写入链路源码——从 log buffer 到磁盘

// 文件:storage/innobase/include/log0log.h// log_t 是 redo log 的核心结构体structlog_t{// === 内存缓冲区 ===byte*buf;// redo log buffer(innodb_log_buffer_size)lsn_t buf_size;// buffer 大小// === LSN 追踪 ===lsn_t lsn;// 当前已写入 log buffer 的最大 LSNlsn_t write_lsn;// 已写入 OS page cache 的 LSNlsn_t flushed_to_disk_lsn;// 已刷到磁盘的 LSN(真正的持久化点)lsn_t available_for_checkpoint_lsn;// 可用于推进 checkpoint 的 LSN// === 刷盘策略 ===ulong write_ahead_buf_size;// 预写缓冲区boolwriter_threads_active;// 写线程是否活跃};// 文件:storage/innobase/log/log0write.cc// log writer 线程——负责将 log buffer 刷到磁盘voidlog_writer(log_t*log_ptr){while(true){// 1. 等待有新的 redo 需要写入os_event_wait(log.writer_event);// 2. 把 log buffer 中 write_lsn 到 lsn 之间的数据写入 OS page cachelsn_t ready_lsn=log_buffer_ready_for_write_lsn(log);// → write() 系统调用 → OS page cache// 3. 更新 write_lsnlog.write_lsn=ready_lsn;// 4. 通知 flusher 线程——有新数据可 fsyncos_event_set(log.flusher_event);}}// log flusher 线程——负责 fsyncvoidlog_flusher(log_t*log_ptr){while(true){os_event_wait(log.flusher_event);// 调用 fsync() 将 OS cache 刷入磁盘// 根据 innodb_flush_log_at_trx_commit:// 1 = 每次 commit 后 fsync// 2 = 每次 commit 写 OS cache,每秒 fsync// 0 = 每秒写 log buffer 到 OS cache 并 fsyncos_file_flush(log.flush_file);log.flushed_to_disk_lsn=log.write_lsn;}}
-- 步骤目标:观察当前 redo log 写入的实时状态-- 查看 LSN 推进情况(两次采样可计算速率)SHOWENGINEINNODBSTATUS\G-- "LOG" 段:-- Log sequence number 1234567890123-- Log flushed up to 1234567890000-- Pages flushed up to 1234500000000-- Last checkpoint at 1234400000000-- 计算:-- redo 生成速率 = ΔLog sequence number / Δt(两次查询间隔)-- 未刷盘的 redo = Log sequence number - Log flushed up to-- Checkpoint Age = Log sequence number - Last checkpoint at

步骤二:Checkpoint 推进机制——脏页刷新策略

// 文件:storage/innobase/buf/buf0flu.cc// 脏页刷新——推进 Checkpoint 的核心逻辑voidbuf_flush_list_space_check(buf_pool_t*buf_pool){// 检查是否需要刷新脏页ulint n_flush=0;// 条件 1:脏页比例超过 innodb_max_dirty_pages_pct_lwmif(buf_get_modified_ratio_pct(buf_pool)>srv_max_buf_pool_modified_pct){n_flush=(srv_max_io_capacity*PCT_IO(100))/100;// 启动"激烈刷新"——用最大 IO 能力刷脏页}// 条件 2:Checkpoint Age 太大(redo 不够用)lsn_t checkpoint_age=log_get_lsn(log)-log.last_checkpoint_lsn;if(checkpoint_age>log.max_checkpoint_age){n_flush=srv_max_io_capacity;// 必须立即刷脏页推进 Checkpoint——否则 redo 可能循环覆盖}// 从 Flush List(LRU 链表中脏页组成的子链表)的尾部开始刷// 靠近头部的页是旧脏页(需要优先刷)——靠近尾部的页是新脏页buf_flush_list(n_flush,buf_pool);}// Checkpoint 的推进voidlog_checkpoint_low(log_t*log){// 找到 Flush List 中最老的脏页的 LSNlsn_t oldest_lsn=buf_pool_get_oldest_modification();// 只有这个 LSN 之前的所有脏页都刷到磁盘后——Checkpoint 才能推进log.last_checkpoint_lsn=oldest_lsn;// 将 Checkpoint LSN 写入 redo log 文件头——崩溃恢复时从这里开始log_files_write_checkpoint(log);}
# 步骤目标:观察 Checkpoint Age 和脏页比例随时间的变化watch-n2'mysql -uroot -pRoot@123 -e " SHOW ENGINE INNODB STATUS\G" 2>/dev/null | grep -E "Modified db pages|Log sequence|Last checkpoint|Pending log"'

步骤三:崩溃恢复三阶段的源码追踪

// 文件:storage/innobase/log/log0recv.cc// 崩溃恢复入口函数dberr_trecv_recovery_from_checkpoint_start(log_t*log,lsn_t flush_lsn){// ===== 阶段 1:Analysis(分析)=====// 从最近一次 Checkpoint 开始——扫描 redo log 中的所有记录// 找出哪些数据页的修改没有刷到磁盘recv_sys.recover();// → 建立 recovery hash table:page_id → { 需要该页的 redo 记录列表 }// ===== 阶段 2:Redo(重做)=====// 将所有未刷盘的脏页按照 redo log 重做recv_apply_hashed_log_recs(TRUE);// → 顺序读取 recovery hash table 中的页// → 对每个页依次应用所有相关的 redo 记录// → 数据页恢复到崩溃前的最新状态// ===== 阶段 3:Undo(回滚)=====// 找出崩溃时未提交的事务——通过 undo log 回滚trx_rollback_or_clean_all_recovered();// → 扫描 undo 表空间——找未提交事务// → 逐一执行反向操作——撤销未提交的修改returnDB_SUCCESS;}
-- 验证清单-- 1. 确认 redo 日志文件存在-- ls -lh /var/lib/mysql/#innodb_redo/-- 2. 查看 InnoDB 恢复相关的指标SELECTNAME,COUNT,MAX_COUNTFROMinformation_schema.INNODB_METRICSWHERENAMEIN('os_log_pending_writes',-- 待写 redo 数量'os_log_pending_fsyncs',-- 待 fsync 数量'log_lsn_checkpoint_age',-- Checkpoint Age'log_max_modified_age_async',-- 异步刷脏阈值'log_max_modified_age_sync'-- 同步刷脏阈值(到达时必须暂停写入等刷完))ORDERBYNAME;-- 3. 查看 Doublewrite 统计SHOWSTATUSLIKE'innodb_dblwr%';-- Innodb_dblwr_pages_written:双写区写入的总页数-- Innodb_dblwr_writes:双写操作的总次数-- 如果 pages_written / writes 远大于 1——说明合并写入效果好

步骤四:强制触发 Checkpoint 并观察效果

-- 步骤目标:通过调整参数观察 Checkpoint 推进对写道的影响-- 1. 记录当前状态SHOWENGINEINNODBSTATUS\G-- 记录 "Last checkpoint at" 和 "Log sequence number"-- 2. 执行大量写入DELIMITER//CREATEPROCEDUREstress_write_for_checkpoint()BEGINDECLAREiINTDEFAULT0;WHILEi<50000DOUPDATEorders_largeSETamount=amount+0.01WHEREid=i+1;SETi=i+1;IFi%5000=0THENCOMMIT;ENDIF;ENDWHILE;COMMIT;END//DELIMITER;CALLstress_write_for_checkpoint();-- 3. 手动触发 Checkpoint 推进(通过 SET 调整低水位触发即时刷新)SETGLOBALinnodb_max_dirty_pages_pct_lwm=0;-- 低水位设为 0 → 立即开始刷新DOSLEEP(5);SETGLOBALinnodb_max_dirty_pages_pct_lwm=10;-- 恢复默认-- 4. 再次查看 Checkpoint 是否已推进SHOWENGINEINNODBSTATUS\G-- "Last checkpoint at" 应该向前推进了

步骤五:读取崩溃恢复的源码级日志

// 在编译时开启崩溃恢复的详细日志// cmake -DWITH_DEBUG=1 ...// 在 recv_recovery_from_checkpoint_start() 中插入:/* static int recovery_step = 0; ib::info() << "[CRASH-RECOVERY] Step " << ++recovery_step << ": phase=" << (recovery_in_progress ? "REDO" : "ANALYSIS") << ", pages_processed=" << n_pages_done << ", lsn=" << current_lsn; // 编译后——在错误日志中可以看到: // [CRASH-RECOVERY] Step 1: phase=ANALYSIS, pages_processed=0 // [CRASH-RECOVERY] Step 2: phase=ANALYSIS, pages_processed=1523 // ... // [CRASH-RECOVERY] Step 57: phase=REDO, pages_processed=12000 // ... // [CRASH-RECOVERY] Step 120: phase=UNDO, transactions_rolled_back=3 */

3.3 测试验证

# 1. 查看 redo 写入速率(每秒产生多少 MB redo)mysql-uroot-pRoot@123-e"SHOW ENGINE INNODB STATUS\G"2>/dev/null|grep"Log sequence"sleep10mysql-uroot-pRoot@123-e"SHOW ENGINE INNODB STATUS\G"2>/dev/null|grep"Log sequence"# 两次的差值 / 10 / 1024 / 1024 = MB/s# 2. 查看 Checkpoint Age 是否健康mysql-uroot-pRoot@123-e"SHOW ENGINE INNODB STATUS\G"2>/dev/null|grep-A1"Max checkpoint age"# 3. 验证双写缓冲正常工作mysql-uroot-pRoot@123-e"SHOW STATUS LIKE 'innodb_dblwr%';"# Innodb_dblwr_pages_written > 0 说明正在使用# 4. 手动模拟崩溃恢复日志grep-i"crash.recovery\|InnoDB.*recovery\|InnoDB.*redo\|InnoDB.*undo"/var/log/mysql/error.log

4. 项目总结

优点 & 缺点

维度优点缺点/局限
WAL + redo log顺序写 redo 远快于随机写数据页——写入吞吐提升 5-100 倍需要两倍写入量(redo + 数据页)——SSD 寿命消耗翻倍
Checkpoint 机制自适应脏页比例控制——防止 redo 循环覆盖激进刷新会导致 IO 毛刺;保守刷新会导致 redo 容量紧张
Doublewrite Buffer防止 torn page——崩溃恢复中最后的防线在 SSD 上写入放大 2 倍(已写 redo + dblwr + data page);原子写 SSD 无需双写
崩溃恢复自动无需人工介入——全自动恢复时间依赖 Checkpoint Age——最坏情况数小时

适用场景

  1. 金融 / 支付:必须双 1 配置 + 双写缓冲——数据安全性最高。
  2. 高写入吞吐 OLTP:增大 redo log 文件(4GB+)减少 Checkpoint 频率——提高写入稳定性。
  3. SSD 存储:考虑关闭双写缓冲(如果 SSD 支持原子页写入,innodb_doublewrite=OFF)——减少写入放大。
  4. 源码级排障:崩溃恢复失败时——通过插桩日志定位具体失败在哪个阶段哪个页。

不适用场景:

  1. 纯内存数据库:不需要 redo(数据不安全但极快)。
  2. 批量导入:临时关闭双写缓冲和 redo 刷盘——导入完再开启——可提升导入速度 2 倍。

注意事项

  • innodb_doublewrite=OFF在非原子写 SSD 上会导致 torn page 无法恢复。
  • Checkpoint 不能手动推进:通过刷脏页间接控制——innodb_max_dirty_pages_pct_lwm是主要杠杆。
  • 修改 redo log 大小需要重启并删除旧文件:操作前全量备份。

常见踩坑经验

  1. 故障案例一:关闭双写缓冲后——一次掉电导致数十个数据页损坏。
    根因:使用的 SSD 不支持原子页写入——4KB 写入是原子的——但 InnoDB 页是 16KB——可能被撕裂。
    修复:重新开启双写缓冲——或使用支持 16KB 原子写的企业级 SSD。

  2. 故障案例二:Checkpoint Age 接近上限——所有写入暂停 3 分钟。
    根因:innodb_io_capacity设置太低——刷脏速度跟不上写入速度。
    修复:调大innodb_io_capacity到磁盘实际 IOPS 的 10-20%。

  3. 故障案例三:崩溃恢复进入死循环——Consume CPU 100% 但不前进。
    根因:redo 文件中某条 redo 记录损坏——恢复时重复尝试应用——每次都失败但不退出。
    修复:innodb_force_recovery = 4跳过损坏页——启动后立即导出数据并重建。

思考题

  1. 如果 redo log 文件被手动删除——但数据文件还在——InnoDB 能启动吗?数据能恢复吗?
  2. 双写缓冲在innodb_flush_method=O_DIRECT和=O_DIRECT_NO_FSYNC下的行为有什么不同?哪种更安全?

答案提示:第 1 题——无法启动(InnoDB 检测 redo 文件缺失直接 abort),需要 innodb_force_recovery=6 强制启动并导出数据重建实例;第 2 题——O_DIRECT 在每次 fsync 时同时保证双写区和数据文件的刷盘,O_DIRECT_NO_FSYNC 跳过部分 fsync——性能更高但对 OS crash 的防护更弱。

延伸阅读与资源

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询