KV存储笔记:WAL 与崩溃恢复
发布于:
前言
WAL(Write-Ahead Log)是 KV 存储系统中保证数据持久性和崩溃恢复的关键机制。本文从 KV 存储系统的实际实现角度,介绍 WAL 在 KV 存储中的工作原理、格式设计、崩溃恢复流程以及性能优化策略。
注:本文聚焦于 KV 存储系统中 WAL 的具体实现。关于 WAL 的通用原理和底层机制,请参考「存储基础系列」的相关文章。
1. WAL 的作用
WAL 的核心思想是:在数据写入存储引擎之前,先将操作记录写入日志。这样可以:
- 保证持久性:即使系统崩溃,已提交的操作也不会丢失
- 支持崩溃恢复:重启后可以通过重放日志恢复数据
- 提高写入性能:顺序写入日志比随机写入数据更快
2. WAL 的写入流程
2.1 基本流程
1. 接收写入请求 (Put/Delete)
2. 将操作序列化并写入 WAL
3. 同步 WAL 到磁盘(可选,取决于持久性要求)
4. 将数据写入 MemTable
5. 返回成功
2.2 同步策略
- 同步写入:每次写入后立即同步到磁盘,保证强持久性
- 异步写入:批量同步,提高性能但可能丢失最近的数据
- 定期同步:按时间间隔同步,平衡性能和持久性
3. WAL 的格式
WAL 通常采用二进制格式,包含:
- 操作类型:Put、Delete 等
- Key:键的长度和内容
- Value:值的长度和内容(Delete 操作没有 Value)
- 序列号:用于去重和排序
- 校验和:用于检测数据损坏
4. 崩溃恢复流程
4.1 恢复步骤
- 扫描 WAL 文件:找到所有未处理的日志条目
- 重放日志:按顺序将操作应用到 MemTable
- 重建索引:如果使用 B+Tree,需要重建索引结构
- 验证数据:检查数据的完整性和一致性
4.2 去重处理
由于 WAL 可能包含重复的操作,恢复时需要:
- 使用序列号去重
- 只保留最新的操作
- 跳过已处理的操作
5. WAL 的管理
5.1 日志轮转
当日志文件过大时,需要:
- 创建新的日志文件
- 将旧日志归档或删除
- 更新日志文件列表
5.2 日志清理
当数据已持久化到存储引擎后:
- 可以安全删除对应的日志
- 需要跟踪哪些日志可以清理
- 避免过早删除导致无法恢复
6. 性能优化
6.1 批量写入
- 将多个操作合并写入日志
- 减少系统调用和磁盘 IO
- 提高写入吞吐量
6.2 压缩
- 对日志进行压缩
- 减少磁盘空间占用
- 降低 IO 开销
6.3 异步刷新
- 使用异步 IO 刷新日志
- 不阻塞写入操作
- 提高并发性能
7. KV 存储系统中的 WAL 实现案例
7.1 RocksDB 的 WAL 实现
RocksDB 的 WAL 特点:
- 格式:使用 RecordBatch 格式,支持批量写入
- 同步策略:支持
sync和async两种模式 - 压缩:支持 WAL 压缩,减少磁盘空间
- 轮转:自动轮转 WAL 文件,避免单个文件过大
7.2 LevelDB 的 WAL 实现
LevelDB 的 WAL 特点:
- 格式:使用变长记录格式,每条记录包含类型、长度、数据
- 同步策略:默认异步写入,可配置同步写入
- 恢复:启动时自动扫描并重放 WAL
7.3 BadgerDB 的 WAL 实现
BadgerDB 的 WAL 特点:
- 格式:使用 Value Log(vlog)格式,专门优化大 Value
- 分离设计:Key 和 Value 分离存储,Value 存储在 vlog 中
- 异步刷新:使用异步 IO 刷新,提高性能
8. KV 存储系统中的特殊考虑
8.1 大 Value 的处理
KV 存储系统的 Value 可能很大,WAL 设计需要考虑:
- Value 分离:大 Value 可以存储在单独的文件中,WAL 只记录引用
- 压缩:对大 Value 进行压缩,减少 WAL 大小
- 流式写入:支持流式写入大 Value,避免内存占用过大
8.2 批量操作的支持
KV 存储系统通常支持批量操作,WAL 需要支持:
- 批量记录:将多个操作合并为一条 WAL 记录
- 事务支持:支持事务的原子性写入
- 部分失败处理:批量操作中部分失败时的恢复
8.3 多版本支持
KV 存储系统可能需要多版本,WAL 需要支持:
- 版本号:每条记录包含版本号
- 版本合并:恢复时合并多个版本
- 版本清理:定期清理旧版本
9. 性能优化实践
9.1 写入路径优化
// KV 存储系统的 WAL 写入优化示例
class KVStorageWAL {
// 批量写入缓冲区
std::vector<WALRecord> write_buffer_;
// 异步刷新线程
std::thread flush_thread_;
void write(const std::vector<KVOperation>& ops) {
// 1. 序列化操作到缓冲区
for (const auto& op : ops) {
write_buffer_.emplace_back(serialize(op));
}
// 2. 批量写入 WAL(减少系统调用)
if (write_buffer_.size() >= batch_size_) {
flush();
}
}
void flush() {
// 批量写入磁盘
wal_file_->write_batch(write_buffer_);
// 根据持久性要求决定是否 fsync
if (sync_mode_ == SYNC) {
wal_file_->fsync();
}
write_buffer_.clear();
}
};
9.2 恢复路径优化
// KV 存储系统的 WAL 恢复优化
class KVStorageRecovery {
void recover() {
// 1. 扫描 WAL 文件(并行扫描多个文件)
std::vector<WALRecord> records = scan_wal_files();
// 2. 按序列号排序
std::sort(records.begin(), records.end(),
[](const auto& a, const auto& b) {
return a.seq < b.seq;
});
// 3. 去重(只保留最新的操作)
records = deduplicate(records);
// 4. 批量重放到 MemTable
batch_replay_to_memtable(records);
}
};
10. 小结
WAL 是 KV 存储系统保证数据持久性的关键机制。通过先写日志再写数据,可以在系统崩溃后恢复数据,保证已提交的操作不丢失。
核心要点:
- WAL 的作用:保证持久性、支持崩溃恢复、提高写入性能
- 同步策略:需要在持久性和性能之间权衡
- 恢复流程:扫描、重放、去重、验证
- 性能优化:批量写入、压缩、异步刷新
在 KV 存储系统中的特殊考虑:
- 大 Value 处理:需要特殊设计避免 WAL 过大
- 批量操作支持:需要支持批量写入和事务
- 多版本支持:需要支持版本号和版本合并
在实际实现中,需要平衡持久性要求和性能开销,选择合适的同步策略和优化方案。不同的 KV 存储系统根据其应用场景,会采用不同的 WAL 实现策略。