原子替换可以避免读者看到一份写到一半的配置,却不能阻止旧草稿完整地覆盖新配置。 解决丢失更新,需要把读取最新值、比较基线、合并修改与写入作为一个受协调的过程。配置保存成功后,运行中的任务是否采用新值,则是另一项需要明确设计的行为。

桌面程序里,这些问题经常同时出现:两个窗口都打开设置,一个修改代理规则,另一个更新账号资料;浏览器任务又在后台使用旧配置。只给 JSON 写入加一个临时文件,无法同时解决这些状态之间的关系。

本文用个人项目的路由设置解释设计取舍,源码阅读基线为 56e25be7f9792a4245f343408bbb061df59bbf7e。可运行代码是独立原创教学示例,不读取项目真实配置,也不是完整的跨进程配置数据库。

案例对应开发源码中的特定保存路径,不代表正式安装包已经交付全部能力,也不代表应用所有配置写入都已使用同一事务约定。

配置完整、更新正确与运行生效分别检查

问题 典型故障 对应的处理方向
文件完整性 程序退出时文件只写了一半 同目录临时文件、刷新及替换
更新一致性 后保存的旧窗口抹掉前一次修改 局部更新、基线比较与并发协调
运行生效 磁盘值改变,旧任务仍使用原值 快照、版本与明确的重启或热更新约定

这三项可能分别验证。一份可以成功解析的 JSON 仍可能包含过时值;一次正确保存也不意味着运行中的连接已切换。

原子替换为什么仍会丢更新

假设初始配置包含路由 A 和语言中文。两个窗口各自复制一份:

1
2
3
4
5
窗口 A:读取 v1 → 把路由改成 B
窗口 B:读取 v1 → 把语言改成英文

A 保存完整配置:路由 B,语言中文
B 再保存旧副本:路由 A,语言英文

两次写入都可以使用 os.replace(),两次文件都完整,A 的更新还是丢了。原子替换负责一次文件替换,不会自动判断写入者的读取基线是否已经过时。

Python 的 os.replace 文档说明了替换语义及跨文件系统等限制。实际设计中,临时文件放在目标目录,可以避免意外跨文件系统;文件刷新、目录项持久化和断电恢复仍受系统及文件系统影响,不能简单宣传为“绝不丢数据”。

两个窗口读取同一基线、先后保存及旧副本覆盖的过程

图1:原创更新过程示意;这是可复现的操作交错,不是性能或压力测试结果。

保存时重新读取,只更新自己负责的字段

局部保存的作用,是不把用户没有编辑的旧字段写回。修改路由的窗口应在提交时读取最新配置,把新的路由段合入其中,而不是重新序列化打开窗口时整份应用配置。

但仅仅“重读后局部写入”仍不够:如果另一个窗口也修改了路由,本窗口还需要知道它的草稿是不是过时。因此编辑器保存打开时的基线,提交时比较相关字段。

提交时的变化 合理处理
只有无关账号资料改变 保留最新资料,继续保存本窗口修改
路由段与打开时不同 提示冲突,要求重新核对
本窗口要改的账号绑定被别人修改 检查对应账号的旧绑定,拒绝不明覆盖
本窗口没有改动的高级选项发生变化 不用旧值覆盖它

基线不仅可能包含值,还可能包含字段是否存在和类型。缺失、显式 null、false 与整数 0 不应未经约定就视为同一种配置状态。

这是乐观并发控制的一种思路:允许用户先编辑,提交时才检查是否仍基于有效版本。HTTP 的 If-Match 条件请求提供了类似的“先验证条件再执行修改”机制,但它不意味着本地 JSON 文件天然拥有 HTTP 的并发保障。

同进程锁需要覆盖整个提交过程

如果比较完基线就释放锁,另一个线程仍可能在真正写入之前改变配置。因此锁需要覆盖重读、检查、合并与写入,而不只是最后一行 os.replace()。

threading 的 Lock可以协调同一进程中使用同一把锁的线程。它不会锁住任意外部编辑器;其他进程或绕过入口的写入者,也不会因为这里有锁就自动参与约定。

以下完整例子限定为:路由段已经存在且是字典,所有示例写入者通过同一保存入口操作,运行环境为 Python 3.8 及以上。将其保存为 config_example.py 后运行 python config_example.py,数据仅写入临时目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from copy import deepcopy
import json
import os
from pathlib import Path
import tempfile
from threading import Lock

LOCK = Lock()


class ConflictError(RuntimeError):
pass


def atomic_write(path, data):
temporary = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="\n",
dir=path.parent, delete=False,
) as stream:
temporary = Path(stream.name)
json.dump(data, stream, ensure_ascii=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)


def save_routing(path, base, replacement):
with LOCK:
latest = json.loads(path.read_text(encoding="utf-8"))
if latest["routing"] != base:
raise ConflictError("routing changed; reload before saving")
updated = deepcopy(latest)
updated["routing"] = deepcopy(replacement)
atomic_write(path, updated)
return updated


if __name__ == "__main__":
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "example.json"
initial = {"routing": {"mode": "direct"}, "language": "zh"}
atomic_write(path, initial)
base = deepcopy(initial["routing"])

# 模拟无关字段在窗口打开后被更新。
latest = deepcopy(initial)
latest["language"] = "en"
atomic_write(path, latest)
saved = save_routing(path, base, {"mode": "proxy"})
assert saved["language"] == "en"
assert saved["routing"]["mode"] == "proxy"

before = path.read_bytes()
try:
save_routing(path, base, {"mode": "other"})
except ConflictError:
pass
else:
raise AssertionError("stale draft was accepted")
assert path.read_bytes() == before

示例检查了无关字段保留、合法更新和过时草稿被拒绝。异常捕获只针对预期的冲突,其他错误继续报告。它没有实现完整输入校验、跨进程锁、崩溃恢复或存储设备故障模拟。

生产实现还应保证所有写入路径遵循同一约定。若要求多个进程共享、频繁并发写入或更复杂的事务,应该评估支持所需事务语义的存储,而不是不断增加“保存失败再读一次”的分支。SQLite 的隔离说明可以作为评估事务及连接并发行为的起点,迁移本身仍需设计。

私有案例仍有这项限制:路由编辑器通过 update_config() 在同一锁内重读、检查和保存,但 save_accounts()、save_dm_config() 等路径仍先读取整份配置,再单独调用加锁的保存函数。因此,路由编辑器保护了自己的提交过程,却不能阻止其他写入者稍后写回旧副本;不能据此宣称整个应用已经消除了丢失更新。

2026年11月7日,对提交 895a6f9 的配置模块进行了临时目录内的隔离交错验证。使用无账号的示意配置与标记字段,在旧保存路径读完后插入一次事务更新,再让旧保存路径继续执行:

1
2
3
4
save_accounts 读取:标记值 old
update_config 保存:标记值 new
save_accounts 写回先前副本
最终文件:标记值 old

验证中确认 new 曾经落盘,最终又恢复成 old。这是人工安排交错得到的结果,不是线上故障记录、随机并发压力测试或真实账号数据。私有源码不能由读者公开复核;前面的独立教学示例用于说明正确保存路径的有限行为,不能替代项目全部写入路径的验收。

运行快照使配置生效范围可预测

浏览器运行时改规则,可能出现旧连接继续走原路径,新连接突然走新路径的情况。私有案例选择在启动时生成快照,让已运行浏览器保持原版本,新浏览器读取新配置。

编辑草稿、已保存配置、新任务快照与旧任务版本的关系

图2:原创状态示意。保存和运行生效是两个步骤,热更新需要额外设计。

这里有三个状态:

  • 草稿属于编辑窗口,修改和预览不直接落盘。
  • 已保存配置属于应用状态,提交时检查并保留相关约定。
  • 运行快照属于一次任务,明确何时生成、由谁持有、何时释放。

快照必须独立于编辑中的可变对象。简单的字典浅拷贝可能继续共享内部列表,dataclass(frozen=True) 也不会自动让内部字典或列表深度不可变。dataclasses 文档描述的是属性赋值限制。可以根据数据规模和使用方式选择独立深拷贝、不可变结构或规范化序列化值。

版本标识可以帮助日志说明任务采用了哪份配置,但不应把一个哈希前缀写成认证、防篡改或权限机制。它首先是关联配置快照的线索。

设置界面应表达真正的生效约定

案例的当前界面把路由和 WebRTC 放进同一弹窗,但两者不共享作用域:规则组可由账号选择,WebRTC 选项仍是全局配置。同一个保存按钮只表示一次提交,不表示这些字段拥有相同作用对象。

“默认组”“未匹配动作”“完全没有配置时的初始值”也需要分开命名。隐藏调试选项时保留其值,不等于可以省略提示:如果隐藏值仍生效,产品说明就应让用户知道。

保存成功后,可以明确提示“新启动的浏览器生效”。已有任务要立即改变,则应提供独立、可解释的操作。刷新标签页是否会重建相关状态,需要依据实际实现判断,不能由按钮文字推断。

配置回归测试应保护哪些行为

验证目标应该围绕结果,而不只检查序列化函数被调用:

场景 应保留的行为
两个窗口修改相同路由段 后保存者收到冲突,已保存内容不被覆盖
另一个窗口只修改无关资料 新资料保留,相关字段可正常提交
配置段缺失与配置损坏 按各自约定处理,不静默混用
保存后继续使用旧任务 旧快照保持,新任务采用新值
隐藏再展开高级选项 值保留,实际生效行为有说明
写入失败 原文件与草稿按约定保留,错误可观察

本文示例仅实证其中有限的本地保存行为。私有项目源码中的检查路径、历史测试记录和正式软件可用性需要分开记录,不能把静态阅读写成完整产品验收。

参考资料

资料核对日期为2026年11月6日。示例和图解均为原创教学材料;案例的配置语义以固定提交的实现为依据。

  1. Python:os.replace,文件替换及系统边界。
  2. Python:threading Lock,线程内同步。
  3. Python:dataclasses 冻结实例,不可变性的范围。
  4. RFC 9110:If-Match,条件修改与丢失更新的相关机制。
  5. SQLite:Isolation,事务与连接并发边界。