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