在并发写入场景下防止 Node.js 和 MongoDB 中的更新丢失
了解原子条件更新、基于版本的乐观锁、409响应以及事务如何防止MongoDB中的并发写入操作悄悄丢弃数据。
有两个人对同一条记录点击保存,两个请求都返回了200 OK状态,但其中一个人的修改却悄然消失了。没有程序崩溃,也没有任何日志记录,而且逐个查看请求时,相关代码看起来完全合理。本文将解释在典型的Node.js和MongoDB后端中为何会出现这些丢失的更新,并提供一套防止此类问题的工具:原子条件更新、基于版本的乐观锁、409 Conflict响应、事务处理,以及能够实际重现竞态条件的测试。
一个真实的场景
以一家在线商店的管理员控制台为例。目前有一款商品的价格为100美元,库存量为10件。
美国的一名员工打开了该产品并将价格降至90美元。几乎同时,欧洲的一名同事也打开了同一产品并将库存数量设置为8。由于两人都在保存之前加载了该产品,因此他们都在编辑同一个过时的快照。正是这个共同的起始点引发了问题。
读取-修改-写入的差异
实现每次编辑的常见方法是加载文档,在内存中修改某个属性后再保存。第一个请求用于更改价格。(所示代码片段在save()调用之后有多余重复的product.price = 90;语句;可忽略它,因为它仅用于说明这种模式。)
const product = await Product.findById(productId);
product.price = 90;
await product.save();product.price = 90;
第二个请求则对库存字段执行相同的操作:
const product = await Product.findById(productId);
product.stock = 8;
await product.save();
每个请求都会读取旧状态,修改其本地副本后再写回。问题并不出在findById()上,真正的危险在于读取数据与写回数据之间的时间窗口,因为在此期间另一个请求可能会修改同一条记录。最后写入的内容会覆盖之前的所有更改,从而导致更新丢失。
Mongoose已处理了多少此类问题
需要明确的是,这种情况何时会发生。当对现有的Mongoose文档调用save()方法时,Mongoose只会以$set的形式发送你修改过的字段路径。因此在上述例子中,由于两个请求分别修改了不同的字段,价格和库存的更改通常都能保留下来。只有当出现以下情况时,更新丢失才会真正发生:
- 两次请求都修改了同一个字段(两名管理员都在编辑价格);
- 新值是根据旧值计算得出的(
product.stock = product.stock - 1),因此第二个操作者使用的数值已经是过时的; - 你的API会像典型的
PUT处理程序那样接收客户端发送的完整文档,并将所有字段都写回,包括其他用户刚刚修改过的字段。
其他的ODM、原生驱动程序以及SQL ORM的行为有所不同,因此不要将脏数据跟踪作为并发控制策略。默认情况下应将读-改-写操作视为不安全的行为,需有意识地选择下面的工具之一。
从不变量出发
在选择具体技术之前,先确定哪些内容绝不能出错。不同领域对此的答案各不相同:
- 对于库存而言:库存数量绝不能低于零。
应当由这一业务规则而非个人偏好来决定技术实现方案。
原子性更新:让数据库来完成修改
如果只是修改单个字段,通常根本无需读取整个文档。只需将所需的更改直接发送给数据库即可。设置价格只需通过一个带有$set参数的updateOne操作即可完成:
await Product.updateOne(
{ _id: productId },
{
$set: {
price: 90
}
}
);
库存的修改同样具有独立性:
await Product.updateOne(
{ _id: productId },
{
$set: {
stock: 8
}
}
);
现在每个操作都能准确表达其真实意图,而无需将旧版本的文档传回服务器。MongoDB会以原子方式处理每条文档的更新,因此针对不同字段的此类操作不会相互冲突。
将业务规则放在更新操作中
当把条件整合到查询语句中时,这种模式会变得更加强大。假设只剩下一张票了。传统的处理方式是先读取库存,在应用程序代码中进行检查,然后再减少库存数量,这样就为其他买家留下了可乘之机。相反,应让过滤器来表达规则,由$inc在同一个操作中完成数量调整:
const result = await Product.updateOne(
{
_id: productId,
stock: { $gt: 0 }
},
{
$inc: {
stock: -1
}
}
);
如果更新操作修改了文档,说明在操作执行时该物品仍有库存。如果没有进行任何修改,那就意味着已有其他请求拿走了最后一件商品,此时可以告知用户该物品已售罄。因为检查与写入是同一步骤,所以不存在时间间隔。这是最简单且最有效的并发处理模式之一,适用于计数器、配额、座位预订以及任何可以通过查询过滤器表达的规则。
针对长期编辑的乐观锁机制
原子更新无法涵盖所有情况。假设一名员工打开一个复杂的产品配置,花费五分钟调整多个字段后进行保存。与此同时,另一名同事已经对该产品进行了更改并保存。这名员工不应在不知情的情况下覆盖掉更新后的版本。
标准的解决方案是在文档中存储版本号:
Product
Price: $100
Stock: 10
Version: 7
两名用户都加载了版本 7。用户A先进行保存,此时版本变为 8。而用户B仍持有版本 7,因此B的保存操作意味着“仅当产品仍处于版本7时才应用此更改”。在MongoDB中,可通过在查询条件中指定期望的版本号,并在同一条更新操作中将其递增来实现这一功能:
const result = await Product.updateOne(
{
_id: productId,
version: currentVersion
},
{
$set: {
price: newPrice
},
$inc: {
version: 1
}
}
);
if (result.modifiedCount === 0) {
return res.status(409).json({
message: "This product was updated by another user."
});
}
如果过滤器不再匹配,就不会有任何内容被写入,处理程序会返回冲突错误,而不会默默忽略A的操作结果。这就是乐观并发控制:假设冲突很少发生,在用户编辑时不持有任何锁,而是在写入时检测冲突。
实际应用中可通过两项改进使其更加稳健。首先,当产品根本不存在时也会出现modifiedCount === 0的情况,因此通过检查matchedCount或进一步查询,可以针对缺失的产品返回404错误,仅在实际版本冲突时才返回409错误。其次,如果使用Mongoose文档而非updateOne方法,应考虑在模式层面设置optimisticConcurrency选项;否则Mongoose内置的__v键仅用于保护某些数组操作,而非所有的数据保存操作。
为何正确的状态码是409 Conflict
版本不一致并非服务器故障。API运行正常,请求格式也正确;只是与资源的当前状态发生了冲突。409 Conflict状态码正是表达了这一含义,同时让客户端能够做出合理响应:
- 重新加载最新版本;
- 向用户展示自其开始操作后发生的变化;
- 允许他们合并修改内容或重新尝试;
- 应用针对该产品特有的冲突处理机制。
无论用户界面如何设计,原则都是一样的:绝不能在未告知他人的情况下破坏他人的工作成果。
事务处理:多个写入操作必须同时成功
现在考虑下下订单的流程。这可能包括创建订单文档、预留库存以及记录相关信息,如付款或审计条目。如果前两项成功而第三项失败,系统就会处于半完成的状态。
当多个操作必须全部成功或全部失败时,事务能够确保这种原子性。从概念上讲,首先启动一个事务,执行相关的写入操作并提交;如果任何必要步骤失败,则会回滚,所有写入操作都不会生效。在 MongoDB 中,多文档事务需要副本集或分片集群,并通过客户端会话来执行。
不过,事务并非解决所有数据一致性问题的一劳永逸之策。它们成本更高,可能在写入冲突时失败,还需要额外的重试机制。仅在业务操作确实需要全有或全无的一致性时才使用事务,而在只需单次条件更新即可满足需求的情况下,应优先选择后者。
选择合适的工具
不要先问“是否应该使用乐观锁?”,而应先思考“我们要防止哪种故障?”:
- 单字段或条件性更改,例如仅在库存充足时才减少数量:此时应使用原子更新。
- 用户基于旧数据进行的无效编辑,例如两名管理员同时编辑同一产品:此时应使用乐观并发控制。
- 多个必须同时成功或失败的写入操作,例如订单、库存和账户的更改:此时应使用事务。
没有一种通用模式适用于所有系统,许多实际场景会结合使用两种方法。
在测试中重现竞态条件
仅通过让用户A更新产品并收到成功响应的测试,无法证明系统的并发处理能力。常规测试是依次执行操作,而这正是这类错误能够逃过检测的原因。必须主动创建竞态条件才能对其进行测试。
以库存管理为例,可将初始库存设置为1,然后使用Promise.all等方式同时发起100次购买请求。预期结果是仅有1次预订成功,其余99次被明确拒绝,最终库存应降为0而非负数。
对于乐观锁机制,将版本号设置为10,然后发送多个均声明使用版本10的更新请求。其中应有一个请求成功并使版本号上升,而其余请求则会因冲突而被拒绝,无法覆盖更新后的数据。
生产环境需关注的事项
部署完成后,应在指标和日志中查看以下信号:
409 Conflict响应;- 未匹配到任何目标的条件更新请求;
- 事务重试及中止情况;
- 死锁与锁竞争问题;
- 异常的库存变动;
- 重复操作;
- 其他与并发相关的错误。
冲突的突然增加往往暗示着更深层的问题:极高的温度记录、异常的流量模式、客户端过度的重试行为,或是新功能引发了超出预期的竞争。尤其是重复操作,通常可以通过幂等键来更好地处理,相关内容可在我们关于幂等POST端点的指南中找到。
并发是常态
竞态条件并非指两个人在同一时刻点击。只要有多个实体能够修改共享状态,就会出现这种情况:用户、API实例、后台任务、队列消费者、定时任务、Webhook以及其他服务。在任何具备实际规模的系统中,并发访问都是常态而非例外。
因此不必去考虑两个请求是否会同时到达同一段代码;应假定它们确实会如此。一个有用的审查习惯是,对于每一次更新都要思考:如果同时执行该更新的两个副本,会产生什么结果。如果设计能给出明确答案,那就说明情况良好;而如果诚实的回答是“希望第二个请求不会造成危害”,那么这段代码就需要重新审视了。
关键要点
- 当某个有效的更改被基于过时数据生成的另一个更改覆盖时,就会发生更新丢失的情况,这通常是通过读-改-写操作实现的。
- 优先选择在过滤器中嵌入业务规则的原子性、条件性更新方式。
- 使用版本字段和
409 Conflict响应来检测过时的编辑内容,而非直接覆盖它们。 - 只有当多个写操作必须一起提交或回滚时,才应使用事务处理。
相关阅读
- 实现 Node.js 服务可靠连接的六种集成模式 — 了解构建稳健的 Node.js 集成所依赖的核心模式:请求-响应、轮询、Webhook、API 密钥、JWT 与 OAuth 认证、带退避机制的重试,以及数据映射。
- 掌控 Node.js 并发:利用 p-map 和 Bottleneck 避免 API 故障 — 了解如何在 Node.js 中结合使用 p-map 和 Bottleneck,通过控制并发和请求时序来防止速率限制错误及系统过载。