改配置前先备份:40 轮实测、20 次残缺读取,和三个让备份失效的细节

举报
yd_263929376 发表于 2026/11/03 13:30:21 2026/11/03
【摘要】 改配置前先备份:40 轮实测、20 次残缺读取,和三个让备份失效的细节改一个线上服务的 app.json,脚本写了 30 行,其中 2 行是 shutil.copy。这份备份能不能救你,取决于一些平时不会去看的细节。我用一个 117,073 字节的配置做了 40 轮"边改边读",量了几组数字,结论比预想的更不乐观。 打开即清空:40 轮里有 20 次读到半个文件最常见的写法是这样:with...

改配置前先备份:40 轮实测、20 次残缺读取,和三个让备份失效的细节

改一个线上服务的 app.json,脚本写了 30 行,其中 2 行是 shutil.copy。这份备份能不能救你,取决于一些平时不会去看的细节。我用一个 117,073 字节的配置做了 40 轮"边改边读",量了几组数字,结论比预想的更不乐观。

打开即清空:40 轮里有 20 次读到半个文件

最常见的写法是这样:

with open("app.json", "w", encoding="utf-8") as f:   # 这一行就把文件截成 0 字节
    f.write(new_text)

open(..., "w") 返回时,磁盘上的文件已经是 0 字节——不需要 write,也不需要 close。我量了一下:进入 with 之后、写入之前,os.path.getsize() 读出来就是 0。

在这个空窗里让另一个线程去读,40 轮的结果是:

  • 读到内容不完整:20 次(50%)
  • 其中读到 0 字节的空文件:16 次
  • 最大缺失:117,073 字节,也就是整份配置

也就是说,一半的读取都发生在文件处于半成品状态的瞬间。如果你的配置读取方是常驻进程、是健康检查、是另一个脚本,那么"改配置"这个动作本身就带 50% 的概率让下游拿到解析失败的内容。这类故障很难复现,因为窗口只有几毫秒。

原子替换在 Windows 上会撞句柄

标准解法是临时文件加一次重命名:

tmp = path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
    f.write(new_text); f.flush(); os.fsync(f.fileno())
os.replace(tmp, path)

残缺读取确实是 0 次。但同一组 40 轮实验里,写线程崩了 29 次,报错是:

PermissionError: [WinError 5] 拒绝访问。:
'E:\...\.tmp-backup\app.tmp' -> 'E:\...\.tmp-backup\app.json'

这是本轮最值钱的一条踩坑记录。第一版脚本在读者线程里一直 read_text(),我原以为 os.replace 是无条件原子的——在 Unix 上 rename 语义确实如此,但 Windows 上只要目标文件被任何进程持有句柄(编辑器、杀毒软件扫描、上一轮读者的句柄),替换就被拒绝。所谓"原子写"在 Windows 上换来的不是零故障,而是把"读到半个文件"换成"写不进去"。

加上 5 ms 间隔、最多 10 次的重试之后:

写法 残缺读取 写失败 写耗时中位
直接覆盖写 20/40 0 2.1 ms
临时文件 + os.replace 0/40 29/40 11.4 ms
同上 + 5 ms×10 重试 0/40 1/40 18.4 ms

fsync 20 次的中位耗时是 12.11 ms,最大 19.00 ms。这说明多出来的十几毫秒主要花在刷盘,而不是重试开销——这笔钱该花,但不能指望重命名本身免费。

还有一层容易忽略:把目标文件设成只读,os.replace 抛的是完全相同的 [WinError 5] 拒绝访问。。同一个错误码至少有"句柄被占"和"属性只读"两种成因,前者重试有用,后者重试十次只是把 5 ms 的等待拖成 50 ms 再失败。判断方式很土但有效——先查 os.stat(path).st_mode & 0o200,没有写权限就直接报错,别进重试循环。

时间戳精度决定备份会不会被自己覆盖

备份文件名带时间戳是常识,问题在精度。三次改动间隔 121 ms,秒级命名生成的三次文件名完全相同:

app-20261103-123718.json
app-20261103-123718.json
app-20261103-123718.json

落盘结果:1 个文件,2 次备份被静默覆盖,copy2 全程不报错。毫秒级命名同样三次操作得到三个不同文件名。

看一下本机真实的备份目录,命名精度是混着的:20 个条目里 15 个精确到秒,2 个只到分钟(cleanup-py-fix-20261024-1145),还有一组同秒的 bettbox-tuning-20261102-163932 与 bettbox-tuning-20261102-163932-src,靠手工加后缀才没撞上。撞名不是理论风险,是命名规则不统一迟早会结算的账。

稳妥的做法是时间戳到毫秒再拼一个内容短哈希,哈希同时解决"两次改动内容相同要不要再存一份"的判断。

copy 会偷走 mtime

shutil.copy(src, dst)    # 目标 mtime = 当前时间
shutil.copy2(src, dst)   # 目标 mtime = 源文件 mtime

同一个源文件(mtime 2023-11-15 06:13:20),copy 之后变成 2026-11-03 12:37:18,copy2 保留原值。这个差别会直接毁掉清理逻辑:按 mtime 排序保留最近 N 份的脚本,在 copy 生成的备份上排序结果全是错的,被删掉的可能正是最老最该留的那一份。

三个"备份成功"的假象

空备份也算成功。 写入中断留下 0 字节文件,Path.exists() 返回 True,st_size 是 0。只校验存在性的备份脚本,等于没有校验。

二次运行覆盖掉唯一可回滚版本。 把 svc.json 改坏成 {"port":8080,"enabled":tru(26 字节),如果备份脚本因为重试、定时任务或手抖又跑了一次,坏内容就被盖到 svc.json.bak 上。此时回滚目标本身已损坏,json.loads 直接抛 JSONDecodeError——你备份了,但备份的是灾难现场。

只比对大小。 截断到相同长度的两份内容,st_size 一样。

把一份 108,013 字节的配置备份截断到 60%(64,807 字节),四种常见校验的结果是:

校验方式 结果
exists() True,放过
size > 0 True,放过
size == 源 False,拦住
sha256 一致 False,拦住
JSON 可解析 否,JSONDecodeError

结论很清楚:exists() 和"非空"两项加起来只能拦住彻底失败的写入,拦不住 60% 的截断。要么比哈希,要么解析一遍——后者顺手还验证了配置本身有没有写坏。

一个能用的最小实现长这样:

import hashlib, json, os, time
from pathlib import Path

def backup(path: Path, keep=10):
    raw = path.read_bytes()                       # 先固化内容,再算哈希
    stamp = time.strftime("%Y%m%d-%H%M%S") + f"-{int(time.time()*1000) % 1000:03d}"
    digest = hashlib.sha256(raw).hexdigest()[:8]
    bak = path.with_name(f"{path.name}.{stamp}-{digest}.bak")
    bak.write_bytes(raw)
    if hashlib.sha256(bak.read_bytes()).hexdigest() != hashlib.sha256(raw).hexdigest():
        raise RuntimeError("备份校验失败,禁止继续修改")
    if path.suffix == ".json":
        json.loads(raw.decode("utf-8"))           # 源文件本身可解析,才允许往下走
    for old in sorted(path.parent.glob(f"{path.name}.*.bak"))[:-keep]:
        old.unlink()
    return bak

关键是 json.loads(raw) 那一行:它在改之前先证明"当前这份是好的"。备份一个已经坏掉的文件,比不备份更危险,因为它会让你以为有退路。

恢复演练:2.8 ms 与 678 ms

备份脚本写完了就该跑一次恢复,成本低到没有理由跳过。同一台机器上的实测:

  • 108,013 字节的配置文件:copy2 加哈希比对,三次分别 2.4 / 2.8 / 2.8 ms
  • 300 个 50 KB 文件的目录:整目录复制 678 ms
  • 本机那个 15,755 KB 的历史备份目录,按 17 MB 量级估算,整目录恢复在秒级

真正的风险不是恢复慢,是"恢复之后不知道对不对"。所以演练要连着做两步:把备份复制到临时路径,再拿哈希和源比较,而不是直接把备份盖回生产路径试。前者失败只是多一个临时文件,后者失败就是第二次事故。

保留策略按体积算,不按份数算

100 个 782 字节的配置版本占 84,300 字节,gzip 后单版 155 字节,压缩比 0.20。而本机那个 16.6 MB 的备份目录里,单个 15,755 KB 的目录就占了 92.7%。同样是"保留最近 10 份",在纯文本配置上是 8 KB 的事,在含二进制产物的目录上是十几 MB 的事。清理阈值应该写成字节上限,份数只作为兜底。

备份的价值不在于磁盘上多了一个文件,而在于你能在三十秒内回到一个已知可用的状态。没做过一次恢复演练的备份,只是一个假设——而 os.replace 那 29 次 WinError 5 提醒我,连"写下去"这件事本身都不该被当成理所当然。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。