通过伪代码学习PostgreSQL的WAL和恢复机制

44 阅读5分钟

WAL是什么、有什么用,这里就不赘述了。直接看伪代码,对整个流程就有数了:

关键结构体

  1. WAL Log Record (日志记录)
  2. WAL Segment (WAL 分段)
  3. Checkpoint Record (检查点记录)
// --------------------------------------------------------
// 描述数据库操作的基本单元,用于 Redo 和 Undo 恢复
// --------------------------------------------------------
struct WAL_Log_Record {
    // 日志序列号 (Log Sequence Number, LSN):全局唯一且严格递增。
    UInt64 LSN;

    // 记录类型:BEGIN, COMMIT, ABORT, UPDATE, INSERT, CLR (Compensation Log Record,见下文) 等
    Enum Type;

    // 关联的事务 ID (Transaction ID)
    Int32 TransactionID;

    // 同一事务中上一条日志记录的 LSN。用于逆向扫描 (Backward Scan) 和回滚 (Undo)
    UInt64 PrevLSN;

    // 被修改的数据页 ID
    Int32 PageID;

    // 撤销(Undo)所需的数据:记录修改前的旧值
    Byte[] UndoData;

    // 重做(Redo)所需的数据:记录修改后的新值
    Byte[] RedoData;

    // 对于 CLR 记录,可能包含一个指向下一条需要 Undo 的记录的 LSN (UndoNextLSN)
    UInt64 UndoNextLSN; 
};


// --------------------------------------------------------
// WAL 文件在文件系统中的物理组织形式,便于管理和归档。
// --------------------------------------------------------
struct WAL_Segment {
    // 分段文件的 ID,通常按时间递增
    UInt32 SegmentID;

    // 存储在文件系统中的路径 (e.g., "wal_00000001.log")
    String FilePath;

    // 此 Segment 包含的第一条日志记录的 LSN
    UInt64 StartLSN;

    // 此 Segment 包含的最后一条日志记录的 LSN
    // 在 Segment ACTIVE 时,此值可能为 0 或指向当前写入的 LSN
    UInt64 EndLSN;

    // 状态:ACTIVE (正在写入), ARCHIVED (已归档), REMOVABLE (可移除/可删除)
    Enum Status;
};


// --------------------------------------------------------
// checkpoint会产生一种特殊的 WAL 记录,用于标记一个恢复点
// --------------------------------------------------------
struct Checkpoint_Record {
    // 此 Checkpoint 记录自身的 LSN
    UInt64 CheckpointLSN;

    // Redo 起始 LSN (Redo Start LSN):恢复操作应从这个 LSN 开始进行 Redo
    // 通常是 Checkpoint 发生时,最早的活动的事务写入的第一条日志的 LSN
    UInt64 RedoStartLSN;

    // 活动事务表 (Active Transaction Table, ATT):
    // Checkpoint 发生时,所有正在运行的事务(Transaction)及其状态的快照
    Map<TxID, Status> ActiveTransactionTable;

    // 脏页表 (Dirty Page Table, DPT):
    // Checkpoint 发生时,所有脏页(Dirty Page)及其对应的 LSN (即需要 Redo 的 LSN)
    Map<PageID, LSN> DirtyPageTable;
};

核心操作:WAL写、Checkpoint

1. 写入 WAL 记录 (AppendWALRecord)
FUNCTION AppendWALRecord(Record rec):
    // 1. 获取当前 Segment
    current_segment = WALManager.GetCurrentSegment()

    // 2. 检查 Segment 是否已满
    IF current_segment.IsFull():
        // a. 关闭当前 Segment
        WALManager.FinalizeSegment(current_segment)
        // b. 创建新的 Segment (触发 Segment 分段)
        new_segment = WALManager.CreateNewSegment()
        current_segment = new_segment

    // 3. 赋值 LSN
    rec.LSN = WALManager.GetNextLSN()
    
    // 4. 将记录写入 Segment
    current_segment.Write(rec)
    
    // 5. 将记录强制刷新到磁盘 (Force Flush)
    // 根据配置,可能是每条记录都 Flush,或周期性 Flush,或 COMMIT 时 Flush。
    IF rec.Type == COMMIT OR SystemConfig.WALFlushMode == IMMEDIATE:
        current_segment.FlushToDisk()
        
    RETURN rec.LSN
2. 执行 Checkpoint

Checkpoint 会周期性或按需执行,以减少崩溃恢复时间。

FUNCTION PerformCheckpoint():
    // 1. 暂停所有新的数据库操作(或使用 Latch/Lock 机制)
    System.PauseNewOperations()
    
    // 2. 收集恢复所需的状态信息
    checkpoint_rec = New CheckpointRecord()
    checkpoint_rec.RedoStartLSN = WALManager.FindMinLSNOfActiveTransactions()
    checkpoint_rec.ActiveTransactionTable = TransactionManager.GetActiveTransactions()
    checkpoint_rec.DirtyPageTable = BufferManager.GetDirtyPageTable()
    
    // 3. 将 Checkpoint 记录写入 WAL
    checkpoint_lsn = AppendWALRecord(checkpoint_rec)
    
    // 4. 将所有脏页刷新到数据文件
    BufferManager.FlushAllDirtyPages()
    
    // 5. 再次强制刷新 WAL,确保 Checkpoint 记录和之前的日志都已持久化
    WALManager.FlushToDisk(checkpoint_lsn)

    // 6. 写入 Checkpoint Pointer 文件
    // 在一个单独的文件 (e.g., `checkpoint_pointer`) 中记录最后成功的 Checkpoint 的 LSN?
    System.UpdateCheckpointPointer(checkpoint_lsn)

    // 7. 恢复数据库操作
    System.ResumeOperations()

    // 8. 清理旧的 Segment 
    SignalWALCleaner(checkpoint_rec.RedoStartLSN)
    
    RETURN checkpoint_lsn
3. 清理旧 Segment
FUNCTION CleanupOldSegments(RequiredMinLSN):
    // RequiredMinLSN: 恢复操作所需的最小 LSN (即 Checkpoint 中的 RedoStartLSN)
    
    FOREACH Segment seg IN WALManager.GetAllSegments():
        IF seg.EndLSN < RequiredMinLSN:
            // 确保 Segment 状态为 ARCHIVED 或类似的非 ACTIVE 状态
            IF seg.Status == ARCHIVED OR seg.Status == REMOVABLE:
                WALManager.DeleteSegmentFile(seg)
            ELSE:
                LOG_ERROR("Attempted to delete non-removable segment.")
                
    RETURN SUCCESS

核心操作:从系统崩溃中恢复

崩溃后,还未成功刷盘的事务变更需要redo,这个很好理解。那什么时候会用到undo呢?

  • 保证原子性Atomicity:如果一个事务在执行过程中因为用户取消、系统错误、死锁或其他原因abort ,那么该事务做出的一切变更都需要回滚

  • 保证隔离性Isolation:在MVCC系统中,undo日志常用于存储旧版本数据。一个事务读取数据时,如果需要看到该数据在它开始时的版本,可通过undo日志日志来重构旧版本数据,从而实现隔离性

如同checkpoint操作,每个undo操作也会产生一条WAL记录,叫CLR (Compensation Log Record)。CLR记录undo的是哪条LSN、下一步应undo哪条LSN。 如此一来,即使系统在执行 Undo 过程中再次崩溃,也能知道从哪里继续回滚。

数据库崩溃恢复的三阶段流程

当数据库系统从崩溃中重启时,它会执行以下三个阶段:

阶段一:分析 (Analysis)

系统从最近的Checkpoint record开始向前扫描 WAL,确定从哪里开始 Redo,以及哪些事务需要 Undo

建立两个表:

  • Active Transaction Table (ATT): 记录崩溃时所有正在运行(没有commit或abort记录)的事务。这些事务都需要被undo
  • Dirty Page Table (DPT): 记录崩溃时内存中所有脏页及其对应的最小LSN (即 LSN of the Redo start point)

阶段二:Redo - 保证持久性 Durability

将所有已写入 WAL 的修改都应用到数据页上,无论事务是否commit

判断条件:

  • 何时 Redo? 如果某条日志记录 LLLSN\text{LSN} 大于 数据页上的 PageLSN\text{PageLSN} (该页最后一次被修改时的 LSN\text{LSN}),则说明这条修改在崩溃前没有被写入磁盘,必须使用 RedoData\text{RedoData} 重新应用
  • 关键点: Redo\text{Redo} 不仅针对已 commit 的事务,也针对未 commit 的事务。这是因为 Redo\text{Redo} 的目的是让数据库恢复到崩溃前最新的状态(即 WAL\text{WAL} 末尾的状态),确保所有已写入 WAL\text{WAL} 的内容都已持久化。

阶段三:Undo - 保证原子性 Atomicity

系统从 ATT\text{ATT} 中所有事务的最新日志记录开始,逆序扫描 WAL。对于阶段一中确定的,每一个需要回滚的事务的修改记录,使用 UndoData\text{UndoData} 进行回滚