
自动化系统里任务队列常常用一个大 JSON 文件来存结构简单肉眼可读改起来也方便。但当不止一个进程要对它做读-改-写时这个看似无害的设计就会埋雷。本文复盘一次真实的丢更新事故两个工作进程并发写同一个 JSON 队列文件结果一条任务被投递了两次另一条不翼而飞。现象一条任务跑了两次另一条凭空消失监控先报的是重复执行同一条任务出现在两次执行记录里日志时间戳只差几秒而且是同一个任务 ID、同一份参数不是相似任务。起初以为是上游重复投递但顺着另一条线索看队列里有一个任务项没有对应任何执行记录——它凭空消失了。一边是重复一边是丢失两个症状同时出现在同一个队列文件上这不太像单纯的重复投递。排查从生成端日志到字节偏移比对排查走了一点弯路。先怀疑任务生成端重复投递。把生成侧的日志完整核对了一遍这条任务只生成过 1 次投递语句也只出现一次任务 ID 全局一致生成端排除。接着换思路不再问谁投递了它而是问文件里发生了什么。我们在每次写文件时顺手留了带时间戳的备份这也是后面能定位的关键把几个时间点的快照拉出来做版本号/字节偏移比对发现一个时间窗口非常可疑B 进程在 T0 时刻读文件、在内存里计算新队列、准备写回这个动作花了约 3 秒而 A 进程在这 3 秒的中途也读了一次文件并在 T02 秒左右先写回B 在 T03 秒才写回。B 写回的内容是基于 3 秒前的旧文件算出来的A 刚写进去的那个任务项被整体覆盖掉了。从字节偏移看更直观A 写回的文件比 B 写回的文件多出一段新增任务的 JSON 片段而这段片段在 B 的版本里根本不存在。两个进程都在正确地做事坏就坏在它们各自基于不同版本的文件计算然后整体覆盖。根因read-modify-write 不是原子操作复盘下来问题可以归结为三点读-改-写read-modify-write这个复合操作本身不是原子的。读文件、在内存里改、写回文件中间任何一个时刻都可能被别的进程插进来。没有任何文件锁。两个进程谁也不知道对方正在写写回时也不校验我读到的版本还是不是当前版本。两个进程地位对等没有主从之分。没有谁负责串行化写入也没有谁负责裁决冲突。后果就是经典的丢失更新lost updateA 和 B 同时基于旧版本计算后写的整体覆盖先写的。A 的写覆盖了 B 刚写入的任务于是这条任务被 A 再次投递而 B 在内存里改过、还没来得及写回确认的另一个队列项被覆盖之后再没有落盘——这就是重复 丢失同时出现的原因。值得强调的是两个进程全程没有抛出任何异常文件也始终是合法 JSON系统从外部看一切正常这正是这类事故难以察觉的原因。修复三条路线的对比与选择现场讨论了三种方案方案一每账号拆独立队列文件。约定每个账号对应自己的 queue_{account}.json谁有权限处理哪个账号就只写哪个文件。共享消失冲突自然消失。方案二文件锁。写前加锁写完释放Linux 下用 flock需要跨平台时可以用 lockfile 目录占位。方案三单写者进程加 IPC。只留一个进程负责写文件其他进程通过管道、Socket 或消息队列把变更发给它由它串行落盘。三条路线的取舍方案二保留了共享结构改动小但锁的粒度、锁超时、进程崩溃后锁残留这些新问题都要处理方案三结构上干净但要引入 IPC 通道和单点进程的存活管理对当时的系统来说改动偏大方案一不动锁、不加组件只是把一个队列传给多个写者改成一个队列至多一个写者改动集中在文件命名和任务认领逻辑上。另外拆分还带来一个附带收益单文件变小读写耗时下降出问题时快照也比一个巨型文件好查得多。落地时选了方案一并叠加一条纪律保证同一账号在任一时刻只有一个写者进程。示意代码# queue_shard.py —— 每账号独立队列 写者互斥 import json, fcntl, contextlib contextlib.contextmanager def account_queue(account): path fqueues/{account}.json lock_path fqueues/.{account}.lock with open(lock_path, w) as lf: fcntl.flock(lf, fcntl.LOCK_EX) # 同一账号的写者在此排队 with open(path, r, encodingutf-8) as f: yield f def push_task(account, task): with account_queue(account) as f: data json.load(f) data[tasks].append(task) data[version] 1 f.seek(0) json.dump(data, f, ensure_asciiFalse) f.truncate() # 覆盖旧内容防止残留即便拆了文件写回时仍要依赖锁的排他性别让读到的版本和写回的版本有机会错开。如果暂时不能拆文件flock 是兜底做法# 用 flock 把读-改-写整体锁成临界区 flock /var/lock/taskqueue.lock -c ./update_queue.sh教训丢的往往是别人的更新日志很难还原这次事故有个让人后怕的细节如果没有提前留下的文件快照事后仅凭业务日志几乎不可能还原真相——被丢的那个任务从未进入执行流程重复的那条也只是看起来像重复投递。并发写同一文件时丢的常常是别人的更新受害者甚至不知道自己丢了东西。所以除了结构上的修复还补了两件事一是写前快照每次写回前把当前文件另存一份带时间戳的副本保留若干天成本几乎为零二是审计行每次读-改-写记一条进程号 读到的版本号 写回的版本号 变更摘要追加到单独的审计文件里出问题时能拼出完整时间线。拆分之后每个账号的队列文件只被一个写者触碰审计行的作用从破案降级为巡检这正是想要的状态。并发问题不总是以报错的形式出现更多时候是安静地覆盖掉别人的写入。队列文件越简单越要问一句它有几个写者参考文章这次事故的修复思路里让写者互不打扰和让进程长期活着是两件配套的事。如果你也在维护一套长驻的自动化系统可以接着读这两篇Windows 定时任务与看门狗让自动化脚本活过重启微信 FAQ 自动回答把重复问题交给知识库