Python JSON 配置原子更新:先写临时文件,再替换正式文件

举报
yd_254857454 发表于 2026/10/30 06:06:11 2026/10/30
【摘要】 直接覆盖 JSON 文件时,序列化或写入中途失败可能破坏旧内容。本文用 Python 标准库实现“同目录临时文件—完整写入—原子替换”,给出可运行的失败验证,并说明并发、权限和断电恢复的边界。

一、为什么一个 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() 对应章节。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。