4. 预写日志(WAL)实现:日志结构、刷盘策略、崩溃后重放

预写日志(Write-Ahead Logging, WAL)是保证崩溃一致性的核心机制。所有数据修改在写入数据文件前,必须先写入持久化的日志记录。这样系统崩溃后可以通过回放WAL恢复到一致状态。

4.1 日志结构

典型的WAL记录采用定长头部 + 变长载荷的格式,每条记录包含以下字段:

字段类型说明
SeqNouint64单调递增序列号,用于顺序和去重
Typeuint8操作类型:INSERT / UPDATE / DELETE / CHECKPOINT
Lengthuint32Payload数据长度(字节)
Payload可变具体修改的数据(如行记录或键值对)
ChecksumCRC32C从SeqNo到Payload的完整性校验

日志文件按固定大小分片(例如64MB),文件名包含起始序列号,便于重放时快速定位。

4.2 刷盘策略

根据持久化时机和性能要求,WAL支持三种刷盘模式:

  • 同步刷盘(fsync every write):每次提交立即调用fsync(),最高安全性,但单次TPS较低。
  • 组提交(Group Commit):累积多个提交事务后统一刷盘,减少fsync次数,典型配置为每1ms或每32条记录刷一次。
  • 异步刷盘(periodic flush):仅定时(例如200ms)将缓冲写入文件,不主动调用fsync,依赖OS刷脏策略,崩溃可能丢失最后一批日志。

以下为C++伪代码展示同步刷盘的核心流程:

bool wal_append(const LogRecord& record) {
    // 1. 追加到内存缓冲区
    buffer_.append(reinterpret_cast(&record), record.total_size());
    // 2. 写入文件描述符
    ssize_t written = write(fd_, buffer_.data() + flushed_, buffer_.size() - flushed_);
    if (written < 0) return false;
    flushed_ += written;
    // 3. 强制刷盘到持久化存储
    if (fsync(fd_) != 0) return false;
    return true;
}

4.3 崩溃后重放

系统重启时,WAL重放按照以下步骤恢复一致状态:

  1. 扫描日志目录:按文件名排序,确定起始序列号和结束序列号。
  2. 校验完整性:对每条记录计算CRC32C,与存储的校验和对比,不一致则丢弃该记录及其之后所有记录(或从上一个检查点重放)。
  3. 应用日志:从检查点后的第一条记录开始,顺序执行INSERT/UPDATE/DELETE操作。已持久化的数据通过主键或行版本号判断是否跳过。
  4. 写入检查点:重放完成后,创建一个新的检查点标记,包含当前应用的最大序列号,加速下次恢复。

重放幂等性关键:日志操作必须设计为可重复执行而不损坏数据,例如UPDATE使用“当前值+新值”方式,DELETE使用主键重试。

4.4 WAL生命周期流程

WAL 写入→刷盘→崩溃→重放流程图 WAL 写入 → 刷盘 → 崩溃 → 重放 事务提交 日志写入内存缓冲区 刷盘策略:同步 / 组提交 / 异步 fsync() 持久化到磁盘 系统崩溃 重放恢复 ① 扫描目录排序 ② 校验CRC32C ③ 从检查点重放 ④ 写新检查点 ⑤ 服务就绪

4.5 调试与观测

生产环境中可通过以下手段验证WAL行为:

  • 日志回放工具:独立工具读取原始WAL文件,打印每条记录的SeqNo、Type和Payload摘要。
  • 慢刷盘监控:记录每次fsync()耗时,超过阈值(如50ms)打印告警。
  • 模拟崩溃测试:在事务提交后、fsync前通过kill -9触发进程退出,重启后验证数据一致性。
# 示例:使用wal_dump工具查看日志
./wal_dump --wal-path=/var/lib/db/wal/ --max-records=10
SeqNo=1001 Type=INSERT  Payload="key:user:1001  value:alice"
SeqNo=1002 Type=UPDATE  Payload="key:user:1002  value:bob status:active"
SeqNo=1003 Type=COMMIT  Payload="txn_id=42"

4.6 部署与配置建议

参数推荐值说明
wal_dir/var/lib/db/wal使用独立磁盘(如SSD)存放,避免与数据文件竞争IO
wal_file_size64 MB太大增加重放延时,太小频繁切换文件
wal_flush_modegroup_commit平衡性能与安全;数据库主节点建议同步模式
wal_group_commit_interval1 ms组提交等待窗口,短于1ms可能增加CPU开销

4.7 常见错误与处理

常见问题1:日志文件校验失败

原因:磁盘扇区损坏、部分写。处理:从最近完整检查点重新拉取全量数据重建WAL,或从副本同步。

常见问题2:重放时主键冲突

原因:重放时数据已通过其他路径(如副本apply)写入。处理:在重放逻辑中使用“INSERT ON CONFLICT DO NOTHING”语义,跳过已存在记录。

4.8 性能与稳定性优化

  • 预分配日志文件:使用fallocate()提前分配空间,减少运行时文件系统碎片。
  • 批量写入:对于异步模式,合并多个小纪录为一个大write(),减少系统调用。
  • 双缓冲机制:维护两个缓冲区(当前写入、待刷盘),允许写入与刷盘并发进行。
  • 检查点频率调整:活跃更新时每10000条记录或每5分钟写入检查点,避免重放过长。
  • 使用O_DIRECT:跳过操作系统page cache,降低fsync延迟抖动(需文件系统对齐支持)。
// 双缓冲示例:写入前台buffer,后台线程轮换刷盘
void wal_flush_thread() {
    while (running_) {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        std::swap(write_buffer_, flush_buffer_);
        // flush_buffer_ 批量写入并 fsync
        int fd = open(wal_file, O_WRONLY | O_DSYNC);
        write(fd, flush_buffer_->data(), flush_buffer_->size());
        fsync(fd);
        close(fd);
    }
}