Python JSON 配置原子更新:先写临时文件,再替换正式文件
一、为什么一个 with 语句还不够
配置、任务检查点和小型缓存经常保存在 JSON 文件中。最直观的写法是先用 open(path, "w") 打开正式文件,再调用 json.dump()。问题在于,w 模式会先截断已有内容;而序列化、编码、磁盘写入中的异常都可能发生在这之后。
例如字典前几个字段可以正常序列化,后面却包含一个 object()。编码器抛出 TypeError 时,正式文件可能已经只剩下一段不完整 JSON。with 会负责关闭文件,但不会把原先的字节还原。把异常捕获后直接忽略,也不能修复已经受损的文件。
比较适合小型、完整替换式文件的流程是:先在目标文件的目录里创建唯一临时文件;在临时文件里完成全部写入;关闭临时文件;最后调用 os.replace() 替换正式路径。单个写者操作可信目录时,提交前的普通失败不会触碰旧文件。
二、一个只用标准库的实现
下面的函数要求父目录已经存在,适用于普通本地文件。传入的相对路径会先变为绝对路径,避免后续操作再次依赖当前工作目录。示例没有自动创建目录,也不会为了重试而删除正式文件。
import json
import os
from contextlib import suppress
from pathlib import Path
from tempfile import NamedTemporaryFile
def write_json_atomic(path, data, *, sync_file=False):
target = Path(path).absolute()
temporary = None
try:
with NamedTemporaryFile(
mode="w", encoding="utf-8", newline="\n",
dir=target.parent, prefix=".json-", suffix=".tmp",
delete=False,
) as stream:
temporary = Path(stream.name)
json.dump(data, stream, ensure_ascii=False,
indent=2, allow_nan=False)
stream.write("\n")
stream.flush()
if sync_file:
os.fsync(stream.fileno())
os.replace(temporary, target)
temporary = None
finally:
if temporary is not None:
with suppress(OSError):
temporary.unlink()
dir=target.parent 很关键。如果临时文件放在系统默认临时目录,而正式文件位于另一个文件系统,替换操作可能失败。同目录创建可以让两者处于同一文件系统,也省去猜测临时目录所在卷的过程。
delete=False 让临时文件在关闭后仍有名字可供替换;os.replace() 放在 with 外面,确保提交前写入句柄已关闭。这也避免依赖“尚未关闭的临时文件能否被再次打开或重命名”的平台细节。Windows 上其他程序占用目标等情况仍可能造成替换失败,函数会把异常交给调用者。
ensure_ascii=False 配合显式 UTF-8 编码,保留可读的中文。allow_nan=False 则拒绝 NaN 和正负无穷值,避免生成其他 JSON 实现可能不接受的数据。它们解决的是输出格式问题,不能替代业务字段校验。业务数据有范围或必填要求时,应先校验,再提交文件。
finally 尝试清理尚未提交的临时文件。示例忽略清理本身的 OSError,以免掩盖真正的写入异常;实际项目可以记录清理失败及临时路径。进程被强制终止时,finally 未必执行,因此也不能承诺目录里永远没有遗留临时文件。
三、用失败用例验证旧文件
将函数与下面的代码放在同一个脚本中运行。临时目录由示例独占,不会覆盖工作目录里的配置文件。测试时应使用独立临时目录。
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
path = Path(directory) / "settings.json"
write_json_atomic(path, {"version": 1, "城市": "杭州"})
before = path.read_bytes()
try:
write_json_atomic(path, {"version": 2, "bad": object()})
except TypeError:
pass
else:
raise AssertionError("应当拒绝不能序列化的对象")
assert path.read_bytes() == before
assert not list(Path(directory).glob(".json-*.tmp"))
write_json_atomic(path, {"version": 2}, sync_file=True)
assert json.loads(path.read_text(encoding="utf-8")) == {
"version": 2
}
print("旧文件保护与正常替换验证通过")
本文示例已在 macOS、Python 3.9.6 本地运行。另行检查了拒绝 NaN、目标为已有目录两条失败路径:旧内容或原目录保持不变,临时文件均已清理。这些结果验证的是所列正常异常路径,没有做断电、磁盘故障或网络文件系统测试。
理解故障发生在哪一阶段,能帮助调用方决定下一步:
| 失败阶段 | 本例中应观察的状态 | 建议处理 |
|---|---|---|
| 创建临时文件失败 | 尚未修改正式路径 | 检查父目录、权限、空间 |
| 序列化或写入失败 | 旧文件仍在,临时文件尝试清理 | 修正数据或存储故障后再提交 |
| 替换操作报错 | 将错误交给上层,不先删旧文件 | 查明占用、权限或目标类型 |
| 成功返回 | 正式路径已切换到新文件 | 继续业务流程,按需记录版本 |
对普通异常,调用方应保留异常类型和路径上下文,而不要返回一个看似成功的状态。例如 TypeError 通常指向不支持的数据类型,ValueError 可能来自禁止的浮点数,而文件系统错误属于 OSError 系列。错误应进入日志或结果对象,避免后台任务重复提交同一份坏数据。
如果业务需要保留历史版本,可以另行设计备份或版本文件;这里的旧内容保护只覆盖新版本提交以前。一旦替换成功,函数不会为原来的文件自动保留一份可恢复副本。发布前还应确定读者何时重新加载配置:每次打开路径、显式通知,还是定时检查版本。把这些规则写清楚,比仅增加一个写文件工具函数更利于排查问题。
四、提交成功以后,还要分清三个边界
原子可见性。 Python 文档说明,os.replace() 成功时重命名是原子操作,其中原子性是 POSIX 的要求。依赖这一保证时,读者应重新打开正式路径,读取替换前或替换后的完整版本。已经打开的句柄不能当作自动刷新机制;网络文件系统、同步盘或特殊挂载应另行验证实际语义。
断电持久性。 flush() 处理 Python 缓冲;启用 sync_file 后,还会在提交前调用文件的 fsync()。这不等于完成了一套跨平台的断电恢复协议:函数没有同步替换后的父目录元数据,存储设备也有自己的持久化行为。因此参数命名为 sync_file,不能把它解读成“绝不会丢数据”。严格恢复要求需要针对文件系统设计和测试,必要时使用数据库事务。
并发与元数据。 多个写者各自生成完整文件,仍可能互相覆盖,最后一次替换决定最终内容。“读取计数—加一—写回”也不是事务,更新可能丢失,应在整个读改写范围使用合适的进程间协调或数据库。替换可能改变目标的权限等元数据,本函数不会主动保留旧文件的所有者、访问控制或扩展属性,也不适用于攻击者能够改动的目录和符号链接场景。
对于单写者、可信本地目录中的小型配置,这个模式的价值很具体:把正式路径的更新推迟到完整内容准备好以后,让序列化失败和多数提交前异常有清晰的恢复边界。
参考:Python 标准库文档中 open()、tempfile.NamedTemporaryFile()、os.replace()、os.fsync() 与 json.dump() 对应章节。
- 点赞
- 收藏
- 关注作者
评论(0)