JavaLYG

Python 写 JSON 配置偶发损坏:用原子替换避开半截文件

Python 定时刷新 JSON 配置,读端偶尔报 JSONDecodeError,重启后文件甚至只剩半截。先别给解析器加无限重试:如果写端直接用 w 模式覆盖,读端可能正好撞上清空或写到一半的窗口。下面针对 Linux 本地文件系统,用同目录临时文件、原子替换与同步操作,把“完整可见”和“持久化确认”分开处理。

一、先确认是不是覆盖写造成的半截文件

配置原来有效,更新瞬间失败,稍后再读又正常,这种现象值得先查写入方式。open(path, 'w') 会截断已有内容;随后分批写入时,其他进程并不会自动排队等它写完。文件小,也不意味着一次更新天然是事务。

排查时记录错误偏移、文件大小和更新时间,不要把完整配置打印到公共日志。JSONDecodeError 也可能来自手工语法错误、错误编码或上游返回的 HTML,所以应先确认读写双方操作的是同一路径、同一版本,而不是见到错误就认定并发。

二、原子替换只负责“换入口”

正确思路是先把新内容写到另一份文件,完成后再用 os.replace 替换正式路径。Linux rename 的原子替换语义让按该路径重新打开的读者看到旧文件或新文件,而不是被写到一半的新内容。前提是所有写入者都遵守这套方式,已发布的文件不再被原地修改。

这里的“旧版或新版”指已有配置在替换期间的并发打开。替换成功后再打开,在没有后续改动时应读到新版;首次创建配置则不同,替换前目标可能根本不存在。初始化阶段应由启动流程处理,不要把文件不存在也当成解析失败反复重试。

临时文件放在目标的同一目录,避免跨文件系统替换失败。已经打开旧文件的描述符不会自动跳到新版本;长期持有文件句柄的程序需要重新打开,内存配置也需要自己的加载机制。换了门牌,不代表每个人手里的旧地图都同步更新。

Linux单写者JSON配置更新:先写同目录临时文件,同步文件,再原子替换路径,最后同步父目录

三、可运行的单写者保存函数

下面代码保存为 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)。这些是长期存在的接口与语义,不是近期新增功能。

🔕 评论已关闭