使用 MongoDB + NodeJS 生成唯一 ID 时处理竞争条件和饥饿

Dealing with race conditions and starvation when generating unique IDs using MongoDB + NodeJS

我正在使用 MongoDB 生成这种格式的唯一 ID:

{ID TYPE}{ZONE}{ALPHABET}{YY}{XXXX}

这里 ID TYPE 将是来自 {U, E, V} 的字母表,具体取决于输入,区域将来自集合 {N, S, E, W}YY 将是最后 2 位数字当前年份和 XXXXX 将是一个从 0 开始的 5 位数字(将用 0 填充以使其长度为 5 位)。当XXXXX到达99999时,ALPHABET部分将递增到下一个字母表(从A开始)。

我将接收 ID TYPEZONE 作为输入,并且必须提供生成的唯一 ID 作为输出。每次,我必须生成一个新 ID,我将读取给定 ID TYPEZONE 的最后生成,将数字部分增加 1 (XXXXX + 1),然后将新生成的 ID 保存在MongoDB 和 return 输出给用户。

此代码将 运行 在单个 NodeJS 服务器上,并且可以有多个客户端调用此方法 如果我只有 运行 单个服务器实例,是否有可能出现如下所述的竞争条件:

  1. 第一个客户端读取最后生成的 ID 为 USA2100000
  2. 第二个客户端将最后生成的 ID 读取为 USA2100000
  3. 第一个客户端生成新 ID 并将其保存为 USA2100001
  4. 第二个客户端生成新 ID 并将其保存为 USA2100001

由于有 2 个客户端生成了 ID,最终数据库应该有 USA2100002

为了克服这个问题,我正在使用 MongoDB 交易。我在 Typescript 中使用 Mongoose 作为 ODM 的代码是这样的:

      session = await startSession();
      session.startTransaction();
      lastId = await GeneratedId.findOne({ key: idKeyStr }, "value").value
      lastId = createNextId(lastId); 
      const newIdObj: any = {
        key: `Type:${idPrefix}_Zone:${zone_letter}`,
        value: lastId,
      };
      await GeneratedId.findOneAndUpdate({ key: idKeyStr }, newIdObj, {
        upsert: true,
        new: true,
      });
      await session.commitTransaction();
      session.endSession();
  1. 我想知道当我遇到这种情况时到底会发生什么 上述代码会发生这种情况吗?
  2. 第二个客户端的事务是否会抛出异常,我必须在我的代码中中止或重试该事务,还是它会自行处理重试?
  3. MongoDB或其他数据库如何处理事务? MongoDB是否锁定交易涉及的文件?是独占锁(甚至不允许其他客户端读取)吗?
  4. 如果同一个客户端一直未能提交其事务,则该客户端将被饿死。如何应对这种饥饿?

您正在使用 MongoDB 来存储 ID。这是一个状态。 ID 的生成是一个函数。当 mongodb 进程接受函数的参数和 returns 生成的 ID 时,您使用 Mongodb 生成 ID。这不是你在做什么。您正在使用 nodejs 生成 ID。

线程数,或者更确切地说,事件循环是至关重要的,因为它定义了体系结构,但无论哪种方式,您都不需要事务。 mongodb 中的事务被称为“多文档事务”,正是为了强调它们旨在一次对多个文档进行一致更新。 https://docs.mongodb.com/manual/core/transactions/ 的第一段警告您,如果您更新单个文档,则没有交易空间。

单线程应用程序不需要任何同步。您可以在启动时可靠地读取最新生成的 ID,并保证该 ID 在 nodejs 进程中是唯一的。如果您从生成函数中排除 mongodb 和其他 I/O ,您将使它同步,这样您就可以在 nodejs 进程中维护 ID 的状态并保证其唯一性。生成后,您可以异步保存在数据库中。在最坏的情况下,您可能会在序号中出现间隙但没有重复。

如果您极有可能需要扩展到超过 1 个 nodejs 进程以处理更多同时请求或添加另一台主机以实现冗余,您将需要同步 ID 的生成,您可以为此使用 Mongodb 个唯一索引。该函数本身并没有太大变化,您仍然像在单线程体系结构中一样生成 ID,但添加了一个额外的步骤以将 ID 保存到 mongo。该文档应该在 ID 字段上具有唯一索引,因此在并发更新的情况下,其中一个查询将成功添加文档,而另一个将失败并显示“E11000 重复键错误”。您在 nodejs 端捕获此类错误并再次重复该函数选择下一个数字:

这是您可以尝试的方法。您只需要在 GeneratedId 集合中存储 一个文档 。该文档将具有最后生成的 id 的值。该文档必须有一个已知的 _id 字段,例如假设它将是一个值为 1 的整数。所以,文档可以是这样的:

{ _id: 1, lastGeneratedId: "<some value>" }

在您的应用程序中,您可以使用带有过滤器 { _id: 1 }findOneAndUpdate() 方法;这意味着您的目标是一个文档更新。此更新将是一个 atomic 操作;根据 MongoDB 文档 “MongoDB 中的所有写入操作在单个文档级别上都是原子的。”。在这种情况下你需要交易吗?不。更新操作是原子的,比使用事务执行得更好。参见 Update Documents - Atomicity

那么,如何生成新生成的id并获取呢?

I will receive ID TYPE and ZONE...

使用上述输入值和现有的 lastGeneratedId 值,您可以得到新值并更新文档(使用新值)。可以在更新操作的聚合管道内计算/格式化新值 - 您可以使用功能 Updates with Aggregation Pipeline(这在 MongoDB v4.2 或更高版本中可用)。

当您使用更新选项 new: true 时,请注意findOneAndUpdate() 方法returns 更新(或修改)文档。此返回的文档将具有新生成的 lastGeneratedId 值。

更新方法可以如下所示(使用 NodeJS 驱动程序甚至 Mongoose):

const filter = { _id: 1 }
const update = [
    { $set: { lastGeneratedId: { // your calculation of new value goes here... } } }
]
const options = { new: true, projection: { _id: 0, lastGeneratedId: 1} }

const newId = await GeneratedId.findOneAndUpdate(filter,  update, options).['lastGeneratedId']

关于JavaScript函数的注意事项:

通过 MongoDB v4.4,您可以在聚合管道中使用 JavaScript 函数;这适用于带有聚合管道的更新。有关详细信息,请参阅 $function 聚合管道运算符。