Python 定时刷新 JSON 配置,读端偶尔报 JSONDecodeError,重启后文件甚至只剩半截。先别给解析器加无限重试:如果写端直接用 w 模式覆盖,读端可能正好撞上清空或写到一半的窗口。下面针对 Linux 本地文件系统,用同目录临时文件、原子替换与同步操作,把“完整可见”和“持久化确认”分开处理。
一、先确认是不是覆盖写造成的半截文件
配置原来有效,更新瞬间失败,稍后再读又正常,这种现象值得先查写入方式。open(path, 'w') 会截断已有内容;随后分批写入时,其他进程并不会自动排队等它写完。文件小,也不意味着一次更新天然是事务。
排查时记录错误偏移、文件大小和更新时间,不要把完整配置打印到公共日志。JSONDecodeError 也可能来自手工语法错误、错误编码或上游返回的 HTML,所以应先确认读写双方操作的是同一路径、同一版本,而不是见到错误就认定并发。
二、原子替换只负责“换入口”
正确思路是先把新内容写到另一份文件,完成后再用 os.replace 替换正式路径。Linux rename 的原子替换语义让按该路径重新打开的读者看到旧文件或新文件,而不是被写到一半的新内容。前提是所有写入者都遵守这套方式,已发布的文件不再被原地修改。
这里的“旧版或新版”指已有配置在替换期间的并发打开。替换成功后再打开,在没有后续改动时应读到新版;首次创建配置则不同,替换前目标可能根本不存在。初始化阶段应由启动流程处理,不要把文件不存在也当成解析失败反复重试。
临时文件放在目标的同一目录,避免跨文件系统替换失败。已经打开旧文件的描述符不会自动跳到新版本;长期持有文件句柄的程序需要重新打开,内存配置也需要自己的加载机制。换了门牌,不代表每个人手里的旧地图都同步更新。

三、可运行的单写者保存函数
下面代码保存为 atomic_config.py。适用范围是受信任、已存在的目录,普通配置文件、单个写入者和支持目录同步的 Linux 本地文件系统;不是通用的安全文件服务。先在测试目录运行,不要直接指向生产配置。
import json
import os
import tempfile
from pathlib import Path
def 保存配置(目标, 数据):
目标 = Path(目标).absolute()
内容 = json.dumps(数据, ensure_ascii=False, allow_nan=False) + '\n'
# 仅用于受信任目录、单写者、本地 Linux 文件系统。
with tempfile.NamedTemporaryFile(
mode='w', encoding='utf-8', dir=目标.parent,
prefix='.' + 目标.name + '.', delete=False
) as 文件:
临时 = Path(文件.name)
try:
文件.write(内容)
文件.flush()
os.fsync(文件.fileno())
except BaseException:
临时.unlink(missing_ok=True)
raise
try:
os.replace(临时, 目标)
目录 = os.open(目标.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(目录)
finally:
os.close(目录)
finally:
临时.unlink(missing_ok=True)
if __name__ == '__main__':
保存配置('config.json', {'版本': 2, '开关': True})
运行 python3 atomic_config.py,会在当前目录生成 config.json。先序列化再建临时文件,让不支持的数据类型或 NaN 在碰正式路径前失败。NamedTemporaryFile 使用独立临时名称;默认仅创建者可读写,示例不会继承旧文件的属主、组、ACL 或扩展属性。
四、flush、fsync 和 replace 别混为一谈
flush 把 Python 缓冲交给操作系统;文件 fsync 请求同步文件数据及相关元数据;replace 切换正式路径;父目录 fsync 用于同步这次目录项变化。仅同步文件,不等于目录项也已经持久化。
这些步骤仍依赖文件系统与存储设备正确实现同步语义,不能写成“任何硬件断电都绝不丢数据”。本文在隔离目录实际执行了示例和异常分支,但没有做断电、内核崩溃或各类网络文件系统测试。完整可见、同步返回成功、业务加载成功,是三份不同的验收单。
五、失败发生在哪一步,决定如何恢复
| 现象或阶段 | 优先检查 | 处置边界 |
|---|---|---|
| 序列化或替换前失败 | 数据类型、空间、写权限 | 旧配置仍在,不要先删旧文件 |
| EXDEV | 临时文件与目标挂载位置 | 改为同目录创建,不退回覆盖复制 |
| 替换成功后同步报错 | 当前内容版本、存储错误 | 新文件可能已可见,不能声称自动回滚 |
| 其他用户读不到 | 属主、权限与安全标签 | 替换前按部署规范设置,不直接 chmod 777 |
| 路径是新版,应用仍旧 | 旧句柄、内存缓存、重载方式 | 验证应用实际加载的版本 |
异常退出可能留下点号开头的临时文件。清理前先确认没有活跃写入者,并按明确前缀、属主和保留时间处理;不要为了“干净”把整个目录清空。目录由不可信用户控制或目标涉及符号链接时,应另做路径安全设计,不能只加一个 exists 检查就当作防护完成。
六、原子替换不解决两个写者互相覆盖
两个进程都读到版本一,各自改不同字段,再依次替换,最后一份会盖掉前一份。JSON 可以始终合法,业务修改却照样丢失。需要多写者时,把读取、修改、保存作为整体加协作锁,或者使用数据库事务;不要只锁最后一次 replace。
它也不是多文件事务。两个配置分别替换,读者可能拿到新旧混合版本;一组必须一起生效的数据应合成单份快照,或采用版本目录与统一入口等专门设计。文件更新成功后,再由应用验证结构、取值范围和版本号,不要只以“能解析”作为上线标准。
七、把负例和故障注入放进验收
本次隔离实验先制造覆盖写半截文件,确认触发 JSONDecodeError;再连续原子替换一百轮,读线程反复重新打开文件,未出现解析错误。同时验证了旧描述符仍读旧版、拒绝 NaN 后旧版保留、替换前失败清理临时文件。
最值得保留的负例是:模拟父目录同步失败时,函数确实抛出异常,但正式路径已经读到新版。这说明调用方不能把任意异常都解释成“没有发生更新”。恢复时先读回版本并核对预期,再决定重试或发布一个新的回滚版本;不要悄悄吞掉异常。
八、上线前核对这张清单
- 写入目录受信任且预先存在,所有写者都停止原地覆盖。
- 临时文件位于同一目录,文件同步与目录同步错误都能上报。
- 确认替换后的权限、属主与安全标签满足实际读取进程要求。
- 测试新打开的读者、长期持有句柄的读者,以及应用重载后的版本。
- 同时核验 JSON 结构和业务字段;多写者、多文件需求另设一致性方案。
参考:Python os.replace 与 os.fsync 文档、Linux rename(2)、Linux fsync(2)。这些是长期存在的接口与语义,不是近期新增功能。
🔕 评论已关闭