Python 写 JSON 写到一半崩了?用 os.replace 保住上一版配置(附故障注入)

脚本写配置时最隐蔽的事故,不是报错,而是第二天重新启动后才发现设置文件只剩一半。保存按钮返回成功之前,程序可能已经打开目标文件并将它清空;随后遇到异常、进程退出或磁盘空间不足,旧内容就没有了。日志告诉你保存失败,现场却已经失去了上一次还能正常使用的配置。对每天跑定时任务、保存采集进度或者维护工具参数的开发者,这种故障往往比直接抛出异常更难恢复。

本文解决一个明确的问题:使用 Python 标准库写入单个 JSON 配置文件时,怎样把"先破坏旧文件再写新文件",改成"先完整准备新文件,最后才切换"。我们会在临时目录复现半截 JSON,用可直接运行的函数完成写入,再模拟切换失败,验证旧文件仍然存在。整个示例不依赖第三方包,也不会碰到电脑上的真实配置文件。需要说明的是,这是一种面向单文件替换的完整性策略,不是跨文件事务,更不能代替备份和数据库。

一、最常见的写法,为什么会留下半个 JSON

很多 Python 程序保存配置时只有三行:打开路径、调用序列化、关闭句柄。问题出在打开方式。当文件以写入模式打开时,已有文件会被截断。即使后续序列化抛出异常,已经发生的截断也不会自动撤回。程序员看到异常堆栈可能以为"没有保存成功",实际上旧文件已经被修改。

下面这段代码只在系统自动生成的临时目录里演示破坏过程,绝不会覆盖你的真实配置。先写入一份可读取的初始设置,再以写入模式打开同一个文件,只写入一个未完成的对象,最后模拟运行中断。之后再用 JSON 解码,就能看到错误。故障不是 JSON 格式本身造成的,而是目标文件承担了尚未完成的写入工作。

python 复制代码
import json
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as folder:
    target = Path(folder) / "settings.json"
    target.write_text(
        json.dumps({"version": 1, "enabled": True}),
        encoding="utf-8",
    )
    with target.open("w", encoding="utf-8") as file:
        file.write('{"version": 2, "enabled":')
        file.flush()
    try:
        json.loads(target.read_text(encoding="utf-8"))
    except json.JSONDecodeError as error:
        print(type(error).__name__, "旧配置已不完整")

这个实验并未要求你真的强制结束进程。我们故意写入一半,就能把结果确定地重现出来。真正生产环境的诱因可能是中途异常,也可能是文件系统写入失败。重要的不是猜是哪一种故障,而是修改保存流程,避免尚未验证的新内容直接替换稳定的旧内容。

二、把保存动作拆成准备和切换两个阶段

可靠的思路是先在同一目录建立一个名字唯一的临时文件。新数据全部写入临时文件之后,主动刷新 Python 缓冲区,并请求操作系统把文件数据同步下去;随后关闭句柄,再执行最后一步替换。只要在替换之前抛出错误,原来的目标文件就没有被这一轮操作截断。临时文件可以清理,下次仍能读取上一版配置。

为什么要选择目标文件的同一目录?因为替换源和目标位于不同文件系统时,移动或替换可能失败。为什么不直接用系统默认临时目录?因为系统临时目录不一定与工作目录在同一个卷。为什么要先关闭临时文件?尤其在 Windows 下,仍然打开着的文件可能影响重命名或删除操作。这里用命名临时文件并明确关闭,避免在文件打开状态下尝试替换。

下面是可直接放进项目的保存函数。它使用标准库 pathlib、tempfile、os、json,编码明确写成 UTF-8,临时文件名称含随机部分,允许同一路径的连续保存使用不同临时名称。在任何异常时,它只尝试删除本函数创建的那个临时文件,不会删除目标目录里的其他文件。调用方仍然需要处理保存失败的异常。

python 复制代码
import json
import os
import tempfile
from pathlib import Path

def save_json_atomically(destination, data):
    target = Path(destination)
    target.parent.mkdir(parents=True, exist_ok=True)
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=target.parent,
            prefix=f".{target.name}.",
            suffix=".tmp",
            delete=False,
        ) as file:
            temporary = Path(file.name)
            json.dump(data, file, ensure_ascii=False, indent=2)
            file.write("\n")
            file.flush()
            os.fsync(file.fileno())
        os.replace(temporary, target)
    except BaseException:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
        raise

这里没有把每一种错误都吞掉。比如 JSON 数据里包含无法被序列化的对象,编码阶段可能失败;比如没有写入权限,创建临时文件会失败;比如目标文件被另一个进程以不兼容的共享方式打开,最终替换可能失败。函数会把错误交给上层处理,这比打印一句"保存好了"却保留损坏数据更容易排查。若异常发生在 os.replace 之后的其他业务步骤,保存文件本身可能已经完成,不能仅凭随后出现的异常判断保存一定失败。

三、用故障注入证明旧文件没有被覆盖

仅仅运行一次正常保存,不能证明异常情况下的行为。我们至少要验证三件事情:初始版本能读取;故意让最后一步替换失败时,原有字节不变;撤销模拟故障后,再次保存得到最新版本。这样才能把"看起来设计合理"变成可以自动重复的证据。

下面的测试把真实文件全部放在 TemporaryDirectory 中。mock.patch 会临时让 os.replace 抛出 OSError,模拟最后一步不可用,并不是修改电脑的文件系统。需要注意,这个测试只验证我们的函数在替换失败时不主动破坏旧文件,不代表真实断电、所有网络盘或所有 Windows 文件系统情形都已被验证。

python 复制代码
import json
import tempfile
from pathlib import Path
from unittest.mock import patch

with tempfile.TemporaryDirectory() as folder:
    path = Path(folder) / "settings.json"
    save_json_atomically(path, {"version": 1, "name": "第一版"})
    previous_bytes = path.read_bytes()
    assert json.loads(previous_bytes)["version"] == 1

    try:
        with patch("os.replace", side_effect=OSError("mock failure")):
            save_json_atomically(path, {"version": 2})
    except OSError:
        pass

    assert path.read_bytes() == previous_bytes
    assert not list(Path(folder).glob(".settings.json.*.tmp"))

    save_json_atomically(path, {"version": 3, "name": "最新版"})
    assert json.loads(path.read_text(encoding="utf-8"))["version"] == 3
    print("三项检查通过")

运行测试时,必须把上一节的保存函数放在同一个 Python 文件中,或从本地模块导入。测试使用的临时目录会在退出作用域时由标准库清理,不会指向你的项目配置。如果测试在第二个断言失败,不要直接发布上线;先确认是否有人把安全函数改回了直接打开目标文件的写入模式。如果第三步失败,则需要查看实际异常,而不是临时绕过替换逻辑。

四、Windows 11 上还有哪些容易忽略的边界

第一,os.replace 与 os.rename 不能在所有场景中互相替代。在 Windows 上,rename 对已有目标的处理并不等同于 replace。我们的目标是保存新版配置时替换已有文件,因此选择明确表达替换意图的接口。Python 官方文档说明 replace 可以替换有权限操作的目标文件,但不同文件系统或操作系统条件仍可能导致失败。代码不应承诺"任何磁盘都一定成功"。

第二,flush 和 fsync 分别解决不同层次的问题。前者推动语言运行时缓冲区写出,后者请求操作系统同步文件内容;它们不能把一个由多个文件组成的业务流程变成事务。极端断电条件下,目录项的持久性还涉及文件系统和平台支持,不能把这里的单文件替换宣称为绝对抗断电。对于账务、库存或交易这类更严格的业务,应使用适合事务的数据库和备份机制。

第三,文件替换并不自动解决并发写入。两个任务如果同时读取版本一,各自修改不同字段,随后依次替换目标文件,后写入者仍可能覆盖先写入者的新字段。因为每次替换都是独立操作,无法单凭这段函数完成读改写的并发协调。应由调用层使用单写者机制、版本号检查或数据库事务,而不是把"原子替换"误解成"并发安全"。

第四,目标程序长时间持有打开的文件句柄时,Windows 可能因共享模式而拒绝替换。此时正确行为是保留旧文件并报告具体 OSError,不能为了让写入成功就强制结束不相关进程或改变系统权限。必要时应明确由哪个服务负责写入,由那个服务自行协调读取和保存时间,避免多个程序争夺同一份配置。

五、不同保存方式的代价要怎样选择

实际选型取决于数据性质。几十 KB 的用户配置,一次完整写入后替换通常足够清晰;每秒变更多次的日志或队列状态,不适合反复写整个 JSON 文件。若项目数据存在多表关联、并发更新或跨记录一致性要求,最初就使用数据库往往更省事,不要试图靠复杂的临时文件协议拼出一个小型数据库。

方式 出错时旧文件 多写者协调 适合场景
直接打开 JSON 写入 可能已被截断 无 可丢弃的临时结果
同目录临时文件加替换 替换前通常保持原状 无 单写者配置和小状态文件
SQLite 事务 按事务规则管理提交 由数据库机制管理 多记录和并发业务状态

这张表并不是说每个脚本都必须迁移数据库。一个每天运行一次的工具、一次只由一个进程保存的设置文件,使用同目录临时文件能以较少代码避免一个典型故障;持续高频写入和多人协作编辑则需要更完整的状态管理。选择技术方案时,把"文件完整性""并发一致性""断电耐久性"三个问题分开,避免一个术语遮盖所有边界。

六、把它接进现有脚本时,先做最小改动

接入旧项目时,先定位负责保存 JSON 的一个函数,不必一次重构整个目录。把原来直接打开目标文件的写入操作替换为上述单一入口,读取侧继续用标准库 json.load 即可。第一次上线前,把旧文件单独备份并验证可解析;先在测试目录运行失败注入,再对业务使用的单个目标进行验证。这样修改范围小,也能明确新故障来自哪一步。

对定时采集脚本而言,保存的通常是最后处理的文章编号、分页游标或时间戳。保存函数应只负责一个文件的完整提交,不要顺带承担网络重试、分页请求或自动恢复旧会话等职责。假设网络请求已经成功而保存进度失败,下次运行仍可能重新处理同一条数据;文件的原子写入不会替代业务层的幂等检查。相反,把多个责任拆开后,业务代码可以根据错误选择重试、报警或停止,而不会在不知道文件状态时盲目继续。

错误日志至少应包含目标路径、异常类型、是否发生替换尝试和业务记录标识,但不要把敏感配置值整体打印进日志。遇到一次保存异常,优先只读打开目标文件、验证 JSON 完整性,再根据具体错误决定是否恢复。不要因为"程序返回错误"就无条件重复执行外部发布、扣款或发送通知。这和文件保存一样,真正关键的是区分已经发生的副作用与尚未完成的动作。

七、最后给自己留一份可复验的保存合同

可以把保存流程压缩成四句:先生成完整新内容;只写同目录临时文件;关闭句柄后替换;失败时保留旧文件并上报异常。再补上两个明确边界:多进程写同一文件需要额外协调,重要数据仍需要备份。比起声称"永不丢数据",能写出并反复运行故障测试,才是更可靠的工程承诺。

Python 官方标准库文档可以直接核查这几个关键接口:os.replace 的覆盖与失败条件、tempfile.NamedTemporaryFile 的 Windows 打开及删除语义、os.fsync 的同步行为。参考资料:docs.python.org/3/library/o... 和 docs.python.org/3/library/t... 。

如果你的定时任务曾遇到配置文件偶尔变空、JSON 解码失败或者重启后进度倒退,可以先确认当前保存代码是不是直接打开了原文件。不要首先猜测模型、框架或者磁盘坏了,先用一个临时目录把失败做成可重复的测试。一个能明确知道何时替换、何时保留旧值的保存函数,通常比一层又一层没有证据的重试更有价值。

相关推荐
大白801 小时前
CSS 盒模型精讲:标准盒模型与 IE 怪异盒模型区别
后端
十年Java程序媛1 小时前
String / StringBuilder / StringBuffer|别再乱用字符串拼接,底层原理一次讲透
java·spring boot·后端
大勇前进1 小时前
CSS 选择器优先级详解,一次搞懂权重计算规则
后端
姜鱼问生2 小时前
fail2ban 实战:从默认配置到生产可用
后端·算法·架构
JavaGuide2 小时前
DeepSeek Harness 重磅更新,Codex、Claude Code 子智能体进官方插件列表了,还有 Git 工作树和思考翻译!
前端·后端
Rain的Java大神之路3 小时前
[特殊字符]8年Java老兵转型AI Agent:90%的人挂在同一个坑,根本不用学Python!(万字实战复盘,建议收藏)
java·数据库·人工智能·后端·python·spring·ai编程
高频因子挖掘机3 小时前
A股分钟K线数据占内存:先缩小请求范围,还是换 DataFrame 工具?
后端·github·api
高级程序源3 小时前
springboot大学生体测系统72170-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·django·课程设计
打工仔折腾 AI3 小时前
极空间用 Docker 部署 Photopea:网页版 PS 的私有化实践
运维·后端·python·docker·容器·langchain·ai agent 实战