How to Avoid Race Conditions in MongoDB with Redis Distributed Locks

Introduction

In concurrent systems where multiple operations can access the same resources simultaneously, race conditions can occur, leading to data inconsistencies and undesired outcomes. MongoDB, a popular NoSQL database, provides powerful capabilities for data storage, but it doesn’t inherently offer built-in locking mechanisms to ensure data consistency. In this article, we’ll explore how to leverage Redis, an in-memory data store, as a distributed lock to safeguard MongoDB read and write operations.

To illustrate this, we’ll consider a case study of students paying for hostel slots, where overbooking must be prevented, and students need to receive value for their payment or be issued a refund.

Understanding the Problem

In our case study, the hostel management system needs to handle multiple concurrent payment requests from students. The challenge lies in ensuring that a student can reserve a hostel slot only if it’s available and that overbooking is prevented. Additionally, we must guarantee that students either secure their slots and receive value for their payment or get a refund in case of a race condition during the reservation process.

The Solution: Redis Distributed Locks

The concept of using a lock in concurrent systems is to ensure exclusive access to a shared resource. When multiple processes or threads attempt to access and modify the same resource simultaneously, it can lead to race conditions, data inconsistencies, and incorrect results. To prevent this, locks are used to synchronize access and provide mutual exclusion.

Here’s how the lock concept helps in this scenario:

  1. Acquiring the Lock: When a student initiates the reservation process for a specific room, they first need to “acquire a lock” associated with that room. Acquiring the lock ensures that no other student can simultaneously modify the same room.

  2. Preventing Concurrent Modifications: Once a lock is acquired, it acts as a guard, preventing other students from acquiring the same lock and modifying the room simultaneously. If another student tries to acquire the lock while it is already held, they will have to wait until the lock is released.

  3. Ensuring Exclusive Access: By allowing only one student to hold the lock at a time, we guarantee exclusive access to the shared resource (the hostel room). This prevents race conditions and maintains data consistency.

  4. Releasing the Lock: After the student completes the reservation process and updates the necessary data (such as increasing the occupied slot count and associating the student with the room), they release the lock. Releasing the lock allows other students to acquire the lock and proceed with their reservation requests.

Implementing the Solution

To implement the solution, we’ll utilize Node.js, MongoDB, and the ioredis library, which provides Redis client functionality. Here are the key steps involved:

1. Setting up Redis: Begin by installing Redis and starting the Redis server. We’ll use the ioredis library for interacting with Redis from our Node.js application.

2. Modeling the Hostel Slots: Create a MongoDB schema for the hostel slots, representing the available slots and their occupancy status. The schema should include the room number, maximum capacity, current occupancy count, and any additional relevant fields.

3. Acquiring the Redis Lock: When a student initiates the reservation process, we’ll acquire a Redis lock corresponding to the hostel room being reserved. This ensures that only one reservation can occur at a time for a particular room.

4. Checking the Availability: Before allowing a reservation, check if the hostel room has available slots by querying the MongoDB database. If all slots are occupied, the student should be notified and offered a refund.

5. Updating the Slot Occupancy: If the room has available slots, update the MongoDB document to reflect the reservation. Increase the occupancy count and mark the slot as occupied.

6. Releasing the Redis Lock: After completing the reservation process and updating the MongoDB document, release the acquired Redis lock. This allows subsequent reservation requests to proceed.

Code Examples

  • redisConfig.js: This file contains the configuration for the Redis client using ioredis.
const Redis = require(‘ioredis’);
const redisClient = new Redis({
 host: ‘localhost’,
 port: 6379,
});
module.exports = redisClient;
  • roomModel.js : This file contains the room schema
const mongoose = require('mongoose');

const roomSchema = new mongoose.Schema({
  roomNumber: {
    type: String,
    required: true,
  },
  maxCapacity: {
    type: Number,
    required: true,
  },
  occupiedSlots: {
    type: Number,
    required: true,
    default: 0,
  },
});

const Room = mongoose.model('Room', roomSchema);
module.exports = Room;
  • studentModel.js : This file contains the student schema
const studentSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
  },
  reservedRoom: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Room',
    default: null,
  },
});

const Student = mongoose.model('Student', studentSchema);

module.exports = Student
  • reservationService.js: This file demonstrates the reservation process using Redis locks.
const redisClient = require('./redisConfig');
const Room = require('./roomModel');
const Student = require('./studentModel');

async function acquireLock(lockKey, lockValue, lockDuration, retryCount = 3) {
  while (retryCount > 0) {
    try {
      const lockAcquired = await redisClient.set(lockKey, lockValue, 'PX', lockDuration, 'NX');
      if (lockAcquired) {
        return true;
      }
    } catch (error) {
      console.error('Failed to acquire lock on hostel room', error);
    }
    retryCount--;
  }
  return false;
}

async function reserveHostelSlot(roomId, studentId) {
  const lockKey = `room:${roomId}`;
  const lockValue = Date.now().toString();
  const lockDuration = 10000; // 10 seconds

  let lockAcquired = false;
  let room;
  try {
    lockAcquired = await acquireLock(lockKey, lockValue, lockDuration);
    if (!lockAcquired) {
      throw new Error('Failed to acquire lock on hostel room');
    }

    room = await Room.findById(roomId);
    if (!room) {
      throw new Error('Hostel room not found');
    }

    if (room.occupiedSlots === room.maxCapacity) {
      throw new Error('No available slots in the hostel room');
    }

    room.occupiedSlots += 1;
    await room.save();

    const student = await Student.findById(studentId);
    if (!student) {
      throw new Error('Student not found');
    }

    if (student.reservedRoom) {
      throw new Error('Student already has a reserved room');
    }

    student.reservedRoom = room._id;
    await student.save();

    return {
      message: 'Hostel room reserved successfully',
      room: room.roomNumber,
      student: student.name,
    };
  } catch (error) {
    console.error(error);
    if (room && room.occupiedSlots > 0) {
      room.occupiedSlots -= 1;
      await room.save();
    }
    throw error;
  } finally {
    if (lockAcquired) {
      try {
        await redisClient.eval(
          `
            if redis.call("get", KEYS[1]) == ARGV[1] then
              return redis.call("del", KEYS[1])
            else
              return 0
            end
          `,
          1,
          lockKey,
          lockValue,
        );
      } catch (releaseError) {
        console.error('Failed to release lock on hostel room', releaseError);
      }
    }
  }
}
  1. Acquiring the Redis Lock:
  • The reserveHostelSlot function begins by acquiring a Redis lock for the specified hostel room using the redisClient.set command.

  • The lock is acquired by setting a unique lock key (lockKey) and a corresponding lock value (lockValue).

  • The lock has a specified duration (lockDuration) after which it will automatically expire.

  • If the lock acquisition fails (indicating that another process already holds the lock), an error is thrown.

2. Checking the Availability of Slots:

  • The function checks the availability of slots in the MongoDB database by querying the Room collection using Room.findById.

  • If the room is not found, an error is thrown.

  • If all slots in the room are occupied (room.occupiedSlots equals room.maxCapacity), an error is thrown.

3. Updating Slot Occupancy and Reserving the Room:

  • If the room has available slots, the room.occupiedSlots count is increased, indicating that a new slot has been reserved.

  • The updated room occupancy is saved to the database using room.save().

  • The function then processes the payment and reservation logic specific to your system, which could involve associating the student with the reserved room and updating payment details.

4. Releasing the Redis Lock:

  • After completing the reservation process and updating the necessary data, the acquired Redis lock is released using the redisClient.eval command.

  • The lock release is conditional and only occurs if the lock value stored in Redis matches the value associated with the lock key.

  • If the lock release fails, an error is thrown.

5. Handling Errors:

  • In case of any errors during the reservation process, the Redis lock is released using a similar redisClient.eval command.

  • This ensures that the lock is always released, even if an error occurs during the reservation process.

Conclusion

Using Redis as a lock mechanism in conjunction with MongoDB allows us to handle concurrent operations efficiently while maintaining data integrity. However, it’s crucial to consider the performance implications and optimize the lock duration and usage to minimize potential delays.

By employing distributed locks, we can ensure that hostel slot reservations are processed correctly, providing a seamless and fair experience for the students and maintaining the integrity of the reservation system.