机房断电的瞬间,数据库正在执行三条事务:T1 已提交但脏页还没刷盘,T2 写了一半 WAL 就中断了,T3 刚修改完 Buffer Pool 中的页面还没来得及写 Redo Log。来电后重启——T1 的数据能找回吗?T2 的半截写入会污染数据吗?T3 的修改还在吗?
WAL(Write-Ahead Logging)和 ARIES 算法回答了这些问题。这是数据库保证 ACID 中 D(Durability)的工业标准方案——崩溃后数据不丢、恢复过程正确、恢复时间可控。
一、崩溃恢复问题
1.1 崩溃场景
| 崩溃场景 | 问题 | 恢复策略 |
|---|---|---|
| WAL 写入中崩溃 | WAL 记录不完整 | 丢弃不完整的记录 |
| 数据页部分写入 | 页面写撕裂(torn page) | Doublewrite 或原子写 |
| 提交后未刷盘 | 已提交事务的数据丢失 | Redo 重做 |
| 未提交已刷盘 | 脏数据存在于磁盘 | Undo 回滚 |
| 检查点中崩溃 | 检查点不完整 | 从上一个有效检查点恢复 |
1.2 WAL 的核心作用
WAL(Write-Ahead Logging)的核心原则:先写日志,再写数据。
// WAL 协议(Write-Ahead Logging Protocol)// 规则 1:修改数据页之前,必须先将对应的日志记录写入磁盘// 规则 2:事务提交之前,必须将所有日志记录写入磁盘
void wal_protocol_example(page_t *page, log_record_t *record) { // 正确顺序:先写日志,再修改页面 log_write_and_flush(record); // 1. 写入并刷盘 WAL page_modify(page, record->data); // 2. 修改数据页面
// 错误顺序:先修改页面,再写日志 // page_modify(page, record->data); // 如果这里崩溃,数据已修改但无日志 // log_write_and_flush(record); // 无法恢复!}WAL 协议保证了:磁盘上的数据页面永远不会比日志记录”更新”。因此,通过重放日志,可以将数据库恢复到崩溃前的任意一致状态。
Group Commit 虽然能提升写入吞吐,但引入了延迟权衡:事务提交必须等待同一批次的其他事务一起刷盘。如果批次间隔(commit_sync_interval)设置过大,单个事务的提交延迟会显著增加。在延迟敏感的 OLTP 场景中,建议将批次间隔控制在 100μs–1ms 之间,在吞吐和延迟间取得平衡。
1.3 Group Commit 机制
Group Commit 是 WAL 性能优化的关键手段——将多个事务的日志记录合并为一次 fsync:
# Group Commit 原理def group_commit(txn_log_records, sync_interval_us=500): # 将多个事务的日志合并刷盘 batch = [] deadline = now() + sync_interval_us
# 收集批次内的事务 while now() < deadline: if has_pending_txn(): batch.append(collect_log_records()) else: break
# 一次性刷盘 if batch: fsync(all_records(batch)) # 一次 fsync 提交多个事务 notify_all_committed(batch)| 参数 | 推荐值 | 说明 |
|---|---|---|
| sync_interval | 100us-1ms | 批次等待时间 |
| sync_binlog | 100-1000 | MySQL binlog 批次大小 |
| innodb_flush_log_at_trx_commit | 1 | 最安全(每次 fsync) |
| innodb_flush_log_at_trx_commit | 2 | 折衷(每秒 fsync) |
二、ARIES 算法
2.1 ARIES 三大原则
ARIES(Algorithm for Recovery and Isolation Exploiting Semantics)是数据库崩溃恢复的工业标准算法,由 C. Mohan 等人在 1992 年提出:
| 原则 | 说明 | 为什么 |
|---|---|---|
| Steal | 未提交事务的修改可以刷盘 | 避免缓冲池满时阻塞 |
| No-Force | 已提交事务的修改不必立即刷盘 | 提升提交性能 |
| Redo-Undo | 恢复时先 Redo 再 Undo | 保证正确性 |
2.2 ARIES vs 其他策略
| 策略 | Steal | No-Force | 需要 Undo | 需要 Redo | 性能 |
|---|---|---|---|---|---|
| Steal/No-Force | 最好(ARIES) | ||||
| Steal/Force | 提交慢 | ||||
| No-Steal/No-Force | 缓冲池受限 | ||||
| No-Steal/Force | 最差 |
2.3 LSN(Log Sequence Number)
LSN 是 WAL 中的核心概念——每条日志记录的唯一递增编号:
// LSN 的使用struct PageHeader { lsn_t page_lsn; // 页面 LSN:最后修改此页的日志记录的 LSN};
struct LogRecord { lsn_t lsn; // 本记录的 LSN lsn_t prev_lsn; // 同一事务的前一条记录的 LSN txn_id_t txn_id; // 事务 ID page_id_t page_id; // 修改的页面 uint8_t type; // 记录类型 uint16_t length; // 数据长度 byte data[]; // 变长数据};
// WAL 协议的精确表述:// page_lsn >= 最后一条刷盘的日志记录的 LSN// 即:如果 page_lsn < log_flushed_lsn,则页面不能刷盘三、ARIES 三阶段恢复
3.1 恢复流程
3.2 Phase 1: Analysis
Analysis 阶段从最后一个检查点开始,扫描日志:
# ARIES Analysis 阶段(简化)def analysis_phase(log, checkpoint): """分析日志,确定恢复范围""" # 从检查点开始的信息 dirty_pages = set(checkpoint.dirty_pages) # 脏页集合 active_txns = set(checkpoint.active_txns) # 活跃事务集合
# 从检查点的 LSN 开始扫描日志 for record in log.scan_from(checkpoint.lsn): if record.type == UPDATE: # 记录修改的页面为脏页 dirty_pages.add(record.page_id) # 记录活跃事务 active_txns.add(record.txn_id)
elif record.type == COMMIT: # 事务已提交,从活跃集合移除 active_txns.discard(record.txn_id)
elif record.type == ABORT: # 事务已回滚,从活跃集合移除 active_txns.discard(record.txn_id)
elif record.type == CLR: # Compensation Log Record(Undo 产生的日志) dirty_pages.add(record.page_id)
return dirty_pages, active_txns # dirty_pages: 需要重做的页面 # active_txns: 需要回滚的事务(崩溃时未完成)3.3 Phase 2: Redo
Redo 阶段重放所有修改,从最早的脏页 LSN 开始:
# ARIES Redo 阶段(简化)def redo_phase(log, dirty_pages, page_lsn_map): """重放所有已提交事务的修改""" # 找到最早的脏页 LSN min_lsn = min(page_lsn_map[p] for p in dirty_pages)
# 从 min_lsn 开始重放日志 for record in log.scan_from(min_lsn): if record.type in (UPDATE, CLR): page_id = record.page_id
# 优化:如果 page_lsn >= record.lsn,跳过 # (页面已经包含了此修改) if page_id in page_lsn_map and \ page_lsn_map[page_id] >= record.lsn: continue
# 重放修改 apply_redo(record) page_lsn_map[page_id] = record.lsn
# 所有脏页刷盘 flush_all_dirty_pages()3.4 Phase 3: Undo
Undo 阶段回滚所有未提交事务:
# ARIES Undo 阶段(简化)def undo_phase(log, active_txns): """回滚所有未提交事务""" # 按事务的最后一个 LSN 逆序处理 for txn_id in active_txns: # 找到事务的最后一条日志记录 last_lsn = find_last_lsn(txn_id)
# 沿 prev_lsn 链逆序回滚 current_lsn = last_lsn while current_lsn is not None: record = log.read(current_lsn)
# 写入 CLR(Compensation Log Record) # CLR 记录 Undo 操作,本身也需要 Redo clr = create_clr(record, current_lsn) log.append(clr)
# 执行 Undo 操作 apply_undo(record)
# 移到前一条记录 current_lsn = record.prev_lsn
# 写入事务回滚完成记录 log.append(TxnEndRecord(txn_id, ABORT))3.5 CLR(Compensation Log Record)
CLR 是 ARIES 的精妙设计——Undo 操作本身也记录日志:
// CLR 记录struct CLRRecord { lsn_t lsn; // CLR 的 LSN lsn_t prev_lsn; // 同一事务的前一条记录 txn_id_t txn_id; // 事务 ID lsn_t undo_next_lsn; // 下一条需要 Undo 的记录 // 注意:CLR 没有 prev_lsn 指向被 Undo 的记录 // 而是用 undo_next_lsn 指向下一个要 Undo 的记录 // 这避免了 Undo 过程中的级联回滚};
// CLR 的作用:// 1. 记录 Undo 操作,使 Undo 本身可 Redo// 2. 如果 Undo 过程中再次崩溃,可以从 CLR 继续// 3. undo_next_lsn 避免重复 Undo四、检查点
4.1 检查点的作用
检查点(Checkpoint)减少崩溃恢复时需要扫描的日志量:
4.2 检查点类型
| 类型 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| Sharp Checkpoint | 刷盘所有脏页,暂停写入 | 恢复最快 | 需要暂停所有写入 |
| Fuzzy Checkpoint | 记录脏页信息,不立即刷盘 | 不暂停写入 | 恢复时需扫描更多日志 |
4.3 InnoDB 检查点
-- 查看 InnoDB 检查点信息SHOW ENGINE INNODB STATUS\G
-- 关键信息:-- Log sequence number: 当前 WAL LSN-- Log flushed up to: 已刷盘的 WAL LSN-- Last checkpoint at: 最后检查点的 LSN-- Modified age: 检查点之后的日志量
-- 检查点触发条件:-- 1. 日志量超过 innodb_max_dirty_pages_pct-- 2. 日志文件空间不足(循环使用)-- 3. 正常关闭时-- 4. 手动执行 FLUSH TABLES# InnoDB 检查点策略def innodb_checkpoint_strategy(): """InnoDB 检查点策略""" # 1. 异步刷脏页(Page Cleaner 线程) # 每秒检查脏页比例,超过阈值加速刷盘
# 2. Sharp Checkpoint(关闭时) # 所有脏页刷盘后才能关闭
# 3. Fuzzy Checkpoint(运行时) # 记录脏页信息,后台逐步刷盘
# 检查点 LSN 推进条件: # - 脏页已刷盘 # - 对应的日志已不再需要 pass五、WAL 实现
5.1 InnoDB Redo Log
// InnoDB Redo Log 架构// 两个固定大小的文件,循环写入// ib_logfile0, ib_logfile1
struct RedoLog { // 日志文件大小 uint64_t file_size; // 通常 48MB × 2 = 96MB
// LSN 范围 lsn_t lsn; // 当前写入位置 lsn_t flushed_lsn; // 已刷盘位置 lsn_t checkpoint_lsn; // 检查点位置
// Buffer byte *buffer; // 日志缓冲(通常 16MB) uint64_t buffer_size;};
// Redo Log 记录类型enum RedoRecordType { MLOG_1BYTE = 1, // 修改 1 字节 MLOG_2BYTE = 2, // 修改 2 字节 MLOG_4BYTE = 4, // 修改 4 字节 MLOG_8BYTE = 8, // 修改 8 字节 MLOG_REC_INSERT = 9, // 插入记录 MLOG_REC_DELETE = 10, // 删除记录 MLOG_PAGE_CREATE = 11, // 创建页面 MLOG_CHECKPOINT = 12, // 检查点};5.2 PostgreSQL WAL
-- PostgreSQL WAL 配置-- WAL 级别SHOW wal_level;-- minimal: 最少日志(不支持复制)-- replica: 支持复制(默认)-- logical: 支持逻辑复制
-- WAL 大小SHOW max_wal_size; -- 默认 1GBSHOW min_wal_size; -- 默认 80MB
-- 检查点配置SHOW checkpoint_timeout; -- 默认 5minSHOW checkpoint_completion_target; -- 默认 0.9
-- WAL 刷盘策略SHOW fsync; -- 默认 onSHOW wal_sync_method; -- 默认 fdatasync六、崩溃恢复实战
6.1 模拟崩溃与恢复
# 模拟 MySQL 崩溃# 方法 1: kill -9kill -9 $(pidof mysqld)
# 方法 2: 使用 debug 模拟# 在 MySQL 中执行:SET SESSION debug = '+d,crash_before_flush_dirty';INSERT INTO test_table VALUES (1, 'crash test');
# 重启 MySQLsystemctl start mysql
# 观察恢复日志grep "Recovery" /var/log/mysql/error.log# InnoDB: Starting crash recovery...# InnoDB: Doing recovery: scanned up to log sequence number xxx# InnoDB: Doing recovery: scanned up to log sequence number yyy# InnoDB: Database was not shutdown normally!# InnoDB: Starting crash recovery.6.2 PostgreSQL 崩溃恢复
# 模拟 PostgreSQL 崩溃kill -9 $(pidof postgres)
# 重启 PostgreSQLsystemctl start postgresql
# 观察恢复日志grep "recovery" /var/log/postgresql/postgresql-16-main.log# LOG: database system was interrupted; last known up at 2026-06-07 10:00:00# LOG: database system was not properly shut down; automatic recovery in progress# LOG: redo starts at 0/1000028# LOG: consistent recovery state reached at 0/2000060# LOG: database system is ready to accept connections6.3 恢复时间估算
# 崩溃恢复时间估算def estimate_recovery_time(checkpoint_lsn, crash_lsn, log_size_per_mb=1_000_000, redo_speed_mb_per_sec=100): """估算崩溃恢复时间""" # 需要重放的日志量 log_to_replay = crash_lsn - checkpoint_lsn log_mb = log_to_replay / log_size_per_mb
# Redo 时间 redo_time = log_mb / redo_speed_mb_per_sec
# Undo 时间(取决于未提交事务的数量和大小) # 通常远小于 Redo 时间 undo_time = redo_time * 0.1 # 估算
total_time = redo_time + undo_time
print(f"检查点 LSN: {checkpoint_lsn:,}") print(f"崩溃 LSN: {crash_lsn:,}") print(f"需重放日志: {log_mb:.1f} MB") print(f"Redo 时间: {redo_time:.2f} 秒") print(f"Undo 时间: {undo_time:.2f} 秒") print(f"总恢复时间: {total_time:.2f} 秒")
estimate_recovery_time(1_000_000, 100_000_000)# 检查点 LSN: 1,000,000# 崩溃 LSN: 100,000,000# 需重放日志: 99.0 MB# Redo 时间: 0.99 秒# Undo 时间: 0.10 秒# 总恢复时间: 1.09 秒WAL 的性能关键在于顺序写入。确保 WAL 日志文件放在独立的磁盘设备上(最好是 NVMe SSD),避免与数据文件竞争 I/O 带宽。PostgreSQL 的 wal_log_hints 和 MySQL 的 doublewrite buffer 都依赖 WAL 的顺序写入特性。
七、总结
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| WAL 协议 | 先写日志再写数据,保证崩溃后可恢复 | Write-Ahead, 持久性 |
| ARIES 三原则 | Steal 允许未提交刷盘,No-Force 不强制提交时刷盘,Redo-Undo 保证正确性 | Steal, No-Force |
| 三阶段恢复 | Analysis 确定范围 → Redo 重放修改 → Undo 回滚未提交 | Analysis, Redo, Undo |
| CLR | Undo 操作也记录日志,使恢复过程本身可恢复 | 补偿日志, 幂等 |
| 检查点 | 减少恢复时需要扫描的日志量 | 检查点, 恢复窗口 |
| 恢复时间 | 取决于检查点之后的日志量,通常秒级完成 | 秒级恢复 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






