顺序和加锁不是二选一,先看产物被破坏的形态:如果新版本把旧版本整段盖掉、文件结构还完整,问题多半出在写入顺序或最后一次写的覆盖;如果文件里出现半句拼接、同一段落两种措辞互相咬合,那通常是两个写操作同时落盘、互相穿插,光排队不做互斥也躲不掉。判断清楚之后再决定是让子 Bot 各写各的分片、最后合并,还是对共享目标加一道串行队列。
先分类型再选手段:产物整段被替换、后写的版本完整覆盖前一个,通常属于写入顺序问题,用串行队列或固定合并顺序就能收敛;如果记录里出现半句拼接、同一段落两种措辞交替,多半是两个写操作在文件内交错,需要让每个子 Bot 只写自己的分片再统一合并。两者可以同时用,边界是:分片降低了共享写的必要性,但不能替代对最终合并动作的互斥。
在产物历史里找出被覆盖的那一次写入
先把前后两个可比的版本拿出来,逐段对齐看差异落在哪里。常见的两种形态在记录上表现不同:
- 覆盖型:差异集中在某一段或某几段,被换掉的内容是一整块,替换后的内容本身语义完整。这说明两次写都是完整写入,只是后一次把前一次的结果顶掉了。定位方法是看该段落后一次写入的时间戳,通常能在子 Bot 的日志里找到一条对应的“写入完成”记录,两条记录的时间间隔会非常近。
- 交错型:差异位置碎、断句处出现半句、标点错位,甚至同一句话里混进两个子 Bot 各自的措辞。这不是谁覆盖谁,而是两个写句柄同时在同一偏移量附近追加或改写。定位方法是对比写入日志里的开始与结束时间区间,两个区间有明显重叠的那一段就是冲突现场。
如果产物有版本历史或备份,建议按时间顺序排一遍,找出内容第一次变得“不像任何一个子 Bot 单独产出”的那一版,冲突时间点基本就在它前一版到它之间。没有版本历史时,可以先用 ls -l `--time-style`=full-iso 看修改时间,再配合子 Bot 日志里的任务 ID 交叉确认。
让每个子 Bot 只写自己那份中间产物
能用分片就不要共享写。约定一个带序号或子 Bot 标识的命名规则,让每个子 Bot 的输出落在彼此不重叠的路径上,合并只在一个地方做:
out/
part-01-<botA>.md
part-02-<botB>.md
part-03-<botC>.md
merged.md # 只由合并步骤产生
命名里带固定宽度序号,排序即最终顺序,避免靠修改时间决定先后。合并前设两个校验点:分片非空、分片开头符合约定标记(例如都有段落标题或固定前缀)。合并脚本骨架可以这样写,注意先写临时文件再原子替换,避免合并过程本身被读到半成品:
import glob, os
parts = sorted(glob.glob('out/part-*.md'))
tmp = 'out/merged.md.tmp'
with open(tmp, 'w') as w:
for p in parts:
text = open(p).read()
if not text.strip():
raise SystemExit('空分片: ' + p) # 校验点一
if not text.startswith('## '):
raise SystemExit('分片头缺失: ' + p) # 校验点二
w.write(text)
os.replace(tmp, 'out/merged.md') # 原子替换,读到的是完整文件
分片方案的前提是产物可以拆分。如果最终产物必须是一份被连续追加的日志或状态文件,分片名就失去意义,这时直接进入下一节的排队。
必须共享写时加一道串行队列
共享写的最简做法是用一个原子操作占位。以目录创建为例,mkdir 在本地文件系统上是原子的,成功即持锁,失败即有人在写:
import os, time
def run_with_lock(name, timeout=30):
lock = '/tmp/bot-' + name + '.lock'
deadline = time.time() + timeout
while True:
try:
os.mkdir(lock) # 成功即持锁
break
except FileExistsError:
if time.time() > deadline:
return 'timeout' # 超时分支,交给调用方决定
time.sleep(0.5)
try:
do_write() # 临界区:只有这里碰共享产物
return 'ok'
finally:
os.rmdir(lock) # 必须释放,异常路径也要走到
超时后的处理要有明确分支,常见选择有三种:放弃本次写入并把任务标记为待重试;退化成“只写自己的分片、不参与合并”,留到下一轮再并;或者记录冲突并转人工确认。不要静默超时,否则问题会从覆盖变成丢内容,更难查。
判断串行是否真的生效,看调用方记录即可:同一产物路径上,各子 Bot 的“开始写入 / 结束写入”时间区间应当不再重叠,等待时间应当出现在持锁之前而不是写入中间。若日志里仍能看到区间重叠,先确认所有写路径都走了同一把锁,而不是有的分支绕过了它。
复现一次并发写来验证改动
改完之后不要只看单次结果,人为制造一次并发:
- 清空产物目录,确认
merged.md不存在。 - 在同一秒内触发两个子 Bot(例如同一条命令加不同参数,或用两个终端几乎同时回车),让它们的写入窗口尽量靠近。
- 等两者都结束后,检查产物长度与段落顺序:长度应当接近各分片之和,段落顺序应当与分片命名顺序一致,不出现半句。
- 再看持锁日志或分片文件,确认两个子 Bot 的时间区间要么不重叠,要么各自只落在互不相同的分片上。
建议连着触发几轮,因为时序问题往往不是每次都出现。如果仍然出问题,先判断是哪一类:产物完整但内容属于某一个子 Bot,说明合并顺序或最后一次替换仍在覆盖,检查是否还有绕过合并的直写路径;产物出现半句或断行,说明互斥没覆盖到真正的写动作,检查锁的粒度和释放时机;分片缺失,则更可能是任务本身失败,先看子 Bot 自己的报错,而不是继续加锁。
把并发写入约定写进任务模板
把这次踩到的约定固化下来,下一次派发任务时直接填,不用临场判断。模板里至少声明四个字段:
- 产物路径:写清是分片目录还是共享文件,路径写全,避免子 Bot 各自拼路径。
- 是否独占:标记该产物是否同一时间只允许一个写入方;独占的必须走串行队列,非独占的走分片。
- 合并方式:按命名顺序拼接、按键合并,还是只追加;写明由谁触发合并。
- 冲突时的处理选择:等待、跳过本轮、写分片留待下轮,或转人工确认,选一个默认值。
模板示例可以很短:
产物路径: out/part-{seq}-{bot}.md
是否独占: 否(合并步骤 out/merged.md 独占)
合并方式: 按 seq 排序拼接,合并前校验非空与分片头
冲突处理: 等待 30s,超时则保留分片跳过合并
约定写在模板里,新加子 Bot 时就不会再撞同一份文件;需要改行为时也只需要改模板字段,而不是逐个翻子 Bot 的代码。