数据库重启后为什么数据不丢?已提交的事务为什么一定还在,没提交的一定不在?这两个问题的答案都叫 WAL(Write-Ahead Logging,预写日志)。本文用一个 160 行的 Python 模拟器完整复现 WAL 纪律与 ARIES 恢复算法:构造一次真实的崩溃——磁盘上留下旧值与未提交的脏数据混杂——然后跑 Analysis/Redo/Undo 三阶段,把数据库精确恢复到『已提交的都在、未提交的都滚』的一致状态。
一、WAL 纪律:先日志,后数据
数据库的每次修改产生两类写入:数据页(随机 I/O、慢)和日志记录(顺序追加、快)。WAL 的核心纪律只有两条:
- 提交纪律:事务提交前,它的全部日志记录必须已经刷到磁盘。数据页刷不刷无所谓——反正有日志可以重做。
- 刷页纪律:任何脏数据页在落盘前,必须保证记录这次修改的日志已经先落盘。否则崩溃后磁盘上会出现没有任何日志佐证的数据,恢复无从下手。
这两条纪律把一个『必须原子地把 N 个随机页写盘』的难题,转化成了『顺序追加几行日志』——把随机写变成顺序写,这是 WAL 既保证持久性又提升性能的根本原因。
二、ARIES 恢复算法:三阶段
ARIES(Algorithm for Recovery and Isolation Exploiting Semantics,Mohan 等 1992 年为 IBM DB2 设计)是崩溃恢复的黄金标准,PostgreSQL、InnoDB、SQL Server 的恢复子系统全是它的变体。崩溃重启后跑三个阶段:
- Analysis(分析):从最近的检查点开始正向扫日志,重建崩溃瞬间的事务表(哪些已提交、哪些还悬着)与脏页表(哪些页的最新版本可能没落盘)。
- Redo(重做):从检查点重放所有日志记录的修改,包括未提交事务的——这一步无条件重现崩溃前的内存状态,保证已提交事务的成果全在。Redo 是幂等的:靠页上记录的 pageLSN 判断这次修改是否已经应用过,重复执行无副作用。
- Undo(回滚):逆序回滚所有未提交事务的修改,每回滚一步还会写一条补偿日志(CLR),保证恢复过程中再崩溃还能继续——恢复本身也遵循 WAL。
三、完整可运行模拟器(Python 3,约 160 行)
下面的程序实现了带 LSN 的 WAL、遵守刷页纪律的缓冲池、检查点与 ARIES 三阶段恢复。剧本故意设计得最凶险:崩溃瞬间磁盘上一个页是旧值(已提交事务的最新修改没落盘)、另一个页是脏值(未提交事务的修改被检查点提前刷了盘):
#!/usr/bin/env python3
"""WAL + ARIES 恢复模拟:先日志后数据、检查点、
崩溃后 Analysis/Redo/Undo 三阶段"""
class LSN:
n = 0
@classmethod
def next(cls):
cls.n += 1
return cls.n
class WAL:
def __init__(self):
self.records = [] # (lsn, tx, op, page, before, after)
self.flushed = 0 # 已刷盘的日志 LSN 水位
def append(self, tx, op, page=None, before=None, after=None):
lsn = LSN.next()
self.records.append((lsn, tx, op, page, before, after))
return lsn
def flush(self, upto):
self.flushed = max(self.flushed, upto)
class BufferPool:
"""数据页缓存:脏页只有在对应日志刷盘后才能落盘"""
def __init__(self, wal):
self.wal = wal
self.mem = {} # page -> (value, page_lsn)
self.disk = {} # page -> value(持久态)
def update(self, page, value, lsn):
self.mem[page] = (value, lsn)
def flush_page(self, page):
val, plsn = self.mem[page]
assert plsn <= self.wal.flushed, \
'WAL 纪律被违反: 页 LSN %d > 日志水位 %d' \
% (plsn, self.wal.flushed)
self.disk[page] = val
class ARIES:
def __init__(self):
self.wal = WAL()
self.bp = BufferPool(self.wal)
self.tx_state = {}
def begin(self, tx):
self.tx_state[tx] = 'running'
self.wal.append(tx, 'BEGIN')
def update(self, tx, page, before, after):
lsn = self.wal.append(tx, 'UPDATE', page, before, after)
self.bp.update(page, after, lsn) # 先记日志,再改内存页
def commit(self, tx):
lsn = self.wal.append(tx, 'COMMIT')
self.wal.flush(lsn) # 提交前日志必须刷盘
self.tx_state[tx] = 'committed'
def checkpoint(self):
lsn = self.wal.append('-', 'CHECKPOINT')
self.wal.flush(lsn)
for pg in list(self.bp.mem):
self.bp.flush_page(pg)
return lsn
def crash(self):
"""掉电:内存页全丢,只有已刷盘日志与已落盘页幸存"""
self.bp.mem.clear()
return dict(self.bp.disk)
def recover(self, survived_disk):
log = []
# --- Analysis: 找出赢家与输家事务 ---
winners, losers = set(), set()
for lsn, tx, op, *_ in self.wal.records[:self.wal.flushed]:
if op == 'BEGIN':
losers.add(tx)
if op == 'COMMIT':
losers.discard(tx)
winners.add(tx)
log.append('Analysis: 已提交事务=%s, 未提交(输家)=%s'
% (sorted(winners), sorted(losers)))
# --- Redo: 重放所有已刷日志的 UPDATE(幂等,含输家) ---
for lsn, tx, op, page, before, after \
in self.wal.records[:self.wal.flushed]:
if op == 'UPDATE':
survived_disk[page] = after
log.append('Redo: LSN%d 重放 %s 对 %s 的修改 -> %s'
% (lsn, tx, page, after))
# --- Undo: 逆序回滚输家事务的修改 ---
undone = 0
for lsn, tx, op, page, before, after \
in reversed(self.wal.records[:self.wal.flushed]):
if op == 'UPDATE' and tx in losers:
survived_disk[page] = before
undone += 1
log.append('Undo: 回滚 %s 对 %s 的修改 -> 恢复为 %s'
% (tx, page, before))
log.append('恢复完成: 回滚 %d 处修改' % undone)
return survived_disk, log
print('=== WAL + ARIES 崩溃恢复模拟 ===')
db = ARIES()
# t1 正常提交:A=100->150
db.begin('t1')
db.update('t1', 'A', 100, 150)
db.commit('t1')
print('t1 提交(A=150),日志已刷盘到 LSN%d' % db.wal.flushed)
# t2 未提交:B=200->250
db.begin('t2')
db.update('t2', 'B', 200, 250)
print('t2 改了 B=250 但未提交')
# 检查点:把脏页刷盘(WAL 纪律保证此时日志已落盘)
db.checkpoint()
print('检查点完成,A/B 两页落盘(含 t2 的未提交修改 B=250)')
# t1 再改 A=150->180,只写日志没刷页就崩溃
db.update('t1', 'A', 150, 180)
db.wal.flush(LSN.n)
print('t1 又把 A 改为 180,日志落盘但数据页未刷')
survived = db.crash()
print('*** 崩溃!内存全丢,磁盘上幸存: %s'
'(A 还是旧值 150,B 是未提交的 250)' % survived)
final, log = db.recover(survived)
for line in log:
print(line)
print('恢复后磁盘: %s' % final)
print('验证: A=180(t1 已提交,Redo 找回), '
'B=200(t2 未提交,Undo 回滚)')
assert final['A'] == 180 and final['B'] == 200
print('>>> 三阶段恢复结果完全正确 <<<')
四、真实运行输出与解读
以上代码在 Python 3 下原样运行(零依赖),输出如下:
=== WAL + ARIES 崩溃恢复模拟 ===
t1 提交(A=150),日志已刷盘到 LSN3
t2 改了 B=250 但未提交
检查点完成,A/B 两页落盘(含 t2 的未提交修改 B=250)
t1 又把 A 改为 180,日志落盘但数据页未刷
*** 崩溃!内存全丢,磁盘上幸存: {'A': 150, 'B': 250}
(A 还是旧值 150,B 是未提交的 250)
Analysis: 已提交事务=['t1'], 未提交(输家)=['t2']
Redo: LSN2 重放 t1 对 A 的修改 -> 150
Redo: LSN5 重放 t2 对 B 的修改 -> 250
Redo: LSN7 重放 t1 对 A 的修改 -> 180
Undo: 回滚 t2 对 B 的修改 -> 恢复为 200
恢复完成: 回滚 1 处修改
恢复后磁盘: {'A': 180, 'B': 200}
验证: A=180(t1 已提交,Redo 找回), B=200(t2 未提交,Undo 回滚)
>>> 三阶段恢复结果完全正确 <<<
这个输出把崩溃恢复的全部精妙之处演了出来:
- 崩溃现场是最混乱的:磁盘上 A=150 是旧值(t1 把 A 改成 180 的修改只写了日志没落页),B=250 是脏值(t2 未提交,但检查点已经把它的修改刷上了盘)。一个『不够新』,一个『不该有』——两种不一致同时存在。
- Redo 无条件重演历史:注意 LSN5 那条——t2 是输家事务,它的修改照样被 Redo。ARIES 的原则是 Redo 阶段不问是非,先把崩溃前的物理状态完整重现;是非留给 Undo 阶段清算。这让 Redo 逻辑极其简单可靠,也因此可以做成纯物理重放、速度极快。
- Undo 只清算输家:t2 的 B 被逆序回滚到 200,t1 的两次修改原封不动。最终磁盘状态精确等于『所有已提交事务都生效、所有未提交事务都像没发生过』。
- 检查点是双刃剑:本例中检查点把 t2 的脏页提前刷盘,正是它制造了崩溃现场的脏值——但这完全符合设计:检查点的职责是限制恢复时需要重放的日志量(只从检查点之后扫),它从不保证盘上数据的事务一致性,一致性是恢复阶段的活。
五、理论分析
5.1 LSN:全系统的逻辑时钟
LSN(Log Sequence Number)是单调递增的日志序号,它是整个恢复系统的坐标系:每条日志记录有 LSN,每个数据页头记录最后修改它的 pageLSN,检查点记录自己的 LSN。Redo 时『pageLSN ≥ 记录 LSN 则跳过』一行判定实现幂等;刷页纪律『pageLSN ≤ 已刷日志水位』一句话可断言。ARIES 的所有正确性论证都建立在这个全局序上。
5.2 为什么 Undo 也要写日志(CLR)
补偿日志记录(Compensation Log Record)记录『我回滚了哪一步』。如果恢复过程中再次崩溃,第二次恢复靠 CLR 知道哪些回滚已经做过,避免重复回滚同一个修改(重复回滚 before 值会把数据改错)。ARIES 的『重复历史 + CLR』设计让恢复本身也是崩溃安全的——这是对『恢复过程不能失败』这一递归要求的优雅回答。
5.3 组提交:WAL 的性能补丁
WAL 把每次提交的代价压成一次顺序写,但高频小事务下 fsync 仍是瓶颈。组提交(group commit)让多个事务的日志合并成一次刷盘:InnoDB 的 redo log 与 binlog 两阶段组提交、PostgreSQL 的 commit_siblings/commit_delay 都是这一思想。延迟换吞吐的经典权衡,在日志子系统里体现得淋漓尽致。
六、工程应用
- InnoDB 对应物:redo log(innodb_log_file_size 决定可重放窗口)、doublewrite buffer(防页半写——WAL 假设页写入原子,16KB 页在不保证原子写的硬件上需要双写兜底)、innodb_flush_log_at_trx_commit 参数(1=每提交刷盘最安全,0/2 用崩溃时最多丢 1 秒数据换性能)。
- PostgreSQL 对应物:WAL 段文件(pg_wal 目录)、checkpoint_timeout/checkpoint_completion_target 控制检查点节奏、full_page_writes 防半页写(与 doublewrite 殊途同归)。
- 复制与备份的基石:物理复制(MySQL binlog 的 row 模式、PostgreSQL 流复制)本质就是把 WAL 流式送到备库重放;PITR(时间点恢复)= 基础备份 + 归档 WAL 重放到目标时刻。理解了 Redo 就理解了复制的全部原理。
- 观测恢复成本:崩溃后启动慢,多半是要重放的日志太多——检查点间隔太大或 redo/WAL 堆积。InnoDB 看 Log sequence number 与 Last checkpoint at 的差值,PostgreSQL 看 pg_stat_bgwriter 的 checkpoint 统计。
七、常见误区
- 误区 1:『redo 日志 = binlog』。两者层级不同:redo(InnoDB 层)保证崩溃恢复,内容是物理页修改;binlog(Server 层)服务于复制与归档,内容是逻辑行变更。InnoDB 用两阶段提交(XA)保证两者一致——这也是 MySQL 里『两阶段提交』一词的真正出处。
- 误区 2:『提交了数据就落盘了』。提交只保证日志落盘,数据页可能还躺在缓冲池里几小时。掉电不丢数据靠的不是提交时刷数据,而是恢复时重放日志。
- 误区 3:『检查点保证数据一致性』。恰恰相反,检查点可以坦然地把未提交事务的脏页刷盘(本文演示中的 B=250)。它只限制恢复工作量,一致性永远是 Redo+Undo 的产物。
- 误区 4:『Redo 只需重做已提交事务』。ARIES 的 Redo 重复全部历史包括输家事务——先重建物理真相,再用 Undo 清算。只重做赢家会让 Undo 失去一致的物理起点,算法反而无法成立。
- 误区 5:『SSD 时代 WAL 的顺序写优势没了』。顺序写在 SSD 上依然比随机写省擦除块、延长寿命,且 fsync 合并的收益与介质无关。WAL 的价值一半在恢复正确性,那一半永不过时。
八、参考文献
- C. Mohan et al. (1992). ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS.(恢复算法的奠基论文)
- P. Bernstein, V. Hadzilacos, N. Goodman (1987). Concurrency Control and Recovery in Database Systems, Chapter 6-7. Addison-Wesley.
- T. Haerder, A. Reuter (1983). Principles of Transaction-Oriented Database Recovery. ACM Computing Surveys.
- MySQL Documentation: The InnoDB Redo Log、Doublewrite Buffer 与 innodb_flush_log_at_trx_commit 章节。
- PostgreSQL Documentation: Chapter 30 Reliability and the Write-Ahead Log。