使用可扩展 Node.js/Express/MongoDB 的方法为 1v1 游戏匹配用户

问题描述

最近我一直在开发一款游戏,如果他们都试图加入具有相同参数的游戏,该游戏会自动将两个用户匹配到一起进行对战。这种匹配不是并发的,这意味着两个用户不必同时在线来匹配它们。我正在使用 MongoDB 来存储游戏,其架构如下所示。

    {
        competitorID1: {type: String,unique: false,required: true},competitorID2: {type: String,required: false,default: null},needsFill: {type: Boolean,required: true,default: true},parameter: {type: Number,required: true}
    }

目前,这就是我创建/加入游戏的控制器功能的样子。

exports.join = (req,res) => {
            Game.findOneAndUpdate({
                needsFill: true,parameter: req.body.parameter,competitorID1: {$ne: req.user._id}
            },{
                // Add the second competitor
                needsFill: false,competitorID2: req.user._id,}).then(contest => {
             if(contest) {
                 // Game was found and updated to include two competitors
             }
             else {
                 // Game with parameter was not found. Create a new game with needsFill set to true and only the competitorID1 populated
             }
        });
}

我的问题是:这是否会扩展到同时使用系统并尝试加入游戏的大量用户?我的理解是

findOneAndUpdate

mongoose 提供的函数是原子的,因此可以防止多个请求并发修改。如果我的假设不好,请原谅我,我仍在尝试使用 MongoDB 找到自己的立足点。即使这确实工作正常,请告诉我是否有更好的方法来实现我的目标。非常感谢您提供任何帮助!

解决方法

我的理解是 findOneAndUpdate 函数由 猫鼬是原子的,因此防止并发修改 多个请求。

您的理解是正确的,findOneAndUpdate 是单文档操作,这意味着更改是原子应用的,客户端永远不会看到部分更新的文档。

如果您已将应用程序部署在独立的 mongod 服务器上,那么您的安全率为 99.99%。另一方面,如果您设置了副本集,您的客户可能会体验到一件有趣的事情。假设您在 5 个成员的副本集上(1 个主副本和 4 个辅助副本)。在某些时候有一个网络分区

Network partition on replica set

在很短的时间内,有 2 名成员暂时认为是初选,在本例中为 PoldPnew。对于像 w: 1 这样的弱写入问题,旧的 Primary (Pold) 可能是唯一具有更新文档的主节点。如果在那个短时间范围内,同一文档的新 findOneAndUpdate 到达 Pnew,则 2 个节点对同一场比赛的看法不一致。

尽管如此,在恢复分区之间的连接后,Pold 更改会回滚,数据库返回到以 Pnew 作为新主数据库的一致状态。为了缓解这种罕见的情况,您可以将更新与具有写关注 ClientSessionw: majority 相关联,而不是默认的 (1)。

您没有提供有关您如何设计项目架构的任何信息,但我想说您实际上并不需要这种额外的复杂性。


即使这可以正常工作,请告诉我是否有 实现目标的更好方法

同样,我不知道 needsFill 是否还有其他含义,但如果它只是让您决定是否填充 competitorID2,为什么不删除它而是检查 competitorID2 {1}} 是 null

,

简短的回复将是:
是的,它会扩展,因为正如您所说,您正在使用原子操作。

但是,如果您有 200 个用户或 2 亿用户,如何确保性能保持不变。 让我们来看看您的应用的不同方面:

  1. 逻辑流程
  2. 架构
  3. 安全
  4. 请求
  5. 索引
  6. 可扩展性

1.逻辑流程

  • 我是一名用户,我想与对手开始一场比赛。
  • 服务器将尝试查找我未玩的游戏,并且:
    • 创建游戏并等待对手
    • 在现有游戏中添加我作为对手
  • 然后你有自己的逻辑来做某事。

2.架构

我相信简单是关键,所以如果你在玩 1v1 游戏 >

{
    status: {type: String,unique: false,required: true},playerId: {type: String,opponentId: {type: String,required: false,default: null},gameType: {type: Number,}
  • 我用 status 字段替换了 NeedFill ,因为它会给你带来弹性,详情见下面的 4.requests
  • 我也修改了命名。我相信,如果你想用你的逻辑让游戏有更多的用户,你会使用players的数组而不是competitorID1competitorID2...
  • 我将参数重命名为 gameType,因为 parameter 对于名称来说似乎太大了,而且我们并不真正理解它的含义,我假设您指的是游戏类型。

此外,您可以在 mongo 中实现模式验证以保持数据一致性 https://docs.mongodb.com/manual/core/schema-validation/

db.runCommand({
  collMod: "game",validator: {
    $jsonSchema: {
      bsonType: "object",required: ["status","playerId","gameType"],properties: {
        status: {
          bsonType: "string",description: "must be a string and is required",},playerId: {
          bsonType: "string",opponentId: {
          bsonType: "string",description: "must be a string",gameType: {
          bsonType: "int",minimum: 1,maximum: 3,description: "must be an integer in [ 1,3 ] and is required",});

3.安全

从您的应用中获取用于在数据库中查询/插入数据的数据是危险的。您不能信任请求对象。如果我发送一个数字或对象怎么办?它可能会破坏您的应用。

因此请始终验证您的数据。

(req,res) => {
  // You should have a validator type on top of your controller
  // Something that will act like this
  if (!req.body?.user?._id || typeof req.body.user._id !== "string") {
    throw new Error("user._id must be defined and a type string");
  }
  if (!req.body?.gameType || typeof req.body.gameType !== "number") {
    throw new Error("gameType must be defined and a type number");
  }
};

4.请求

我更喜欢使用 async await 因为我发现它在代码中更清晰,而不是有很多逻辑子功能。

async (req,res) => {
  let game = await Game.findOneAndUpdate(
    {
      status: "awaiting",gameType: req.body.gameType,playerId: { $ne: req.body.user._id },{
      $set: {
        status: "ready",opponentId: req.user._id,{
      upsert: true,}
  );

  // Create the game if it doesn't exist
  if (!game) {
    game = await Game.insertOne({
      status: "awaiting",playerId: req.body.user._id,});
  }

  // Next logic...
};

状态将帮助您涵盖游戏的更多方面:等待、播放、完成……您只需要一个索引即可快速获取这些统计信息。
我建议稍后通过添加以下属性来改进您的架构:startDate、finishDate、...

5.索引

为确保您的查询不会消耗集群的所有资源,请根据您执行的查询创建索引。 在上面的例子中:

db.game.createIndex({status:1,gameType:1,playerId:1},{background:true})

6.可扩展性

既然您的应用可以完成大部分工作,那么您如何确保可以正确扩展?

您需要确保您的应用可扩展以处理您的请求。这取决于您的基础设施资源和类型(无服务器、您自己的实例,...)。使用不同的网络指标来调整您需要的 ram 和 cpu。

首先您还需要确保您的 Mongo 服务器可以通过使用集群进行扩展,根据您的资源使用情况自动扩展您的集群。

您可以水平扩展(添加更多机器)或垂直扩展(添加更多资源)

可以涵盖更多点以提高速度和可扩展性(缓存、分片等),但我认为以上内容足以让您开始。