使用扩展节点的方法将用户匹配在一起进行 1v1 游戏。js/Express/MongoDB

Matching Users Together For a 1v1 Game Using a Method That Scales Node.js/Express/MongoDB

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

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

目前,这就是我的控制器功能 create/join 游戏的样子。

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 找到我的立足点。即使这确实有效,如果有更好的方法来实现我的目标,请告诉我。非常感谢您的帮助!

My understanding is that the findOneAndUpdate function offered by mongoose is atomic, and therefore prevents concurrent modification by multiple requests.

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

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

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

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

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


Even if this does work correctly, please let me know if there is a better way to accomplish my goal

同样,我不知道needsFill是否有其他含义,但如果它只是让你决定competitorID2是否被填充,你为什么不删除它而是检查如果 competitorID2null?

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

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

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

1.逻辑流程

  • 我是用户,我想和对手开始游戏。
  • 服务器将尝试查找我不玩的游戏,并且:
    • 创建游戏并等待对手
    • 将我添加到现有游戏中作为对手
  • 那你做事有自己的逻辑。

2。架构

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

{
    status: {type: String, unique: false, required: true},
    playerId: {type: String, unique: false, required: true},
    opponentId: {type: String, unique: false, required: false, default: null},
    gameType: {type: Number, unique: false, required: true},
}
  • 我用 status 字段替换了 needsFill 因为它会给你弹性,详情在下面的 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",
          description: "must be a string and is required",
        },
        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。安全

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

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

(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",
      gameType: req.body.gameType,
      playerId: req.body.user._id,
    });
  }

  // Next logic...
};

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

5.索引

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

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

6.可扩展性

既然您的应用程序可以完成大部分工作,您如何确保它的缩放比例合适?

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

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

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

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