windows上filestream写入崩溃致文件变脏,因默认不保证原子性且不自动刷盘;应采用临时文件+原子重命名方案实现崩溃安全写入。

Windows 上 FileStream 写入崩溃后为什么文件常变脏?
因为默认的 FileStream 不保证原子写入,也不自动刷盘。写到一半进程挂了、断电了,Write() 可能只落盘一部分,或者缓存还在内核里没到磁盘,导致文件内容错位、截断或乱码。
这不是 .NET 的 bug,是底层 Windows 文件系统(NTFS/exFAT)和硬件缓存共同决定的:除非显式要求,否则操作系统不会把每次 Write() 都同步到物理介质上。
-
FileStream默认启用内核缓冲(FileOptions.None),Flush()只刷到内核缓冲区,不触发FlushFileBuffers() - 即使调用
Flush(true)(即FlushAsync(true)),也只对当前句柄生效;若文件被其他进程打开,仍可能看到中间状态 - 重命名(
File.Move())在同卷下是原子的,但前提是目标文件尚未存在 —— 这是构建“日志式”写入的基础
用临时文件 + 原子重命名实现崩溃安全写入
这是最轻量、兼容性最好、无需额外依赖的做法,核心思路是:先写到一个带随机后缀的临时文件,写完校验再 Move() 覆盖原文件。只要重命名发生在同一 NTFS 卷,就是原子操作,不会出现“半更新”状态。
- 临时路径必须和目标路径在同一卷,否则
Move()会退化为复制+删除,失去原子性 —— 用Path.GetPathRoot()检查 - 务必在
Move()前调用fs.Flush(true)和fs.Close(),否则 NTFS 可能延迟落盘,重命名后读到空/旧内容 - 推荐加
FileOptions.WriteThrough | FileOptions.NoBuffering(需对齐缓冲区大小),但仅限高性能场景;普通业务用Flush(true)足够
示例关键片段:
string tempPath = Path.ChangeExtension(filePath, $".tmp.{Guid.NewGuid():N}");
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
{
fs.Write(data, 0, data.Length);
fs.Flush(true); // 确保真正落盘
}
File.Move(tempPath, filePath, true); // 同卷下原子覆盖
日志结构(WAL)不适合普通文件 IO 场景
别一听到“崩溃一致性”就想到 WAL(Write-Ahead Logging)。SQL Server 或 SQLite 那套是为事务引擎设计的,要维护日志索引、校验、回放逻辑 —— 对单个配置文件、JSON 日志、导出 CSV 来说,纯属杀鸡用牛刀。
- 自己实现 WAL 至少要处理:日志轮转、崩溃后扫描校验、主文件与日志对齐、多线程写入竞争 ——
FileStream完全不帮你管这些 - .NET 没有内置 WAL 文件抽象;
MemoryMappedFile或FileStream都得你手动设计日志头、CRC、提交标记 - 唯一合理用 WAL 的情况:你在写一个嵌入式键值库,且必须支持并发写+掉电恢复 —— 否则直接用 SQLite
Copy-on-Write(CoW)不是 .NET 开发者能直接控制的
NTFS 本身不支持 CoW,ReFS 才有,而且需要开启卷级选项(fsutil behavior set disablelastaccess 1 等并不等价)。.NET 的 FileStream 更不可能调用 ReFS 的 CoW 接口 —— 那属于存储驱动层行为,应用层只能被动适配。
- 所谓“CoW 文件系统保障一致性”,其实是说:写新数据时分配新块,旧块保留直到提交完成。但这对应用透明,你无法感知或触发它
- 即便用 ReFS,也要配合
FILE_FLAG_NO_BUFFERING和扇区对齐 I/O 才能真正受益;普通FileStream仍走缓存路径,CoW 优势被抵消 - 别指望靠换文件系统绕过写入逻辑设计 —— NTFS 占比仍超 95%,且 CoW 不解决“写一半崩溃”问题,只降低元数据损坏概率
真正容易被忽略的点是:原子重命名只保“存在性”和“完整性”,不保“时效性”。如果程序在 Move() 后立即读取,而另一个进程刚打开旧句柄,它仍可能读到旧内容 —— 这是 Windows 文件共享语义决定的,不是 bug,得靠应用层加锁或版本号规避。










