从零搭建灌装监控系统(十二):配置系统,原子写入与容错

配置系统:原子写入与容错

这是「从零搭建灌装监控系统」系列第12篇。配置文件看起来只是几行 JSON,真正运行在现场以后,它却承担了设备地址、串口参数和操作偏好。这篇不讨论怎么反序列化对象,而是解决保存过程中最容易被忽略的三个问题:原子写入、并发保存和损坏恢复。


断电以后,设备参数变成半个 JSON

现场调试时,操作员改了串口号,点击保存,程序马上退出。第二次启动时,系统提示配置格式错误,设备也连不上了。

打开文件一看,内容停在一个对象的一半:少了结尾的大括号,最后一个字段也没有写完。原因很现实,程序写文件不是瞬间完成的,写到一半突然断电、进程崩溃或 U 盘被拔出,都可能留下半成品。

如果直接使用 File.WriteAllText(path, json),等于让正式文件承担写入过程中的风险。更稳妥的办法是:先写临时文件,确认写入完成后,再用替换动作把临时文件变成正式文件。


原子写入的核心动作

原子写入不是说磁盘真的只用了一个硬件动作,而是让程序的观察者只看到两种状态:旧文件完整,或者新文件完整,不看到中间过程。

csharp 复制代码
private static async Task WriteAtomicallyAsync(
    string path, string content, CancellationToken token)
{
    var directory = Path.GetDirectoryName(path)
        ?? throw new ArgumentException("配置路径无效", nameof(path));

    Directory.CreateDirectory(directory);
    var tempPath = path + ".tmp";

    await File.WriteAllTextAsync(path: tempPath,
        contents: content,
        encoding: new UTF8Encoding(true),
        cancellationToken: token);

    File.Move(tempPath, path, overwrite: true);
}

这段代码已经比直接覆盖正式文件安全很多,但还不够完整:如果替换前进程崩溃,.tmp 文件会留下;如果目标文件被其他程序占用,替换会失败;如果保存请求同时到达,还可能发生互相覆盖。

所以原子写入是一个流程,不只是 File.Move 这一行。


配置保存必须串行化

设置页面可能有"保存"按钮,后台服务也可能在连接模式切换时保存配置。如果两个任务同时序列化并写文件,最后写入的内容不一定是用户最后看到的内容。

用 SemaphoreSlim 给保存操作加一把异步锁:

csharp 复制代码
public static class ConfigService
{
    private static readonly SemaphoreSlim SaveGate = new(1, 1);

    public static async Task SaveAsync(
        DeviceSettings settings, string path,
        CancellationToken token = default)
    {
        await SaveGate.WaitAsync(token);
        try
        {
            var json = JsonSerializer.Serialize(settings,
                new JsonSerializerOptions { WriteIndented = true });
            await SaveAtomicallyAsync(path, json, token);
        }
        finally
        {
            SaveGate.Release();
        }
    }
}

lock 不能包住 await,因为它是同步锁;SemaphoreSlim.WaitAsync 适合这种异步文件操作。锁只保护保存流程,不要把设备通信、弹窗等待和长时间业务操作都放进去。

如果希望最后一次修改覆盖前面的保存请求,可以进一步做防抖:用户停止编辑 300ms 后再保存。但防抖不是并发锁的替代品,两者解决的是不同问题。


保存前先验证对象

原子写入只能保证"写进去的内容完整",不能保证内容正确。一个完整的 JSON 也可能把端口写成负数,把 IP 写成空字符串。

csharp 复制代码
private static void Validate(DeviceSettings settings)
{
    if (string.IsNullOrWhiteSpace(settings.PortName))
        throw new InvalidOperationException("串口号不能为空");

    if (settings.BaudRate <= 0)
        throw new InvalidOperationException("波特率必须大于零");

    if (settings.TcpPort is < 1 or > 65535)
        throw new InvalidOperationException("TCP端口超出范围");
}

校验应该发生在序列化和写文件之前。保存失败时,原正式文件应该保持不变,UI 显示校验错误,日志记录失败原因。不要先把错误对象写入文件,再期待启动时帮你修复。


启动读取:正式文件、临时文件和备份文件

读取策略要明确优先级:

  1. 正式文件存在且能反序列化,使用正式文件;
  2. 正式文件损坏,尝试 .bak 备份;
  3. 正式文件不存在,尝试默认配置;
  4. 所有来源都失败,创建内存中的安全默认值,并记录错误。
csharp 复制代码
public static async Task<DeviceSettings> LoadAsync(string path)
{
    try
    {
        if (File.Exists(path))
            return await ReadAndValidateAsync(path);
    }
    catch (JsonException ex)
    {
        LogService.Error("配置文件格式错误", ex);
    }
    catch (IOException ex)
    {
        LogService.Error("读取配置文件失败", ex);
    }

    var backupPath = path + ".bak";
    if (File.Exists(backupPath))
    {
        try
        {
            var backup = await ReadAndValidateAsync(backupPath);
            LogService.Warn("已从配置备份恢复");
            return backup;
        }
        catch (Exception ex)
        {
            LogService.Error("配置备份也无法读取", ex);
        }
    }

    LogService.Warn("未找到可用配置,使用默认值");
    return DeviceSettings.CreateDefault();
}

默认值必须是安全值。比如自动连接可以默认关闭,通信地址使用示例地址,不能让软件第一次启动就向未知设备发命令。


#mermaid-svg-8zEsl1NAWMGpJp76{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8zEsl1NAWMGpJp76 .error-icon{fill:#552222;}#mermaid-svg-8zEsl1NAWMGpJp76 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8zEsl1NAWMGpJp76 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8zEsl1NAWMGpJp76 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8zEsl1NAWMGpJp76 .marker.cross{stroke:#333333;}#mermaid-svg-8zEsl1NAWMGpJp76 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8zEsl1NAWMGpJp76 p{margin:0;}#mermaid-svg-8zEsl1NAWMGpJp76 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 .cluster-label text{fill:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 .cluster-label span{color:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 .cluster-label span p{background-color:transparent;}#mermaid-svg-8zEsl1NAWMGpJp76 .label text,#mermaid-svg-8zEsl1NAWMGpJp76 span{fill:#333;color:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 .node rect,#mermaid-svg-8zEsl1NAWMGpJp76 .node circle,#mermaid-svg-8zEsl1NAWMGpJp76 .node ellipse,#mermaid-svg-8zEsl1NAWMGpJp76 .node polygon,#mermaid-svg-8zEsl1NAWMGpJp76 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8zEsl1NAWMGpJp76 .rough-node .label text,#mermaid-svg-8zEsl1NAWMGpJp76 .node .label text,#mermaid-svg-8zEsl1NAWMGpJp76 .image-shape .label,#mermaid-svg-8zEsl1NAWMGpJp76 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8zEsl1NAWMGpJp76 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8zEsl1NAWMGpJp76 .rough-node .label,#mermaid-svg-8zEsl1NAWMGpJp76 .node .label,#mermaid-svg-8zEsl1NAWMGpJp76 .image-shape .label,#mermaid-svg-8zEsl1NAWMGpJp76 .icon-shape .label{text-align:center;}#mermaid-svg-8zEsl1NAWMGpJp76 .node.clickable{cursor:pointer;}#mermaid-svg-8zEsl1NAWMGpJp76 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8zEsl1NAWMGpJp76 .arrowheadPath{fill:#333333;}#mermaid-svg-8zEsl1NAWMGpJp76 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8zEsl1NAWMGpJp76 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8zEsl1NAWMGpJp76 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8zEsl1NAWMGpJp76 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8zEsl1NAWMGpJp76 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8zEsl1NAWMGpJp76 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8zEsl1NAWMGpJp76 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8zEsl1NAWMGpJp76 .cluster text{fill:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 .cluster span{color:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8zEsl1NAWMGpJp76 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8zEsl1NAWMGpJp76 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8zEsl1NAWMGpJp76 .icon-shape,#mermaid-svg-8zEsl1NAWMGpJp76 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8zEsl1NAWMGpJp76 .icon-shape p,#mermaid-svg-8zEsl1NAWMGpJp76 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8zEsl1NAWMGpJp76 .icon-shape rect,#mermaid-svg-8zEsl1NAWMGpJp76 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8zEsl1NAWMGpJp76 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8zEsl1NAWMGpJp76 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8zEsl1NAWMGpJp76 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
否
是
读取配置
正式文件可解析?
使用正式文件
.bak 备份可解析?
从备份恢复并记录警告
使用安全默认值
业务校验
校验通过?
中止保存,保留原文件
写 tmp 后原子替换

备份文件什么时候生成

最简单的策略是在替换前把旧文件复制为 .bak:

csharp 复制代码
if (File.Exists(path))
    File.Copy(path, path + ".bak", overwrite: true);

File.Move(tempPath, path, overwrite: true);

更严格的流程还要考虑复制失败。如果备份失败,是否继续替换?答案要由产品场景决定。对于设备关键参数,我宁愿中止保存,也不愿意在没有备份的情况下覆盖唯一副本。

实际项目可以保留一个带时间的备份目录,但不要无限增长。配置备份不是日志,保留最近 3 到 5 份通常已经足够,清理动作也要避免删除当前正式文件。


临时文件的清理

程序上次异常退出后,目录里可能留下 settings.json.tmp。下一次启动不能盲目把它当正式配置,因为它可能只写了一半。

可以做两件事:

  • 启动时尝试解析临时文件,解析成功也只把它作为恢复候选,不直接覆盖正式文件;
  • 保存成功后删除临时文件,失败时记录路径,便于诊断。
csharp 复制代码
private static void TryDeleteTemp(string tempPath)
{
    try
    {
        if (File.Exists(tempPath))
            File.Delete(tempPath);
    }
    catch (IOException ex)
    {
        LogService.Warn($"临时配置文件清理失败:{ex.Message}");
    }
}

清理失败不应该让主程序启动失败,但必须留下日志。磁盘权限和杀毒软件锁文件的问题,往往只有日志能告诉你发生过什么。


配置版本和向后兼容

配置文件会跟着软件版本变化。新增字段通常可以使用默认值,但字段重命名和结构调整就需要版本号:

json 复制代码
{
  "configVersion": 2,
  "connection": {
    "mode": "Serial",
    "portName": "COM1"
  }
}

读取后先判断版本,再做迁移:

csharp 复制代码
if (model.ConfigVersion < CurrentVersion)
{
    model = ConfigMigrator.Upgrade(model);
    await SaveAsync(model, path);
}

不要在反序列化异常时直接把用户配置覆盖成默认值。先备份原文件,记录迁移或恢复原因,再让用户知道系统采取了什么措施。配置容错的目标是恢复运行,不是悄悄丢掉现场设置。


踩坑记录

临时文件和正式文件放在不同磁盘

跨磁盘移动不再是简单替换,可能退化成复制加删除,中间风险更大。临时文件必须和正式文件位于同一目录。

用 lock 包住异步保存

编译可能通过,但设计会让异步流程难以维护。文件保存使用 SemaphoreSlim,并确保所有退出路径都释放。

JSON 能解析就认为配置可用

格式正确不代表值合法。端口、地址、范围和必填字段都必须做业务校验。

恢复默认值却不提示

程序能启动不代表问题解决了。如果配置损坏后默默使用默认值,设备可能连到错误目标。至少要写日志,并在 UI 给出明确提示。


本篇小结

风险 对策
断电留下半文件 临时文件写完后替换
多个保存同时发生 SemaphoreSlim 串行化
写入错误值 保存前业务校验
正式配置损坏 .bak 备份恢复
新旧版本不兼容 配置版本和迁移
临时文件残留 启动诊断,成功后清理

数据、配置都有了稳定的落点,接下来要面对的是现场更敏感的部分:报警。报警不是一条红色文字,它有触发、持续、恢复和确认四个不同阶段。


下期预告

第13篇:报警系统设计:生命周期与通知

下一篇把报警从一个布尔值拆成完整生命周期,并实现数据库持久化、重复抑制和 UI 事件通知。

相关推荐
2401_885885049 小时前
国际语音C#接口怎么写?.NET环境下实现国际语音验证码批量下发
开发语言·c#·.net
淡海水15 小时前
07-03-源码-.NET Core GC标记与扫描
unity·c#·.netcore·gc
波力海苔夹心脆67516 小时前
C# string 字符串方法大全:判空、查找、截取、分割、替换一篇讲透
经验分享·c#·.net·字符串·string·入门教程
芯希望1 天前
芯伯乐INA213/INA214/INA210系列双向零漂电流检测放大器技术选型解析
dc-dc·工业控制·国产替代·电源管理·xblw芯伯乐
阿松爱学习1 天前
【Unity开发】Unity长截图功能实现
unity·c#·游戏引擎
_oP_i1 天前
Windows 11 家庭中文版 25H2
c#
unicrom_深圳市由你创科技1 天前
电池化成曲线MES追溯:关键点提取与验证
c#
老王IT1 天前
C#语言基础语法介绍
开发语言·c#
人丰1 天前
一次生产卡顿复盘:BI 数据抽取引发 PostgreSQL 长事务事故
数据库·c#
淡海水1 天前
07-01-源码-.NET Core GC总览与架构
unity·架构·c#·.netcore·gc