首页 / 文章 / 在并发写入场景下防止 Node.js 和 MongoDB 中的更新丢失

在并发写入场景下防止 Node.js 和 MongoDB 中的更新丢失

了解原子条件更新、基于版本的乐观锁、409响应以及事务如何防止MongoDB中的并发写入操作悄悄丢弃数据。

2030 词

有两个人对同一条记录点击保存,两个请求都返回了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响应来检测过时的编辑内容,而非直接覆盖它们。
    • 只有当多个写操作必须一起提交或回滚时,才应使用事务处理。
  • 使用真正的并发请求进行测试,并监控生产环境中的冲突情况;设计时应考虑可能出现的重叠请求,而不仅仅是预期的那种。
  • 相关阅读

  • 使用$match、$group和$lookup构建MongoDB聚合管道 — 了解MongoDB聚合阶段如何对文档进行过滤、分组、重塑、连接和排序,以及如何将它们串联成能够解决实际报表需求的管道。