4. 预写日志(WAL)实现:日志结构、刷盘策略、崩溃后重放
预写日志(Write-Ahead Logging, WAL)是保证崩溃一致性的核心机制。所有数据修改在写入数据文件前,必须先写入持久化的日志记录。这样系统崩溃后可以通过回放WAL恢复到一致状态。
4.1 日志结构
典型的WAL记录采用定长头部 + 变长载荷的格式,每条记录包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| SeqNo | uint64 | 单调递增序列号,用于顺序和去重 |
| Type | uint8 | 操作类型:INSERT / UPDATE / DELETE / CHECKPOINT |
| Length | uint32 | Payload数据长度(字节) |
| Payload | 可变 | 具体修改的数据(如行记录或键值对) |
| Checksum | CRC32C | 从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重放按照以下步骤恢复一致状态:
- 扫描日志目录:按文件名排序,确定起始序列号和结束序列号。
- 校验完整性:对每条记录计算CRC32C,与存储的校验和对比,不一致则丢弃该记录及其之后所有记录(或从上一个检查点重放)。
- 应用日志:从检查点后的第一条记录开始,顺序执行INSERT/UPDATE/DELETE操作。已持久化的数据通过主键或行版本号判断是否跳过。
- 写入检查点:重放完成后,创建一个新的检查点标记,包含当前应用的最大序列号,加速下次恢复。
重放幂等性关键:日志操作必须设计为可重复执行而不损坏数据,例如UPDATE使用“当前值+新值”方式,DELETE使用主键重试。
4.4 WAL生命周期流程
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_size | 64 MB | 太大增加重放延时,太小频繁切换文件 |
| wal_flush_mode | group_commit | 平衡性能与安全;数据库主节点建议同步模式 |
| wal_group_commit_interval | 1 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);
}
}