数据库重启后为什么数据不丢?已提交的事务为什么一定还在,没提交的一定不在?这两个问题的答案都叫 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 的恢复子系统全是它的变体。崩溃重启后跑三个阶段:

三、完整可运行模拟器(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 回滚)
>>> 三阶段恢复结果完全正确 <<<

这个输出把崩溃恢复的全部精妙之处演了出来:

五、理论分析

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 都是这一思想。延迟换吞吐的经典权衡,在日志子系统里体现得淋漓尽致。

六、工程应用

七、常见误区

八、参考文献

  1. C. Mohan et al. (1992). ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS.(恢复算法的奠基论文)
  2. P. Bernstein, V. Hadzilacos, N. Goodman (1987). Concurrency Control and Recovery in Database Systems, Chapter 6-7. Addison-Wesley.
  3. T. Haerder, A. Reuter (1983). Principles of Transaction-Oriented Database Recovery. ACM Computing Surveys.
  4. MySQL Documentation: The InnoDB Redo Log、Doublewrite Buffer 与 innodb_flush_log_at_trx_commit 章节。
  5. PostgreSQL Documentation: Chapter 30 Reliability and the Write-Ahead Log。
← 返回文章列表