Reputation: 521
Recently I have been working on a game that automatically matches two users together to play against one another if they are both trying to join a game with the same parameters. This matching is not concurrent, meaning that both users do not have to be online at the same time to match them. I am using MongoDB to store the games, which have a schema that looks like this.
{
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}
}
Currently, this is what my controller function to create/join a game looks like.
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
}
});
}
My question is: will this scale to a large number of users concurrently using the system and trying to join a game? My understanding is that the
findOneAndUpdate
function offered by mongoose is atomic, and therefore prevents concurrent modification by multiple requests. Forgive me if my assumptions are not good, I'm still trying to find my footing using MongoDB. Even if this does work correctly, please let me know if there is a better way to accomplish my goal. Thank you so much in advance for any help!
Upvotes: 2
Views: 243
Reputation: 8441
The short response will be:
yes, it will scale because as you said, you are using an atomic operation.
However, how to ensure that the performance stays the same if you have 200 users or 200 millions. Let's have a look on differents aspect of your app:
1. Logic Flow
2. Schema
I believe that simplicity is key, so if you're doing a 1v1 game >
{
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},
}
4.requests
below.players
instead of competitorID1
, competitorID2
, ...
parameter
seems too large for a name and we don't really understand the meaning of it, I assume you meant the type of the game.Also, you can implement a schema validation in mongo to maintain your data consistency 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. Security
Getting data from your app that you use to query/insert data in your db is dangerous. You cannot trust the request object. What If I send a number or object? It might break your app.
So always validate your data.
(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. Requests
I prefer to use async await because I find it clearer in the code rather than having many logic subfunctions.
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...
};
The status will help you cover more aspect of your game: when it's awaiting, playing, finished, ... You will only need one index to get stats on those quickly.
I recommend to improve your schema later by adding attributes like: startDate, finishDate, ...
5. Indexes
To ensure that your query will not consume all resources of your cluster, create an index based on the queries you do. In the above example:
db.game.createIndex({status:1, gameType:1, playerId:1},{background:true})
6. Scalability
Now that you're app can do most of the job, how can you ensure that is scale properly?
You will need to ensure that your app is scalable to handle your requests. It depends on your infrastructure resources and type (serverless, your own instance, ...). Use the different networks metrics to adjust the ram & cpu you need.
You will also need to ensure that your Mongo server can be scaled by using a cluster at first, automatically scale your cluster depending on your resources uage.
You can scale horizontally (adding more machines) or vertically (adding more resources)
Many more points can be covered to improve the speed & scalability (cache, sharding,...) but I assume that the above is enough to let you start.
Upvotes: 1
Reputation: 6056
My understanding is that the
findOneAndUpdate
function offered by mongoose is atomic, and therefore prevents concurrent modification by multiple requests.
Your understanding is correct, findOneAndUpdate
is a single-document operation, which means that the changes are applied atomically and the client will never see a partially updated document.
if you have deployed your application on a standalone mongod
server, you are 99.99% safe. On the other hand, if you have set up a replica set, your customer could experience an interesting thing. Suppose you are on a 5 members replica set (1 primary and 4 secondaries). At some point there is a network partition
There is a small time frame when 2 members transiently believe to be primaries, in this case Pold
and Pnew
. It is possible with a weak write concern like w: 1
that the old Primary (Pold
) is the only one with the updated document. If during that small time frame, a new findOneAndUpdate
for that same document reaches Pnew
, then 2 nodes have inconsistent views of the same game match.
Nonetheless, after the connection between partitions is restored, Pold
changes are rolled back and the database returns to a consistent state with Pnew
as the new primary. To mitigate this rare scenario you could associate the updation to a ClientSession
with write concern w: majority
, instead of the default one (1
).
You did not provide any information on how you designed the architecture of your project, but I would say that you do not actually need this additional complexity.
Even if this does work correctly, please let me know if there is a better way to accomplish my goal
Again, I do not know if needsFill
has another meaning, but if it is just there to let you decide if competitorID2
is filled, why don't you delete it and instead check if competitorID2
is null
?
Upvotes: 1