Python 可靠写文件:临时文件与 os.replace 的正确用法
Python 可靠写文件:临时文件与 os.replace 的正确用法
配置、索引和小型缓存经常需要整体重写。最直接的写法是 open(path, "w") 后输出新内容,但打开文件就会截断旧内容:如果序列化到一半抛出异常,或者进程在写入途中退出,磁盘上可能只剩一个空文件或半截 JSON。问题不在于写入速度,而在于旧版本被过早破坏。
更合适的思路是先把新版本写到旁边的临时文件,确认写入结束并关闭文件,再通过 os.replace 替换目标。它把“准备新版本”和“发布新版本”分开,适合按完整快照更新的小文件。下面只使用 Python 标准库,先给出实现,再用可重复的小测试说明它真正验证了什么。
一、最小实现:先准备,再替换
import json
import os
import tempfile
from pathlib import Path
def atomic_write_json(path, data):
target = Path(path)
fd, temp_name = tempfile.mkstemp(
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
json.dump(data, stream, ensure_ascii=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_path, target)
finally:
temp_path.unlink(missing_ok=True)
调用者需要保证父目录已经存在,并且有创建文件与替换目标的权限。目标原先不存在时,这段代码也可以发布第一版。这里选择显式要求目录存在,而不是悄悄创建一串目录,便于调用方发现路径配置错误。
mkstemp 返回已经打开的文件描述符和唯一文件名,不需要先随机拼名字、再检查是否存在。os.fdopen 接管描述符,并在离开 with 时关闭。替换安排在 with 之后,这对 Windows 尤其重要:临时文件仍被当前进程打开时,可能无法按预期完成替换。
JSON 序列化失败、写盘失败等异常会继续向上传递;finally 尝试清理临时文件。由于替换尚未发生,旧目标仍保留原内容。不要捕获异常后假装更新成功,也不要在失败后退回“直接打开旧文件覆盖”的方案,那会重新引入半写入风险。
二、为什么临时文件要放在目标旁边
代码明确使用 dir=target.parent,不是多余的路径设置。系统默认临时目录可能在另一块磁盘或挂载点上,而原子重命名依赖同一文件系统。跨文件系统的替换可能报错,不能靠“文件名看上去在同一台机器上”判断。
同目录是简单可靠的常见做法,也让目录权限要求更清楚。如果替换失败,应保留异常并检查权限、磁盘状态及目标是否被其他程序占用;不要先删除目标再移动临时文件。先删后移会产生目标不存在的窗口,且第二步失败时连旧版本也丢了。
在支持相应语义的本地文件系统上,成功的替换让随后通过目标路径打开文件的读者看到旧版本或新版本,而不是写入过程中的中间内容。已经打开旧文件的读取者、网络文件系统和特殊挂载的行为仍需结合具体环境理解,不能把这个结论扩展成所有存储系统的保证。
三、三个可以直接运行的小测试
将下面代码接在函数定义后即可运行。测试目录会自动清理,不会覆盖真实配置。
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / "config.json"
target.write_text('{"version": 1}', encoding="utf-8")
atomic_write_json(target, {"version": 2, "enabled": True})
print("round_trip:", json.loads(target.read_text(encoding="utf-8")) == {
"version": 2, "enabled": True
})
before = target.read_bytes()
try:
atomic_write_json(target, {"unsupported": {1, 2}})
except TypeError:
pass
print("failure_keeps_old:", target.read_bytes() == before)
print("temporary_files_cleaned:", list(Path(directory).iterdir()) == [target])
在本地 Windows、Python 3.12 环境运行以上逻辑,输出为:
round_trip: True
failure_keeps_old: True
temporary_files_cleaned: True
第二项故意传入不能被默认 JSON 编码器序列化的集合,验证失败发生时旧目标的字节内容没有变化。第三项检查本轮异常路径没有遗留临时文件。这些结果只证明正常更新与所模拟的序列化异常路径,不是断电、进程强杀、磁盘故障或并发写入测试。
四、原子替换不等于万事可靠
首先,原子可见性不等于断电持久化。flush 把 Python 的缓冲交给操作系统,fsync 请求同步文件数据,但替换还涉及目录项更新。对掉电后仍必须存在的新版本,部分 POSIX 环境还需要在替换后同步父目录;具体顺序及保证取决于操作系统、文件系统和存储设备。本文的跨平台最小实现不宣称实现了完整的崩溃持久化协议。
其次,它不是并发事务。两个进程都读取版本一、各自计算并替换,仍可能发生后写者覆盖先写者的更新。原子替换只保护单次发布,不保护“读取、修改、写回”这一整段业务。需要计数器、余额或多文件一致性时,应考虑受控的单写者、锁,或者直接使用数据库事务,而不是继续给这个小函数堆功能。
比较合适的落点是由单个后台任务生成一份完整配置,再供其他进程只读加载;不合适的落点则是多个请求频繁改写同一份共享状态。前者关心读者不要遇到半份配置,后者还关心更新之间的先后和冲突,两种需求不应混为一谈。
还要注意,替换发布的是一个新文件,旧文件的权限、所有者、扩展属性等不会因为路径相同就自动完整继承。如果程序依赖这些元数据,应制定明确的保留策略。进程被强杀时 finally 也不一定执行,因而可能留下临时文件;可以在维护任务中清理,但必须避免删除其他仍在写入的进程所使用的文件。
小结
对于整体更新的小型 JSON 配置,“同目录临时文件、写完并关闭、一次替换”比直接截断旧文件更合适。它清楚地划出了新版本的发布点,也便于测试异常路径。真正重要的是知道边界:它减少半写入暴露,不自动解决断电持久化、元数据继承和并发更新。按需求选择机制,往往比给一个工具函数许下过多保证更可靠。
- 点赞
- 收藏
- 关注作者
评论(0)