Python 脚本写配置被中断就损坏:一次原子写入的排查与改造
Python 脚本写配置被中断就损坏:一次原子写入的排查与改造
一、问题
一套定时脚本负责把采集结果写进一个 JSON 状态文件,另一条定时任务在几分钟后读取它做汇总。过去一个月里出现过三次"配置读不出来":文件大小正常,内容却只有半截,json.load 直接抛 JSONDecodeError。最典型的一次发生在机器内存紧张的时候,进程被 OOM killer 干掉,恰好卡在写文件那一刻,文件就停在了不完整的状态。
二、原因
最常见的写法是 open(path, 'w') 然后 json.dump。问题在于 'w' 模式一打开就把文件截断成 0 字节,而写入是分多次系统调用完成的:先写一部分,再写剩下的。中间任何一步中断——进程被杀、磁盘写满、机器掉电、异常栈一路退出去没走到正常的 close——文件都会停在"写了一半"。此时读端拿到的是坏数据,而原来的内容已经被截断,没有任何回滚的余地。
还有第二个隐患是并发:两个进程同时写同一个路径,后写的可能覆盖前者,也可能和前者交叉写入,得到一个两边都不是的混合体。
三、做法
思路一句话:永远不要就地改写正式文件,而是在同目录写一个临时文件,写完落盘后再 rename 覆盖。POSIX 的 rename 在同一文件系统内是原子操作,读端要么看到完整的旧文件,要么看到完整的新文件,不存在中间态。
def save_json_atomic(path, data):
d = os.path.dirname(os.path.abspath(path))
fd, tmp = tempfile.mkstemp(dir=d, prefix=".tmp-", suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
f.flush()
os.fsync(f.fileno()) # 让数据真正落盘,而不是停在页缓存
os.replace(tmp, path) # 原子替换
except BaseException:
os.unlink(tmp) # 失败不留垃圾文件
raise
几个容易漏的细节:
1. 临时文件必须和目标文件同目录。跨文件系统的 rename 会退化成"复制+删除",就不再是原子操作。
2. fsync 不能省。只 flush 只是把 Python 的缓冲区刷进内核页缓存,进程崩溃没问题,整机掉电仍然可能丢数据。
3. 权限要单独处理。mkstemp 建出来的文件默认是 0600,如果原文件是 0644,替换之后权限就悄悄变了,可能让另一个以不同用户运行的读端读不到。稳妥做法是替换前把临时文件 chmod 成原文件的权限。
4. 读端也要配合。读的时候用 try/except 兜住 JSONDecodeError,但不要一解析失败就写回一份空的默认值——那会把仅有的一份好数据也清掉。正确做法是报错退出,保留现场。
5. 并发场景再加一把文件锁(fcntl.flock 或一个 lock 文件),保证同一时刻只有一个进程在"读-改-写"。
改完之后还补了一条校验:替换之前先试着把临时文件解析一遍,解析不过就不替换,直接告警。这条在磁盘写满的场景里真的救过一次——那次 fsync 报 ENOSPC,旧文件原样保留。
四、小结
- "写文件"在工程上不是一个动作,而是一串动作,每一步都可能失败,所以需要给它一个明确的成功点。
- 原子写入的通用套路很短:同目录临时文件 → 写完 fsync → os.replace,失败就删临时文件。
- 判据也很简单:如果这个文件在读端看来坏了会造成故障,就值得用这套写法,代价不过十几行代码。
- 配套的写后校验和读端容错,比追求"永远不出错"要实际得多。
- 点赞
- 收藏
- 关注作者
评论(0)