使用 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 TYPE
和 ZONE
作为输入,并且必须提供生成的唯一 ID 作为输出。每次,我必须生成一个新 ID,我将读取给定 ID TYPE
和 ZONE
的最后生成,将数字部分增加 1 (XXXXX + 1),然后将新生成的 ID 保存在MongoDB 和 return 输出给用户。
此代码将 运行 在单个 NodeJS 服务器上,并且可以有多个客户端调用此方法
如果我只有 运行 单个服务器实例,是否有可能出现如下所述的竞争条件:
- 第一个客户端读取最后生成的 ID 为
USA2100000
- 第二个客户端将最后生成的 ID 读取为
USA2100000
- 第一个客户端生成新 ID 并将其保存为
USA2100001
- 第二个客户端生成新 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();
- 我想知道当我遇到这种情况时到底会发生什么
上述代码会发生这种情况吗?
- 第二个客户端的事务是否会抛出异常,我必须在我的代码中中止或重试该事务,还是它会自行处理重试?
- MongoDB或其他数据库如何处理事务? MongoDB是否锁定交易涉及的文件?是独占锁(甚至不允许其他客户端读取)吗?
- 如果同一个客户端一直未能提交其事务,则该客户端将被饿死。如何应对这种饥饿?
您正在使用 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 聚合管道运算符。
我正在使用 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 TYPE
和 ZONE
作为输入,并且必须提供生成的唯一 ID 作为输出。每次,我必须生成一个新 ID,我将读取给定 ID TYPE
和 ZONE
的最后生成,将数字部分增加 1 (XXXXX + 1),然后将新生成的 ID 保存在MongoDB 和 return 输出给用户。
此代码将 运行 在单个 NodeJS 服务器上,并且可以有多个客户端调用此方法 如果我只有 运行 单个服务器实例,是否有可能出现如下所述的竞争条件:
- 第一个客户端读取最后生成的 ID 为
USA2100000
- 第二个客户端将最后生成的 ID 读取为
USA2100000
- 第一个客户端生成新 ID 并将其保存为
USA2100001
- 第二个客户端生成新 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();
- 我想知道当我遇到这种情况时到底会发生什么 上述代码会发生这种情况吗?
- 第二个客户端的事务是否会抛出异常,我必须在我的代码中中止或重试该事务,还是它会自行处理重试?
- MongoDB或其他数据库如何处理事务? MongoDB是否锁定交易涉及的文件?是独占锁(甚至不允许其他客户端读取)吗?
- 如果同一个客户端一直未能提交其事务,则该客户端将被饿死。如何应对这种饥饿?
您正在使用 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 聚合管道运算符。