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

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

这是「从零搭建灌装监控系统」系列第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 事件通知。

相关推荐
流形填表4 小时前
题库去重实战:基于题干指纹的重复题清理
开发语言·c#
开开心心就好10 小时前
以图搜图找重复图片,本地工具离线就能用
javascript·智能手机·ffmpeg·c#·ocr·word·音视频
QQ_216962909611 小时前
基于C#(Asp.net)电竞陪玩信息管理系统的设计与实现
java·大数据·spring boot·微信小程序·c#·云计算·asp.net
未来之窗软件服务20 小时前
C# 截图源码支持自动保存-东方仙盟
c#·仙盟创梦ide·东方仙盟·电脑工具
UIU1141 天前
-7 / 2 在 C 里是 -3,在 Python 里是 -4,谁算错了?
c++·学习·c#
wflynn1 天前
GitHub 今日推荐|REDox:64 位 token 表示结构化数据,内存占用降 70% 支持多格式互转
开源·c#·github
驰骋工作流1 天前
C#/.NET 开源BPM工作流引擎6大常用对比选型分析驰骋BPM驰骋低代码低代码工作流引擎
开源·c#·.net
en.en..1 天前
C语言 fp与fd 区别详解
c#
淡海水2 天前
02-02-原理篇-分代GC
算法·unity·c#·游戏引擎·.net·gc