Python 小记:写 JSON 文件,先别急着覆盖旧内容

老K 15 0
摘要:

直接用 w 模式写 JSON,序列化失败也可能先弄坏旧文件。我用同目录临时文件和 os.replace 做了一次保存实验,顺便记下文件锁、临时文件清理与原子替换的边界。

[aru_8]前几天写了给定时脚本加文件锁,解决同一份任务重复启动的问题。这次我接着看另一个小地方:脚本要保存文章 ID、执行时间这些状态,直接打开 JSON 文件覆盖,真的够用吗?

平时只要一两行就能写完,容易顺手就用了。我拿临时目录做了几个实验,发现不需要等到断电:数据里混进一个不能序列化的对象,旧文件就可能先被清空,最后留下一半 JSON。

所以这篇记一个适合小配置、状态文件的办法:先把新内容准备好,写到同目录的临时文件,最后再替换目标。

Python 小记:写 JSON 文件,先别急着覆盖旧内容
先准备新内容,再替换目标文件(AI 示意图)。

先复现:报错发生时,旧内容已经没了

下面是我这次用来演示问题的写法。请在空目录里试,state.json 是演示文件:

import json
from pathlib import Path

path = Path("state.json")
path.write_text('{"old": true}\n', encoding="utf-8")

with path.open("w", encoding="utf-8") as stream:
    json.dump({"bad": object()}, stream)

最后一行会抛 TypeError,因为普通 object() 不能直接转成 JSON。但重点在前一行:以 w 模式打开文件时,原有内容就被截断了。

我在本地跑完后,文件里实际只剩下:

{"bad": 

这既不是原来的配置,也不是一份完整的新配置。即便用 with 关闭了文件,已经丢掉的内容也不会自动恢复。

我改成先写临时文件,再替换

完整示例放下面,保存为 atomic_json.py。只用标准库,本轮实际运行环境是 macOS、Python 3.9.6;没有在 Windows 或网络共享盘上验证。

import json
import os
from pathlib import Path
import tempfile


def write_json_atomic(path, value):
    target = Path(path).expanduser().absolute()
    # Serialize first: invalid data must not touch the existing file.
    text = json.dumps(
        value, ensure_ascii=False, indent=2, allow_nan=False
    ) + "\n"
    target.parent.mkdir(parents=True, exist_ok=True)
    temp_path = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w", encoding="utf-8", dir=target.parent,
            prefix="." + target.name + ".", suffix=".tmp",
            delete=False,
        ) as stream:
            temp_path = Path(stream.name)
            stream.write(text)
            stream.flush()
            os.fsync(stream.fileno())
        # Same directory, and the temporary file is already closed.
        os.replace(temp_path, target)
    finally:
        if temp_path is not None:
            temp_path.unlink(missing_ok=True)


if __name__ == "__main__":
    write_json_atomic("demo-state.json", {
        "last_post_id": 667,
        "note": "只是本地演示,不会连接 WordPress",
    })
    print(Path("demo-state.json").read_text(encoding="utf-8"))

在一个单独的演示目录里运行:

python3 atomic_json.py
python3 -m json.tool demo-state.json

第一条创建或替换 demo-state.json,第二条读取并格式化它。示例里的文章 ID 只是数字,不会请求博客接口。函数适合保存能放进内存的小份 JSON,不是大文件流式写入方案。

这几步各自管什么

我先调用 json.dumps(),等数据完整变成文本,再创建临时文件。这样遇到不能序列化的对象,错误会在接触目标文件之前出现。allow_nan=False 会同时拒绝 NaN、正负无穷值,避免写出别的工具未必接受的 JSON。

临时文件用随机名字,放在 target.parent,也就是目标文件所在目录。这样源和目标通常处在同一文件系统;Python 文档明确提醒,跨文件系统的替换可能失败,所以我没有直接把临时文件丢进系统默认临时目录。

写完后,先 flush(),再对临时文件调用 os.fsync(),然后退出 with,关闭它。最后的 os.replace() 才切换目标路径。

在这里讨论的本地 POSIX 文件系统场景下,替换成功是一个原子操作。读者重新按目标路径打开时,应该读到完整旧版或完整新版,而不是我们正在一点点写入的临时内容。已经打开旧文件的读者可能继续读旧版,这也不奇怪。

不过,原子替换和断电后一定保住最新版本,是两回事。这段代码同步了临时文件,没有另外同步父目录,也没有做断电实验;我不会把它称为断电不丢数据的保证。替换成功后,旧版本也不会自动留下来,需要历史版本时还得另外做备份。

我这次检查了哪些情况

验证都在本机临时目录完成,没有改动真实配置或线上文件:

  • 正常写入中文和列表:目标文件能完整解析,临时文件被清理。
  • 传入不能序列化的对象:抛出错误,旧内容保持原样。
  • 传入 NaN:拒绝写入,旧内容保持原样。
  • 人为让替换调用失败:错误继续抛出,旧文件保留,临时文件被清理。
  • 让子进程在执行替换前直接退出:目标仍是旧版,但临时文件会残留。
  • 一个线程不断读取,主线程连续替换 60 次:本轮读到的内容都能解析,最后版本正确。

这 6 项检查全部通过。并发读取这一轮共读了 670 次,观察到了 61 个版本,包含初始版本;它证明的是这一次本机实验没有读到半份 JSON,不是对所有存储环境的承诺。

子进程那项值得单独记一下:我用 os._exit() 在替换前退出,finally 没有机会执行,所以临时文件还在。进程被强制终止时,也不能指望 Python 替我收尾。正式使用时如果要清理遗留文件,要确认没有正在写入的任务,别只看见 .tmp 就全部删掉。

文件锁还是得留着

原子替换能减少“文件被写坏”的问题,但不会自动合并多个写入者的结果。比如 A、B 都读到计数 10,各自加 1,再分别保存,最终可能仍然是 11。两份 JSON 都完整,业务更新却丢了一次。

如果需要“读取—修改—保存”作为一个整体执行,我会继续用之前那篇的文件锁把整个过程包起来。锁用独立、固定的 run.lock,不要直接锁这个即将被替换的 JSON 文件,因为替换后路径指向的文件对象已经变了。

另外,临时文件的权限通常比较收紧,替换后不会自动继承旧文件的权限和其他元数据。这个示例用于同一用户自己管理的小状态文件;如果配置要交给其他账号读取,或者目标是符号链接,就需要按实际使用方式另做处理。

我给自己留下的用法是:单个小状态文件,先把新内容写完整,再替换;多个任务一起改同一份状态,就再把文件锁加上。步骤多了几行,出问题时却更容易知道旧数据还在不在。

这次核对的官方文档:

发表评论 取消回复
OwO 图片 链接 代码

分享