C# 文件系统的弹性 C#如何设计一个能够容忍磁盘故障的文件处理系统

7次阅读

file.copy磁盘故障需手动重试并判错:捕获ioexception,检查hresult为0x80070015等码,对设备未就绪指数退避重试3次;多磁盘场景用diskrouter前置选盘,懒检测健康状态;跨卷move须拆解为复制+manifest记录+删除,确保可回退;日志写入要autoflush或定时flush防缓冲掩盖故障。

C# 文件系统的弹性 C#如何设计一个能够容忍磁盘故障的文件处理系统

磁盘故障时 File.Copy 直接抛 IOException 怎么办

它不区分“磁盘暂时忙”和“磁盘已离线”,只要底层 CreateFile 失败,就立刻 throw。这不是设计缺陷,而是 windows API 的默认行为——C# 只是透传了这个语义。

实操上必须自己兜底重试 + 判定错误类型:

  • 捕获 IOException 后检查 InnerException?.HResult 是否为 0x80070015(设备未就绪)、0x80070070(磁盘空间不足,但可能实为坏道假报)、或 0x80070020(进程正占用)
  • 0x80070015 做指数退避重试(比如 1s、2s、4s),最多 3 次;超过则认为磁盘已不可用
  • 避免在重试中反复调用 File.Exists——它本身也会触发磁盘访问,可能加重故障

单机多磁盘场景下如何让 FileStream 自动降级到备用盘

不能靠 try-catch 后手动切路径,因为 FileStream 构造即打开句柄,失败就结束了。得把“选择哪块盘”从 IO 路径里抽出来,变成前置决策。

推荐做法是封装一个 DiskRouter 类,内部维护磁盘健康状态缓存:

  • 启动时用 DriveInfo.GetDrives() 扫描所有本地固定磁盘,逐个执行 new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None, 1, FileOptions.RandomAccess) 测试可写性(只开不读写,快速验证)
  • 将健康盘按剩余空间排序,故障盘标记为 Unhealthy 并记录最后失败时间
  • 业务调用 DiskRouter.GetWritablePath("myapp/logs") 时,它返回当前最优路径,而非硬编码盘符

注意:不要每秒轮询磁盘状态,改用“首次访问失败 + 延迟 30 秒后重新探测”的懒检测策略,减少干扰。

Directory.Move 跨卷失败时,如何无损回退到复制+删除

跨卷移动本质是复制+删除,但 Directory.Move 把这两步锁死在原子操作里,一旦中间出错(比如目标盘突然满),源目录可能已部分清空,无法恢复。

必须拆解成可控步骤:

  • 先用 Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories) 获取全量文件路径快照(内存小,不占磁盘)
  • 逐个 File.Copy 到目标,每成功一个就记录到临时 .move_manifest.json 文件(写入目标盘根目录)
  • 全部复制完成后,再执行源目录递归删除;若删除中途失败,根据 manifest 反向清理目标盘残留文件
  • 关键点:.move_manifest.json 必须用 File.WriteAllText(path, json, Encoding.UTF8) + FileOptions.WriteThrough 确保落盘,否则断电时 manifest 丢失,回退失效

日志写入卡住时,StreamWriter 缓冲区会掩盖磁盘故障

默认 StreamWriter 开启缓冲,即使磁盘已掉线,WriteLine 仍返回成功,直到缓冲区满或 Flush 时才暴露问题——这会让故障感知延迟几十秒甚至更久。

生产环境必须关闭自动缓冲或强制同步:

  • 构造时传 new StreamWriter(stream) { AutoFlush = true },但注意性能损耗(每次写都落盘)
  • 更平衡的做法:保持缓冲,但每 5 秒调用一次 streamWriter.Flush(),并在 catch (IOException) 后立即尝试 stream.Flush() 验证底层是否存活
  • 绝对不要依赖 Dispose 触发的隐式 Flush——进程崩溃时它根本不会执行

真正难处理的是“磁盘响应极慢但不死”,这时候需要单独起一个 watchdog 线程,用 stream.BeginWrite + 超时判断,而不是等同步写卡住整个主线程

text=ZqhQzanResources