1. 从一次线上数据覆盖事故说起
凌晨两点,值班群里弹出一条告警:某核心业务集合里,一条订单记录的状态从"已支付"被改回了"待支付",而这条记录在十分钟前刚刚完成支付回调。更诡异的是,操作日志里显示这次写入来自一个正常的业务请求,没有任何异常登录或越权痕迹。排查到天亮才发现,问题出在一个异步任务上——这个任务在读取订单时拿到的是旧版本快照,处理过程中用户完成了支付,等异步任务把结果写回时,它并不知道自己手里的数据已经过期了,于是干净利落地把新状态覆盖成了旧状态。
这就是典型的陈旧写入(stale write)。它不像死锁那样会立刻暴露,也不像主键冲突那样会直接报错,它安安静静地把正确数据改错,等到业务方发现时,往往已经过去了好几个小时,中间可能还叠加了更多写入,追溯和修复都极其痛苦。
在 MongoDB Atlas 引入 Agent Engine 这类智能代理能力之后,这个问题反而变得更隐蔽了。Agent Engine 的典型工作模式是"读取上下文 → 推理决策 → 写回结果",这个链路天然存在时间窗口,窗口越长,陈旧写入的概率越高。很多人以为上了 Agent Engine 就自动解决了并发问题,实际上它只是把决策逻辑交给了代理,并发控制这一层该做的门禁,一点都不能少。
这篇内容就是围绕这个场景展开的:在 Agent Engine 之后,怎么用版本门禁这套机制,把陈旧写入挡在数据库门外。我会从原理讲到落地,包括字段设计、查询写法、失败重试、和乐观并发控制的关系,以及我在实际项目里踩过的几个坑。适合正在用 MongoDB Atlas 做多代理协作、异步任务、或者任何"读改写"链路的同学参考。
2. 陈旧写入到底是怎么发生的
2.1 一个被低估的时间窗口
先把问题拆到最底层。任何"读改写"操作,本质上都包含三个步骤:读取当前值、在应用层计算新值、把新值写回。这三步之间存在时间差,这个时间差就是陈旧写入的温床。
举个最朴素的例子。假设库存字段stock当前是 10:
- 代理 A 读到
stock = 10 - 代理 B 也读到
stock = 10 - 代理 A 计算 10 - 3 = 7,写回
stock = 7 - 代理 B 计算 10 - 2 = 8,写回
stock = 8
最终库存是 8,但正确结果应该是 5。代理 B 的写入就是陈旧写入——它基于一个已经失效的读快照做了决策。这个问题在单机单线程时代不存在,因为读写是串行的;一旦进入并发或异步场景,它就必然出现。
Agent Engine 的介入让这个窗口变得更长。因为代理在"读取"和"写回"之间,往往还要调用模型推理、查询外部知识、做多轮工具调用。这些操作耗时从几百毫秒到几秒不等,窗口被拉长了一个数量级,冲突概率自然水涨船高。
2.2 为什么 Agent Engine 场景下更危险
传统 CRUD 接口的读改写窗口通常很短,几毫秒到几十毫秒,冲突概率低,很多人靠"运气"就能扛过去。但 Agent Engine 不一样,它的决策链路有几个特点:
- 推理耗时不可控:模型调用可能几百毫秒,也可能几秒,取决于负载和复杂度。
- 多步工具调用:代理可能先查数据库、再查缓存、再调外部 API,每一步都在拉长窗口。
- 批量决策:一个代理任务可能一次性处理多条记录,处理到第 N 条时,第 1 条的读快照早就过期了。
- 多代理并行:多个代理实例同时跑,彼此不知道对方在改什么。
这几个特点叠加起来,陈旧写入从"偶发"变成了"高发"。我见过一个项目,上线 Agent Engine 之后,数据不一致的工单量涨了三倍,最后定位下来全是陈旧写入。
2.3 陈旧写入和丢失更新的区别
这两个概念经常被混用,但严格说不是一回事。
丢失更新(lost update)指的是两个写入互相覆盖,后写的把先写的成果抹掉了。陈旧写入范围更大,它强调的是"写入所依据的读快照已经过期",至于是否覆盖了别人的成果,是另一回事。一个陈旧写入可能恰好写入了和当前值相同的值(无害),也可能覆盖了别人的更新(有害)。
理解这个区别很重要,因为它决定了你的防护策略。防丢失更新,核心是"写入时检查当前值有没有变";防陈旧写入,核心是"写入时检查我读的那个版本还在不在"。后者更通用,也是版本门禁的理论基础。
3. 版本门禁的核心机制拆解
3.1 给每条记录发一张"身份证"
版本门禁的思路非常朴素:给每条文档加一个版本字段,每次写入时版本号递增,写入前先检查"我读到的版本"和"数据库当前版本"是否一致,一致才允许写,不一致就拒绝。
这个版本字段就是记录的身份证。它可以是:
- 单调递增整数:
version: 1, 2, 3...,最直观,便于排查。 - 时间戳:
updatedAt,天然有序,但并发下可能撞车(同一毫秒多次写入)。 - 内容哈希:对关键字段做哈希,能检测内容变化,但计算成本高。
- ObjectId 或 UUID:每次写入生成新值,只做"是否相等"判断。
我在生产环境里最推荐单调递增整数,原因是它可读、可比较、可追溯。出问题时看一眼版本号就知道这条记录被改过几次,比时间戳直观得多。时间戳作为辅助字段保留即可,不要拿它当版本门禁的主键。
字段命名上,version是最常见的,也有人用__v(Mongoose 默认)、rev、seq。建议统一用version,避免和框架内置字段冲突。
3.2 条件写入:把检查交给数据库
版本门禁最关键的一点是:检查动作必须和写入动作在同一个原子操作里完成。如果你在应用层先查版本、再判断、再写入,那中间还是有窗口,等于白做。
MongoDB 的单文档操作是原子的,所以正确做法是把版本检查写进查询条件里:
db.orders.updateOne( { _id: orderId, version: expectedVersion }, { $set: { status: "paid", updatedAt: new Date() }, $inc: { version: 1 } } )这段代码的含义是:只有当数据库里这条记录的version等于我读到的expectedVersion时,才执行更新,同时把版本号加一。如果版本对不上,matchedCount会是 0,说明有人在我读取之后改过这条记录,我的写入被拒绝了。
这就是版本门禁的全部精髓。它把"检查"和"写入"合并成了一个原子操作,数据库帮你保证了这两件事要么一起成功,要么一起失败,中间没有任何窗口。
3.3 和乐观并发控制的关系
版本门禁本质上是乐观并发控制(Optimistic Concurrency Control,OCC)在文档数据库上的一种具体实现。乐观并发控制的哲学是:假设冲突很少发生,所以不加锁,只在提交时检查有没有冲突,有冲突就回滚重试。
为什么用乐观而不是悲观?因为悲观锁(比如findOneAndUpdate加锁、或者事务里SELECT FOR UPDATE)在高并发下会带来严重的锁竞争和吞吐下降。而 Agent Engine 场景下,冲突虽然比传统 CRUD 高,但相对于总请求量仍然是少数,用乐观策略性价比更高。
MongoDB 本身也提供了findOneAndUpdate这种原子操作,它返回更新前或更新后的文档,可以配合版本字段做更复杂的逻辑。但要注意,findOneAndUpdate如果不带版本条件,它只是原子,不防陈旧写入。原子性和并发安全是两回事,别搞混。
4. 在 Agent Engine 链路里落地版本门禁
4.1 代理读取阶段:把版本号一起带走
Agent Engine 的读取阶段,最容易犯的错是只取业务字段,不取版本号。代理拿着业务数据去推理,推理完了想写回,发现自己根本不知道读的是哪个版本。
正确做法是在读取时就把version一起取出来,并且在整个代理上下文里传递它。如果你的代理框架有上下文对象(context),把version塞进去;如果是函数调用链,把它作为参数一路传下去。
const order = await db.collection("orders").findOne( { _id: orderId }, { projection: { status: 1, amount: 1, version: 1 } } ); // 把 order.version 存进代理上下文 agentContext.set("orderVersion", order.version);这里有个细节:projection里一定要显式包含version,别用默认全字段返回。全字段返回在文档大的时候会拖慢读取,而且容易让代理误用无关字段。
4.2 代理决策阶段:版本号是只读的
代理在推理过程中,绝对不能修改版本号。版本号是数据库的"事实",不是代理可以协商的变量。我见过有人让代理在决策时"预估"一个新版本号,结果写回时和数据库对不上,整个门禁形同虚设。
代理决策阶段只做一件事:基于读到的数据算出要写什么。版本号原封不动地保留,等写回时作为条件使用。
如果你的代理需要做多轮推理,每一轮都可能产生中间状态,建议把中间状态存在代理自己的内存或临时存储里,不要中途写回数据库。一次代理任务,只做一次最终写回,这样版本门禁的检查点最少,逻辑最清晰。
4.3 写回阶段:条件更新加失败处理
写回是整个链路的关键。前面说了,用带版本条件的updateOne。但光有条件还不够,你必须处理"条件不满足"的情况。
const result = await db.collection("orders").updateOne( { _id: orderId, version: expectedVersion }, { $set: { status: newStatus, updatedAt: new Date() }, $inc: { version: 1 } } ); if (result.matchedCount === 0) { // 版本冲突,说明数据被改过 // 这里不能静默失败,必须走冲突处理逻辑 throw new VersionConflictError(orderId, expectedVersion); }matchedCount === 0就是冲突信号。注意,这里不要用modifiedCount,因为如果新值和旧值恰好相同,modifiedCount会是 0,但matchedCount是 1,两者含义不同。判断冲突只看matchedCount。
4.4 冲突之后:重试还是放弃
冲突发生后怎么办,取决于业务语义。常见策略有三种:
| 策略 | 适用场景 | 做法 |
|---|---|---|
| 立即重试 | 冲突少、重试成本低 | 重新读取最新数据,重新推理,再写回 |
| 有限重试 | 冲突中等、推理成本高 | 最多重试 N 次,超过则放弃并告警 |
| 直接放弃 | 冲突意味着业务异常 | 记录冲突日志,交给人工或补偿任务 |
Agent Engine 场景下,我一般推荐有限重试,重试上限设 2 到 3 次。因为代理推理成本高,无限重试会放大系统负载;但完全不重试又会让很多本可自动解决的冲突变成人工工单。
重试时有个关键点:必须重新读取数据,重新推理。不能拿着旧的推理结果直接再写一次,那样只是把冲突往后推,没有解决根本问题。
async function writeWithRetry(orderId, computeFn, maxRetry = 3) { for (let i = 0; i < maxRetry; i++) { const current = await db.collection("orders").findOne({ _id: orderId }); const newValue = await computeFn(current); // 重新推理 const result = await db.collection("orders").updateOne( { _id: orderId, version: current.version }, { $set: newValue, $inc: { version: 1 } } ); if (result.matchedCount === 1) return { ok: true, retries: i }; } return { ok: false, reason: "max_retry_exceeded" }; }这段代码把"读、算、写"打包成一个可重试单元,每次重试都基于最新数据,是版本门禁落地的标准形态。
5. 几个容易踩的坑和排查思路
5.1 版本字段被业务代码绕过
最常见的坑是:版本门禁在主链路上做了,但某个后台脚本、数据修复工具、或者老接口直接updateOne不带版本条件,把版本号覆盖了或者没递增。结果主链路的门禁还在,但版本号已经乱了,冲突判断全部失效。
排查方法很简单:在数据库层面加一个校验,任何对目标集合的更新,如果$inc: { version: 1 }缺失,就记录告警。MongoDB 的 change stream 可以监听所有变更,写个小消费者专门检查版本号是否递增,一旦发现异常就报警。
const changeStream = db.collection("orders").watch(); changeStream.on("change", (change) => { if (change.operationType === "update") { const updatedFields = change.updateDescription.updatedFields; if (!("version" in updatedFields)) { logger.warn("version field not incremented", { docId: change.documentKey._id }); } } });这个监控上线之后,我们抓出了三个偷偷绕过门禁的老脚本,都是历史遗留代码。
5.2 重试风暴把数据库打挂
版本门禁本身没问题,但重试策略设计不当会引发次生灾害。如果冲突率突然升高(比如某个热点数据被大量代理同时处理),所有代理都在重试,数据库 QPS 瞬间翻几倍,可能直接把库打挂。
防护手段有两个。一是重试加退避,每次重试前 sleep 一个随机时间,避免所有代理同时重试:
await sleep(50 + Math.random() * 150); // 50-200ms 随机退避二是冲突率熔断,如果某个集合的冲突率超过阈值(比如 10%),自动降低代理并发度或者暂停非关键代理任务。这个阈值要根据业务容忍度调,没有万能值。
5.3 版本号溢出和类型问题
版本号用整数递增,理论上会溢出。JavaScript 的Number安全整数上限是 2^53 - 1,实际业务里几乎不可能达到,但如果你用的是 32 位整数存储,那就要注意了。MongoDB 的$inc对 32 位整数溢出会报错,对 64 位长整型则安全。
建议版本字段统一用Long或者Number(MongoDB 内部按 double 或 int 处理),别用字符串。字符串版本号没法做$inc,也没法做大小比较,排查时很痛苦。
还有一个隐蔽的坑:如果版本号从别的系统同步过来,类型可能不一致。比如一个系统存的是"5"(字符串),另一个存的是5(数字),条件查询{ version: 5 }匹配不到字符串版本,门禁会静默失效。上线前一定要做一次全量类型检查。
5.4 事务和版本门禁的配合
MongoDB 支持多文档事务,有人会问:既然有事务,还需要版本门禁吗?答案是需要,而且两者是互补的。
事务保证的是"一组操作要么全成功要么全失败",它不保证"我读的数据在我写的时候没被别人改过"。在事务里,如果你读了一个文档,然后基于它做决策,再写回,中间如果有其他事务改了它,你的写入仍然可能是陈旧的。MongoDB 的事务默认隔离级别是 snapshot,能读到一致快照,但写回时不会自动检查快照是否过期。
所以正确做法是:事务里也用版本门禁。读取时拿版本号,写入时带版本条件,冲突就回滚重试。这样既有事务的原子性,又有版本门禁的并发安全。
6. 版本门禁之外还能做什么
6.1 用 change stream 做实时冲突感知
版本门禁是"写入时拦截",change stream 是"变更时感知"。两者结合,可以做到事前拦截加事后审计。
具体做法是监听目标集合的变更流,把每次版本变化记录下来,形成一个版本历史。当代理发生冲突时,可以查这个历史,看看是谁在什么时候改的,改了什么。这对排查复杂冲突特别有用,因为版本门禁只告诉你"冲突了",不告诉你"和谁冲突了"。
版本历史表建议只存关键字段:文档 ID、旧版本、新版本、变更时间、变更来源。不要存全量文档,否则存储成本会失控。
6.2 给代理任务加幂等键
版本门禁防的是"基于旧数据写入",但还有一种情况是"同一个代理任务被重复执行"。比如任务队列重试、网络超时导致代理重跑,同一个决策被执行两次,第二次可能基于新数据做出不同决策,产生意外写入。
解决办法是给每个代理任务分配一个幂等键(idempotency key),写回时把这个键也作为条件之一。如果这个键已经处理过,直接跳过。
db.collection("orders").updateOne( { _id: orderId, version: expectedVersion, processedKeys: { $ne: taskKey } }, { $set: { status: newStatus }, $inc: { version: 1 }, $push: { processedKeys: taskKey } } )processedKeys数组会随着任务增多而变大,需要定期清理或者用 TTL 索引控制。这个方案适合任务量不大、幂等要求高的场景。
6.3 什么时候该考虑悲观锁
版本门禁不是银弹。如果某个数据的冲突率极高(比如超过 30%),乐观重试的成本会超过悲观锁的等待成本,这时候应该考虑悲观策略。
MongoDB 里可以用findOneAndUpdate配合一个"锁定"字段实现悲观锁,或者用事务的写冲突机制。但悲观锁会降低并发度,用之前一定要算清楚:冲突率多高、重试成本多大、业务能容忍多少延迟。我的经验是,冲突率低于 10% 用乐观,高于 30% 考虑悲观,中间地带看具体业务。
7. 我在实际项目里的几点体会
版本门禁这套东西,原理不复杂,难的是落地时的细节。我踩过的坑里,最深刻的一个是版本号在代理上下文里被意外修改。当时有个代理框架的序列化逻辑,把上下文对象转 JSON 再转回来,结果version字段被当成普通数字处理,精度丢失,写回时条件永远匹配不上。排查了两天才发现是序列化的问题。所以版本号在传递过程中,一定要确认它的类型和精度没有被破坏。
另一个体会是,版本门禁的日志一定要打全。冲突发生时,日志里至少要有:文档 ID、期望版本、当前版本、代理任务 ID、冲突时间。缺了任何一个,排查时都要靠猜。我们后来把冲突日志单独建了一个集合,配合看板,冲突率、冲突热点、重试成功率一目了然,运维效率提升非常明显。
还有一点,不要指望版本门禁解决所有并发问题。它解决的是单文档的陈旧写入,跨文档的一致性还得靠事务或者业务层的补偿逻辑。把版本门禁当成整个并发控制体系里的一环,而不是全部,心态会稳很多。
最后分享一个小技巧:上线版本门禁之前,先做一轮影子模式。也就是照常写入,但额外记录"如果启用门禁,这次写入会不会被拒绝"。跑一周,看看冲突率有多高、集中在哪些数据上,再决定门禁的严格程度和重试策略。这样比直接上线然后被冲突打懵要从容得多。