[aru_8]前几天写了给定时脚本加文件锁,解决同一份任务重复启动的问题。这次我接着看另一个小地方:脚本要保存文章 ID、执行时间这些状态,直接打开 JSON 文件覆盖,真的够用吗?
平时只要一两行就能写完,容易顺手就用了。我拿临时目录做了几个实验,发现不需要等到断电:数据里混进一个不能序列化的对象,旧文件就可能先被清空,最后留下一半 JSON。
所以这篇记一个适合小配置、状态文件的办法:先把新内容准备好,写到同目录的临时文件,最后再替换目标。

先复现:报错发生时,旧内容已经没了
下面是我这次用来演示问题的写法。请在空目录里试,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 文件,因为替换后路径指向的文件对象已经变了。
另外,临时文件的权限通常比较收紧,替换后不会自动继承旧文件的权限和其他元数据。这个示例用于同一用户自己管理的小状态文件;如果配置要交给其他账号读取,或者目标是符号链接,就需要按实际使用方式另做处理。
我给自己留下的用法是:单个小状态文件,先把新内容写完整,再替换;多个任务一起改同一份状态,就再把文件锁加上。步骤多了几行,出问题时却更容易知道旧数据还在不在。
这次核对的官方文档:
- os.replace() 与 os.fsync():替换条件及缓冲区同步顺序。
- NamedTemporaryFile:临时文件的位置、关闭和删除行为。
- json 编码参数:序列化失败与 allow_nan。
🎨 原创不易,支持请点赞、转载请注明本文作者为老K