mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1229 字
4 分钟
WAL 与崩溃恢复
2025-05-16

机房断电的瞬间,数据库正在执行三条事务:T1 已提交但脏页还没刷盘,T2 写了一半 WAL 就中断了,T3 刚修改完 Buffer Pool 中的页面还没来得及写 Redo Log。来电后重启——T1 的数据能找回吗?T2 的半截写入会污染数据吗?T3 的修改还在吗?

WAL(Write-Ahead Logging)和 ARIES 算法回答了这些问题。这是数据库保证 ACID 中 D(Durability)的工业标准方案——崩溃后数据不丢、恢复过程正确、恢复时间可控。

一、崩溃恢复问题#

1.1 崩溃场景#

graph TB subgraph 崩溃场景 T1["事务 T1<br/>已提交"] --> CRASH[" 崩溃"] T2["事务 T2<br/>未提交"] --> CRASH T3["事务 T3<br/>部分刷盘"] --> CRASH end subgraph 恢复后 T1_R["T1: 必须保留<br/>(已提交 = 持久化)"] T2_R["T2: 必须回滚<br/>(未提交 = 不一致)"] T3_R["T3: Redo 已提交部分<br/>Undo 未提交部分"] end CRASH --> T1_R CRASH --> T2_R CRASH --> T3_R style CRASH fill:#ffcdd2,stroke:#c62828 style T1_R fill:#c8e6c9,stroke:#2e7d32 style T2_R fill:#ffe0b2,stroke:#e65100 style T3_R fill:#e3f2fd,stroke:#1565c0
崩溃场景问题恢复策略
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); // 无法恢复!
}
Important

WAL 协议保证了:磁盘上的数据页面永远不会比日志记录”更新”。因此,通过重放日志,可以将数据库恢复到崩溃前的任意一致状态。

Warning

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_interval100us-1ms批次等待时间
sync_binlog100-1000MySQL binlog 批次大小
innodb_flush_log_at_trx_commit1最安全(每次 fsync)
innodb_flush_log_at_trx_commit2折衷(每秒 fsync)

二、ARIES 算法#

2.1 ARIES 三大原则#

ARIES(Algorithm for Recovery and Isolation Exploiting Semantics)是数据库崩溃恢复的工业标准算法,由 C. Mohan 等人在 1992 年提出:

原则说明为什么
Steal未提交事务的修改可以刷盘避免缓冲池满时阻塞
No-Force已提交事务的修改不必立即刷盘提升提交性能
Redo-Undo恢复时先 Redo 再 Undo保证正确性
graph TB subgraph ARIES["ARIES 算法三大原则"] STEAL["Steal<br/>未提交可刷盘"] --> REDO_UNDO["Redo + Undo<br/>恢复策略"] NO_FORCE["No-Force<br/>已提交不必刷盘"] --> REDO_UNDO REDO_UNDO --> PHASES["三阶段恢复<br/>Analysis → Redo → Undo"] end style STEAL fill:#e3f2fd,stroke:#1565c0 style NO_FORCE fill:#c8e6c9,stroke:#2e7d32 style REDO_UNDO fill:#fff9c4,stroke:#f9a825 style PHASES fill:#ffe0b2,stroke:#e65100

2.2 ARIES vs 其他策略#

策略StealNo-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 恢复流程#

graph TB subgraph ARIES恢复["ARIES 三阶段恢复"] CRASH["崩溃重启"] --> ANALYSIS["Phase 1: Analysis<br/>分析日志,确定脏页和活跃事务"] ANALYSIS --> REDO["Phase 2: Redo<br/>重放所有已提交事务的修改"] REDO --> UNDO["Phase 3: Undo<br/>回滚所有未提交事务的修改"] UNDO --> DONE["恢复完成"] end style ANALYSIS fill:#e3f2fd,stroke:#1565c0 style REDO fill:#c8e6c9,stroke:#2e7d32 style UNDO fill:#ffe0b2,stroke:#e65100 style DONE fill:#c8e6c9,stroke:#2e7d32

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)减少崩溃恢复时需要扫描的日志量:

graph LR subgraph 无检查点["无检查点"] LOG1["从头扫描全部日志<br/>恢复时间长"] end subgraph 有检查点["有检查点"] CP["检查点"] --> LOG2["只扫描检查点之后的日志<br/>恢复时间短"] end style CP fill:#c8e6c9,stroke:#2e7d32 style LOG1 fill:#ffcdd2,stroke:#c62828 style LOG2 fill:#e3f2fd,stroke:#1565c0

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; -- 默认 1GB
SHOW min_wal_size; -- 默认 80MB
-- 检查点配置
SHOW checkpoint_timeout; -- 默认 5min
SHOW checkpoint_completion_target; -- 默认 0.9
-- WAL 刷盘策略
SHOW fsync; -- 默认 on
SHOW wal_sync_method; -- 默认 fdatasync

六、崩溃恢复实战#

6.1 模拟崩溃与恢复#

# 模拟 MySQL 崩溃
# 方法 1: kill -9
kill -9 $(pidof mysqld)
# 方法 2: 使用 debug 模拟
# 在 MySQL 中执行:
SET SESSION debug = '+d,crash_before_flush_dirty';
INSERT INTO test_table VALUES (1, 'crash test');
# 重启 MySQL
systemctl 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)
# 重启 PostgreSQL
systemctl 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 connections

6.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 秒
Tip

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
CLRUndo 操作也记录日志,使恢复过程本身可恢复补偿日志, 幂等
检查点减少恢复时需要扫描的日志量检查点, 恢复窗口
恢复时间取决于检查点之后的日志量,通常秒级完成秒级恢复

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

WAL 与崩溃恢复
https://blog.souloss.com/posts/storage/storage-wal-and-crash-recovery/
作者
Souloss
发布于
2025-05-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时