配置系统:原子写入与容错
这是「从零搭建灌装监控系统」系列第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 显示校验错误,日志记录失败原因。不要先把错误对象写入文件,再期待启动时帮你修复。
启动读取:正式文件、临时文件和备份文件
读取策略要明确优先级:
- 正式文件存在且能反序列化,使用正式文件;
- 正式文件损坏,尝试
.bak备份; - 正式文件不存在,尝试默认配置;
- 所有来源都失败,创建内存中的安全默认值,并记录错误。
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 事件通知。