raft日志需原子写入与校验:用filemode.create打开文件,头部含crc32与长度,flush(true)强刷,失败则截断;索引与偏移分离,内存dictionary映射,扫描重建;快照截断用setlength并重置索引;windows需native flushfilebuffers保障落盘。

日志文件必须支持原子写入和校验
Raft 要求日志条目一旦 AppendEntries 返回成功,就必须持久化——否则节点重启后可能丢失已提交条目,直接破坏安全性。C# 默认的 FileStream.Write 不保证原子性,尤其在断电或崩溃时容易写入半条记录。
实操建议:
- 用
FileMode.Create+FileAccess.Write+FileShare.None打开文件,避免并发写冲突 - 每条日志前加固定长度头(如 8 字节:4 字节 CRC32 + 4 字节长度),写入时先写头、再写内容、最后调用
Flush(true)强刷到磁盘 - 不要用
StreamWriter,它带缓冲且不暴露底层FileStream的Flush(true)控制权 - 写入后立即验证头是否可解析,失败则截断文件末尾——这是恢复阶段判断“最后有效条目”的依据
索引与偏移需分离存储,不能只靠文件位置
Raft 日志需要按 index 随机查找(例如 GetEntryAt(index)),但文件是顺序追加的,直接用 index 当字节偏移会出错:不同条目长度不同,且中间可能有损坏或截断。
实操建议:
- 维护一个内存中
Dictionary<long long></long>(index→ 文件偏移),写入新条目时更新;重启时从头扫描日志重建该映射 - 扫描时跳过无法校验 CRC 的条目,将其后所有条目视为无效(Raft 要求日志连续,不接受空洞)
- 避免把索引单独存成另一个文件——多文件 I/O 增加崩溃不一致风险;单文件 + 内存索引是更稳妥的选择
快照(Snapshot)必须与日志文件协同截断
当节点安装快照后,要删除 snapshot.LastIndex 之前的所有日志条目。但如果只删文件内容,而没同步更新索引映射,后续 GetEntryAt 就会读到错误偏移甚至抛 IOException。
实操建议:
- 截断日志文件用
FileStream.SetLength(newLength),不是Delete+Create—— 后者在 Windows 上可能触发文件句柄失效 - 截断后立刻清空并重建内存索引映射,从新文件头开始重新扫描
- 快照元数据(如
snapshot.LastIndex和snapshot.Term)必须写入独立的snapshot.meta文件,并用原子重命名(File.Move(temp, final))保证可见性
Windows 上 Flush(true) 不等于落盘,得看存储栈
即使调用了 FileStream.Flush(true),某些 SSD 或虚拟机磁盘驱动仍可能缓存写入,导致崩溃后丢失最后几条日志。这不是 C# 的 bug,而是硬件/驱动行为。
实操建议:
- 生产环境务必启用
FILE_FLAG_NO_BUFFERING(通过 P/Invoke 调用CreateFile),但这要求读写对齐到扇区边界(通常 512 字节),需手动 padding - 更实用的做法:在关键路径(如 Leader 提交日志后)加一次
NativeMethods.FlushFileBuffers(handle),比纯托管Flush(true)更可靠 - 测试时用
fsutil behavior set disablelastaccess 1关闭时间戳更新,减少干扰;用sync命令(WSL)或拔电源模拟崩溃,验证日志恢复逻辑
真正难的不是写进文件,而是让“写成功”这个语义在各种崩溃场景下都成立——校验、截断、索引、刷盘,每个环节都得对齐 Raft 的安全假设,少一环就可能在半夜三点触发脑裂。








