Understanding locking within transactions (and how it deals with high concurrency)

TLDR: Do the locks created by operations that occur within mongodb transactions unlock immediately after the operation is complete or after the transaction is complete (committed/aborted)?

The background:

I’m using mongo to model the lobby state of a game round that clients are able to join and I would just like some clarification on how the use of transactions and correct data modeling can allow me to handle a high amount of concurrent requests while eliminating the risk of race conditions and maintaining game rules that are established.

My solution:

I created two collection schemas (using NodeJS’s mongoose) to handle the game scenario (one for the game itself and another for game participants) as well as a general player schema that stores all player data.

A simplified version of a game round looks something like:

{
  roundId: { type: Number, required: true, index: true },
  dateCreated: { type: Date, required: true, default: new Date() },
  gameState: { type: String, enum: ['open', 'closed'], required: true },
  winner: { type: String }, // only set after the game is complete
}

a simplified version of a game participant looks something like:

{
  roundId: { type: Number, required: true, index: true },
  username: { type: String, required: true },
  joins: [{
    xpWagered: { type: Number, required: true },
    joinTime: { type: Date, required: true, default: new Date() },
  }],
}

and lastly, a simplified version of a player looks like this:

{
  username: { type: String, required: true, unique: true },
  totalXp: { type: Number, required: true, default: 0 },
}

The game participants collection is used to prevent frequent write locks when users join from being solely allocated to the game collection, hence why I did not include an array field of game participants within the game collection itself. Instead, if a player joins the game, then only their game participant document will lock.

Within the game a player can join a game round (via the /join_game endpoint) up to four times and each time they join, we validate whether they have the xp to join and if they do it is decremented from their total xp. If a second unique player joins the game, a cron job starts and kicks off the game join timer loop and at any point in time and other participant can join the game lobby. Once the cron job is finished, it calls a /close_game endpoint which essentially sets the gameState inside the game round model to closed and no other players can join.

The db reads/writes within the /join_game endpoint occur within a mongo transaction. These operations include:

  • a .findOneAndUpdate() of a user to ensure that they have enough totalXp to join
  • an upsert-ed .findOneAndUpdate() of a game participant to ensure that they didn’t join more than four times
  • a .find() of all players within the game participants collection for the associated roundId to determine whether the game join timer cron job needs to be started
  • a .findOne() of the game round to ensure that the gameState isn’t closed (basically acting as a semaphore)

and if any of these fails, mongo will abort the transaction.

My questions/concerns:
I’m aware that mongo transactions are atomic in the sense that all transactions will either commit or roll back (which it’s why it’s useful to use the gameState lock/semaphore check at the end), but I was wondering if all documents within the transaction are locked for the duration of the semaphore? According to this article:

MongoDB does lock a document that is being modified by a transaction. However, other sessions that attempt to modify that document do not block. Rather, their transaction is aborted, and they are required to retry the transaction

So if a document is locked for the entire time of the transaction until it is committed/aborted and multiple people are attempting to hit the /join_game endpoint at once, then won’t concurrent transactions continuously lock each other out and constantly have to retry their transactions because the read lock of the .find() of all the current game participants will conflict with the write lock of the .findOneAndUpdate() of the individual game participant? And therefore, this should lead to some latency if many concurrent users are attempting to join.

Also won’t the findOne() of the current round document within the join_game endpoint therefore conflict with the findOneAndUpdate of the /close_game endpoint that sets the round to be closed? Could this potentially cause the /close_game endpoint to hang for a bit?

If this wasn’t the case (and operations within transactions only locked for the duration of the operation), then won’t there be race-condition issues for the .find() number of unique participants check where many concurrent calls to the /join_game endpoint can lead to an inaccurate start of the cron job (ex: multiple concurrent reads w/ the result of one user existing in the game round will result in the cron job being called multiple times).

I seem to be a little bit confused with how transaction locking exactly works, so any advice or information would be greatly appreciated. Thanks in advance!

Hi @Tom_S and welcome to the community!!

Firstly, I would really appreciate you spending time on posts with so much details.

However, to answer your question:

The locks in MongoDB are acquired at the transaction level. However only write operations will lock the associated document. Read operations will not lock them. Hence the locks will be released only when the transaction is completed.
For more reference on how transactions and locks work functions in MongoDB, would recommend following documentations for:

  1. Transactions
  2. FAQ: Concurrency
  3. How to Use MongoDB Transactions in Node.js

In terms of your concern about latency, I think it depends on the size of the workload you’re expecting, and the hardware that executes this workload. I agree though that using transactions will definitely have a performance impact, but depending on your workflow, it might be unavoidable (especially if you’re depending on mutating the state of more than one documents where the all mutations must succeed to be considered successful). In general, though, MongoDB works fastest with operations involving single documents.

Best Regards
Aasawari

1 Like