用filestream+binarywriter追加写入带时间戳的版本块,每块以固定头(4字节长度+8字节utc ticks)开头,配合索引文件实现可追溯、跨平台、线程安全的轻量版本历史。

用 FileStream + BinaryWriter 写入带时间戳的版本块
单个文件内做版本历史,核心不是“模拟 Git”,而是把每次变更当作一个可追溯的数据块追加写入。C# 没有内置支持,但靠自己控制文件结构就能实现——关键在于每个版本块开头必须包含长度、时间戳和校验信息。
常见错误是直接覆盖原文件或用文本拼接,导致历史丢失或解析错位。正确做法是始终以二进制追加模式打开:File.Open(path, FileMode.Append, FileAccess.Write),并在每块前写入固定头(比如 4 字节长度 + 8 字节 ticks 时间戳)。
- 版本块内容建议用
MemoryStream+BinaryFormatter(仅限可信环境)或更安全的System.Text.Json序列化后写入 - 不要用
StreamWriter追加文本,换行符和编码差异会让块边界无法准确定位 - 每次写入前先 seek 到文件末尾,避免多线程下
FileMode.Append在某些 .NET 版本中出现竞态
读取时用 FileStream 循环解析版本头
读取不是“打开文件看最后一段”,而是从头开始逐块扫描:先读 12 字节头(4+8),解出长度和时间戳,再读对应字节数的内容体。这样能按时间倒序列出所有版本,也能跳转到任意版本。
容易踩的坑是假设文件小就一次性 ReadAllBytes ——一旦文件超百 MB,内存暴涨且无法流式处理。真实场景里,版本文件可能几年积累到几百 MB,必须边读边解析。
- 用
stream.Position记录每个块起始偏移,方便后续快速定位某次提交 - 时间戳用
DateTime.UtcNow.Ticks,避免本地时区干扰和夏令时跳跃 - 遇到读取长度不匹配(比如头说要读 1024 字节,实际只剩 200),说明文件损坏或写入中断,应丢弃该块并继续下一块
删除旧版本?别真删,用 version_index.json 标记逻辑删除
物理删除文件中间某段数据在 C# 里没有原子操作,FileStream.SetLength 截断只能从末尾开始。强行“删中间”等于重写整个文件,失去轻量优势。
真正可行的做法是维护一个外部索引文件,比如 mydata.version_index.json,里面存每个版本的偏移、长度、时间戳和 is_deleted 标志。读取时跳过标记为删除的块即可。
- 索引文件本身也要用原子写法:先写临时文件,再
File.Replace替换旧索引 - 如果业务允许,干脆不删——磁盘空间比工程复杂度便宜得多;真要清理,安排后台任务定期压缩(读有效块 → 写新文件 → 原子替换)
- 别把索引和主文件放同一目录又不同名,容易被误删或备份遗漏
跨平台兼容性注意 Encoding.UTF8 和 DateTimeKind.Utc
Windows 默认 ANSI 编码、.NET Framework 默认 DateTimeKind.Unspecified,而 Linux/macOS 下 FileStream 对字节更敏感。同一份版本文件在不同系统上读出来时间错乱或字符串乱码,90% 出在这两处。
不是“功能能跑就行”,而是序列化/反序列化路径必须显式锁定编码和时区语义。
- 所有字符串写入前统一用
Encoding.UTF8.GetBytes(str),绝不依赖StreamWriter默认编码 -
DateTime全部转成Utc后再存Ticks,读取时用new DateTime(ticks, DateTimeKind.Utc) - 测试务必在 Linux 容器里跑一遍,特别是用
dotnet publish -r linux-x64后验证二进制兼容性
最麻烦的从来不是写入或读取,而是当多个进程/线程同时尝试写入同一个版本文件时,如何不破坏块边界。锁文件只是起点,FileStream 的 FileShare.None 和重试逻辑得自己兜底。










