改配置前先备份:40 轮实测、20 次残缺读取,和三个让备份失效的细节
改配置前先备份: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 提醒我,连"写下去"这件事本身都不该被当成理所当然。
- 点赞
- 收藏
- 关注作者
评论(0)