4. 实现WAL日志与崩溃恢复

任务说明

实现一个最小可运行的WAL(Write-Ahead Logging)模块,支持日志追加写入、日志回放,并基于WAL实现崩溃恢复:模拟数据库在写入数据页之前先记录日志,在“崩溃”后通过回放日志将数据恢复到崩溃前的一致状态。

核心设计

本案例实现一个单文件数据库存储引擎的核心WAL机制,包含三个部分:

  • 日志记录格式:每条日志包含LSN(日志序列号)、事务ID、操作类型(INSERT/UPDATE/DELETE)、键、值以及校验和。
  • WAL写入器:所有数据修改先追加到WAL文件(wal.log),并维护当前LSN;数据页(data.db)的修改在日志落盘之后执行。
  • 崩溃恢复器:启动时读取WAL,重放所有日志到数据页,实现崩溃前已提交事务的恢复。

代码实现

import os
import struct
import hashlib

# ---------- 日志格式定义 ----------
# 日志记录二进制布局(固定40字节头 + 变长key/value):
#   LSN        : 8字节无符号整数
#   txn_id     : 4字节无符号整数
#   op_type    : 1字节 (1=INSERT, 2=UPDATE, 3=DELETE)
#   key_len    : 4字节无符号整数
#   value_len  : 4字节无符号整数
#   checksum   : 16字节 (MD5 of payload)
#   key        : key_len 字节
#   value      : value_len 字节 (DELETE时value_len=0)

OP_INSERT = 1
OP_UPDATE = 2
OP_DELETE = 3
HEADER_FMT = ">QIBII16s"
HEADER_SIZE = struct.calcsize(HEADER_FMT)

class WalLog:
    """单条WAL日志记录"""
    def __init__(self, lsn, txn_id, op_type, key, value=b""):
        self.lsn = lsn
        self.txn_id = txn_id
        self.op_type = op_type
        self.key = key
        self.value = value

    def serialize(self):
        payload = self.key + self.value
        checksum = hashlib.md5(payload).digest()
        header = struct.pack(HEADER_FMT, self.lsn, self.txn_id,
                             self.op_type, len(self.key),
                             len(self.value), checksum)
        return header + payload

    @staticmethod
    def deserialize(data):
        header = data[:HEADER_SIZE]
        lsn, txn_id, op_type, key_len, value_len, checksum = \
            struct.unpack(HEADER_FMT, header)
        key_start = HEADER_SIZE
        key_end = key_start + key_len
        value_end = key_end + value_len
        key = data[key_start:key_end]
        value = data[key_end:value_end]
        # 校验和验证
        payload = key + value
        if hashlib.md5(payload).digest() != checksum:
            raise ValueError("Checksum mismatch, log corrupted")
        return WalLog(lsn, txn_id, op_type, key, value)

class WalWriter:
    """WAL写入器:负责日志追加与数据页写入"""
    def __init__(self, wal_path="wal.log", data_path="data.db"):
        self.wal_path = wal_path
        self.data_path = data_path
        self.current_lsn = 0
        self._wal_file = open(wal_path, "ab")
        self._data_file = open(data_path, "r+b" if os.path.exists(data_path) else "w+b")

    def append_log(self, txn_id, op_type, key, value=b""):
        """先写WAL,再写数据页(WAL原则)"""
        self.current_lsn += 1
        log = WalLog(self.current_lsn, txn_id, op_type, key, value)
        # 1. 日志先落盘
        self._wal_file.write(log.serialize())
        self._wal_file.flush()
        os.fsync(self._wal_file.fileno())
        # 2. 再更新数据页
        self._apply_to_data_page(log)
        return self.current_lsn

    def _apply_to_data_page(self, log):
        """将日志操作应用到数据文件(模拟数据页)"""
        # 数据文件布局:每条记录为 [key_len(4B)][key][value_len(4B)][value]
        # 为简化演示,使用内存字典模拟数据页,实际可替换为B+树等结构
        # 此处直接操作文件:读取全部到内存,修改后写回
        data = self._read_all_data()
        if log.op_type == OP_INSERT or log.op_type == OP_UPDATE:
            data[log.key] = log.value
        elif log.op_type == OP_DELETE:
            data.pop(log.key, None)
        self._write_all_data(data)

    def _read_all_data(self):
        """读取数据文件全部内容为字典"""
        self._data_file.seek(0)
        raw = self._data_file.read()
        data = {}
        offset = 0
        while offset < len(raw):
            key_len = struct.unpack(">I", raw[offset:offset+4])[0]
            offset += 4
            key = raw[offset:offset+key_len]
            offset += key_len
            value_len = struct.unpack(">I", raw[offset:offset+4])[0]
            offset += 4
            value = raw[offset:offset+value_len]
            offset += value_len
            data[key] = value
        return data

    def _write_all_data(self, data):
        """将字典写回数据文件"""
        self._data_file.seek(0)
        self._data_file.truncate()
        for key, value in data.items():
            self._data_file.write(struct.pack(">I", len(key)))
            self._data_file.write(key)
            self._data_file.write(struct.pack(">I", len(value)))
            self._data_file.write(value)
        self._data_file.flush()
        os.fsync(self._data_file.fileno())

    def close(self):
        self._wal_file.close()
        self._data_file.close()

class CrashRecovery:
    """崩溃恢复器:启动时扫描WAL并重放日志"""
    def __init__(self, wal_path="wal.log", data_path="data.db"):
        self.wal_path = wal_path
        self.data_path = data_path

    def recover(self):
        """执行崩溃恢复,返回恢复的日志条数"""
        if not os.path.exists(self.wal_path):
            return 0
        recovered = 0
        with open(self.wal_path, "rb") as f:
            while True:
                header = f.read(HEADER_SIZE)
                if len(header) < HEADER_SIZE:
                    break  # 文件末尾或部分写入(模拟崩溃点)
                lsn, txn_id, op_type, key_len, value_len, checksum = \
                    struct.unpack(HEADER_FMT, header)
                payload_len = key_len + value_len
                payload = f.read(payload_len)
                if len(payload) < payload_len:
                    break  # 日志不完整,忽略(崩溃时未写完)
                full_record = header + payload
                try:
                    log = WalLog.deserialize(full_record)
                except ValueError:
                    break  # 校验失败,停止恢复
                # 重放日志到数据页
                self._replay(log)
                recovered += 1
        return recovered

    def _replay(self, log):
        """将单条日志重放到数据文件"""
        # 与WalWriter._apply_to_data_page逻辑相同
        data = self._read_all_data()
        if log.op_type == OP_INSERT or log.op_type == OP_UPDATE:
            data[log.key] = log.value
        elif log.op_type == OP_DELETE:
            data.pop(log.key, None)
        self._write_all_data(data)

    def _read_all_data(self):
        """读取数据文件(与WalWriter相同)"""
        if not os.path.exists(self.data_path):
            return {}
        with open(self.data_path, "rb") as f:
            raw = f.read()
        data = {}
        offset = 0
        while offset < len(raw):
            key_len = struct.unpack(">I", raw[offset:offset+4])[0]
            offset += 4
            key = raw[offset:offset+key_len]
            offset += key_len
            value_len = struct.unpack(">I", raw[offset:offset+4])[0]
            offset += 4
            value = raw[offset:offset+value_len]
            offset += value_len
            data[key] = value
        return data

    def _write_all_data(self, data):
        """写回数据文件(与WalWriter相同)"""
        with open(self.data_path, "wb") as f:
            for key, value in data.items():
                f.write(struct.pack(">I", len(key)))
                f.write(key)
                f.write(struct.pack(">I", len(value)))
                f.write(value)
            f.flush()
            os.fsync(f.fileno())

# ---------- 演示与验证 ----------
def demo_crash_recovery():
    """模拟一次正常写入 + 一次崩溃 + 恢复"""
    # 清理旧文件
    for f in ["wal.log", "data.db"]:
        if os.path.exists(f):
            os.remove(f)

    # 阶段1:正常写入两条记录
    writer = WalWriter()
    writer.append_log(txn_id=1, op_type=OP_INSERT, key=b"user:1", value=b"Alice")
    writer.append_log(txn_id=1, op_type=OP_INSERT, key=b"user:2", value=b"Bob")
    writer.close()
    print("正常写入完成,WAL中日志条数: 2")

    # 阶段2:模拟崩溃——直接删除数据文件(数据页丢失),WAL保留
    os.remove("data.db")
    print("模拟崩溃:数据文件已丢失,WAL日志保留")

    # 阶段3:执行崩溃恢复
    recovery = CrashRecovery()
    count = recovery.recover()
    print(f"崩溃恢复完成,重放日志条数: {count}")

    # 验证恢复结果
    with open("data.db", "rb") as f:
        raw = f.read()
    offset = 0
    recovered_data = {}
    while offset < len(raw):
        key_len = struct.unpack(">I", raw[offset:offset+4])[0]
        offset += 4
        key = raw[offset:offset+key_len]
        offset += key_len
        value_len = struct.unpack(">I", raw[offset:offset+4])[0]
        offset += 4
        value = raw[offset:offset+value_len]
        offset += value_len
        recovered_data[key] = value
    assert recovered_data == {b"user:1": b"Alice", b"user:2": b"Bob"}, \
        f"恢复数据不匹配: {recovered_data}"
    print("验证通过:崩溃前写入的数据已完整恢复")

if __name__ == "__main__":
    demo_crash_recovery()

运行与验证

直接运行上述脚本:

python wal_crash_recovery.py

预期输出:

正常写入完成,WAL中日志条数: 2
模拟崩溃:数据文件已丢失,WAL日志保留
崩溃恢复完成,重放日志条数: 2
验证通过:崩溃前写入的数据已完整恢复

判定标准:程序退出码为0,且最后一行输出“验证通过”即表示WAL写入与崩溃恢复功能正确。若数据文件被删除后能通过WAL完整恢复出user:1=Alice和user:2=Bob两条记录,则任务闭环达成。

WAL写入与崩溃恢复时序 应用/事务 WAL写入器 数据文件(data.db) 写操作(key,value) ① 追加日志到wal.log并fsync ② 更新数据页 返回LSN ⚠ 崩溃发生:data.db被删除,wal.log保留 恢复器 读取wal.log 逐条校验并重放日志 重建数据 ✓ 恢复完成:数据页重建,崩溃前已提交事务不丢失