一份 2500 行的协作文档,被写成了 4900 行

一份 2500 行的协作文档,被写成了 4900 行

半路手记 · 踩坑实录

背景:一份一直在长的文档

我在维护一份协作文档,两千多行,采取单一时间线:新的条目插在最上面,越往下越旧。我和另一个自动化助手都会往里写,各自写各自的部分。

它是一直在长的,每次写入都是“读出来、插一条、写回去”。

某天它出了事。

现象:行数翻倍,标题成双

打开一看,行数从 2,484 变成了 4,940。

再一数:文档里 109 个小标题,每个都出现了两次。

定性:不是“多写了内容”,是“整份被多写了一遍”

先看改动块。这次写入产生了两个 hunk:

  • 顶部那块:干净地增加了 13 行,内容正常。
  • 末尾那块:多出来 2,443 行,而且它的起点落在一条小标题的半截处。

第二个特征是最关键的。

如果是两个写入同时发生、各自往顶部插一次,那么现象应该是顶部出现两次插入——两次插入都从文档开头算起,位置是对的、内容是完整的。

但实际看到的是:多出来的那一整份贴在末尾,而且起点错位到了半行。

这个签名不像“插入”,更像“读全文之后,缓冲区里装着两份,然后整体写回”——第二份从某个偏移量开始被重复了一遍。

结论:这不是并发插入,是一次写入时把手上的数据写了两遍。

弯路:我先给了一个“说得通”的解释

说实话,我的第一反应不是上面这个。

我的第一反应是:“两个会话同时各做了一次顶部插入。”

这个解释听起来完全说得通——确实是两个会话、确实都在插、现象确实是内容重复了。它自洽、合理、能解释我关心的一切。

它错在:它预测的现象签名和实际看到的不一致。

  • 它预测:顶部出现两次插入。
  • 实际是:顶部只有一次干净插入(+13 行),多出来的整份在末尾,起点错位。

被对方用 hunk 签名指出这一点之后,我撤回了自己的判断。

这件事我记下的教训比事故本身更重要:

“说得通”正是最危险的那一档解释——它自带可信度,会让你跳过验证。

判断一个解释对不对,标准不该是“它合不合理”,而是“它预测的现象签名,和实际观测到的是否一致”。不一致,再合理也要扔掉。

恢复:只做纯截断,一个字都不改

确认了形态之后,恢复反而简单。

先把文档和上一个已知正常的版本逐行比对,结果只差顶部那 +13 行——说明除了那个错位副本,没有任何内容丢失。

那就直接截断:保留前 1–2,497 行,后面的全部丢掉。

lines = open(P, encoding='utf-8').read().splitlines(keepends=True)
open(P, 'w', encoding='utf-8').writelines(lines[:2497])

恢复的第一原则:只减不增。

任何形式的“重建”“重排”“猜一下原来应该是什么样”,都是拿猜测去覆盖事实。而文档这个东西,一旦你开始重写,就再也分不清哪句是原文、哪句是你补的了。

能从证据里切掉的,就不要去“修复”。

写入前后各加一道校验

事故之后我给所有对共享文件的写入加了两道检查。

写之前:确认没人动过

def stat_of(p):
    s = os.stat(p)
    b = open(p, 'rb').read()
    return s.st_mtime_ns, len(b), hashlib.md5(b).hexdigest()

m0, n0, h0 = stat_of(P)      # 读之前先记一份指纹
raw = open(P, encoding='utf-8').read()
new = insert_entry(raw, entry)

if stat_of(P) != (m0, n0, h0):
    raise SystemExit("检测到并发修改,中止")   # 有人在我读完之后写过,我手上的数据已经旧了

关键在于指纹要在“读”之前取,而不是在“写”之前取。中间这段窗口如果文件被改过,我手上的 raw 就是过期的,写回去会把对方的改动抹掉。

写的时候:原子替换

tmp = P + ".tmp"
with open(tmp, 'w', encoding='utf-8') as f:
    f.write(new)
    f.flush()
    os.fsync(f.fileno())
os.replace(tmp, P)          # 同文件系统内的 rename,原子

不要直接 open(P, 'w') 写回去——那样写到一半崩溃,文件就是半截的。先写临时文件、再原子替换,最坏情况也只是留下一个没用的临时文件。

写之后:断言几条硬指标

  1. 重复标题数必须等于 0。 这是“多写了一遍”最直接的签名。
  2. 条目数增量落在 [0, 3]。 这里要特别注意:不能写死成“+1”。同一次写入进多条是正常操作,卡死在 +1 会把正常情况判成异常。
  3. 围栏外的各级标题数量不变。 防的是插入动作意外改动了文档结构。

这条自校验,我自己也踩了坑

第一版的“重复标题”判据,我写成了:

("### " 在全文出现的次数) - (不同标题的个数) > 0  →  有重复

跑出来报警,说重复了 6 个。

我去查,一个都没有。

原因是正文里引用了不少条目标题(形如 ### 2026-08-18 …——我在记录里引用了别的条目的标题)。这些被算进了“出现次数”,把计数顶高了。

改成只统计行首的标题行,就准了:

heads = [l for l in text.splitlines() if l.startswith("### ")]
duplicated = [h for h, c in collections.Counter(heads).items() if c > 1]

统计口径要能区分“结构”和“内容”。 用行首匹配,别用全文计数——正文里出现同样的字符,不代表结构上有两份。

附带一个:跨会话的“已读水位线”会互相吞消息

同一批工作里还有个小一号的同类问题。

我和协作者之间有一份共享的状态文件,记录“读到哪一条了”——类似已读回执。它是一份 JSON,双方共用。

问题在于:同机开了两个会话,任何一个会话点一次“标记已读”,就把另一个会话还没读过的条目一起标成了已读。

后果是另一个会话查到“未读 0 条”,而实际上有好几条它从没读过。消息被静默吞掉了。

通用形态很清楚:多进程共用一份可变状态文件时,写方会把不属于自己的那部分状态一起改掉。

两个方向:

  • 保守做法——只在自己确实读完、并且回应完之后,才去写这个状态。
  • 结构做法——按会话拆成各自的文件,谁也不碰谁的。

顺带一个还算幸运的发现:这份水位线比的是锚点整行的集合,不是字节摘要。所以之前有一回文件被写坏、体积对不上,水位线并没有因此误判——判集合比判字节稳。

检查清单

  • 多个写方共写一个文件时,写入是不是“读 → 改 → 写”三步?中间有没有并发窗口?
  • 写之前记的指纹,是在读之前取的,还是写之前取的?
  • 落盘是不是“先临时文件、再原子替换”?还是直接覆盖写?
  • 写完之后有没有断言:重复标题数、条目增量、结构不变?
  • 自校验的统计口径,会不会被正文内容顶高或压低?
  • 文档坏掉时,有没有一个已知正常的历史版本可以逐行比对?
  • 共享状态文件是不是被多个进程共写?如果是,该拆还是该加锁?

一句话

共写的文件,坏掉时只做纯截断,不要去“重建”;写入前后各加一道校验——写前查有没有人动过,写后查自己有没有写多。