Understanding Transactions in MongoDB (with examples)

Updated: February 3, 2024 By: Guest Contributor Post a comment

Introduction

Transactions are crucial for maintaining data integrity in applications that require operations involving multiple documents or collections to be atomic. In MongoDB, transactions were introduced in version 4.0, offering a way to perform multi-document read and write operations atomically. This means you can apply all operations successfully or rollback changes in case of an error, similar to traditional relational databases.

This tutorial will guide you through the concept of transactions in MongoDB with a variety of examples to facilitate understanding—from the basics to more advanced concepts.

Prerequisites

  • Familiarity with basic MongoDB operations and query language.
  • MongoDB server version 4.0 or later installed and running.
  • MongoDB driver or MongoDB Shell (mongosh) ready for operation.

Setting Up Your Environment

Before diving into transactions, make sure your MongoDB instance is running, and you are connected to it using either a MongoDB driver for your preferred programming language or the MongoDB Shell. Ensure that you are connecting to a replica set, as transactions are not supported on standalone instances.

# Connecting to a MongoDB replica set using the MongoDB Shell
mongosh "mongodb://localhost:27017/?replicaSet=myReplicaSet"

Basic Transactions

To start a basic transaction:

const session = db.getMongo().startSession();
session.startTransaction();
try {
    // Perform operations within the transaction
    session.getDatabase('myDatabase').collection('orders').insertOne({ item: 'book', quantity: 10 });
    session.getDatabase('myDatabase').collection('inventory').updateOne({ item: 'book' }, { $inc: { quantity: -10 } });
    // Commit the transaction
    session.commitTransaction();
} catch (error) {
    // Abort the transaction on any error
    session.abortTransaction();
    throw error;
} finally {
    // End the session
    session.endSession();
}

In the example above, we inserted an order into the ‘orders’ collection and adjusted the inventory in the ‘inventory’ collection. If anything goes wrong in either operation, the entire transaction is rolled back.

Handling Errors and Retrying

It’s important to handle errors correctly and implement a retry mechanism:

const executeTransaction = async (session) => {
    while (true) {
        try {
            // Start a new transaction
            session.startTransaction();
            // Perform desired operations within the transaction
            // ... (transactional code goes here)
            // Attempt to commit the transaction
            await session.commitTransaction();
            console.log('Transaction committed.');
            break; // Exit the loop on success
        } catch (error) {
            if (error.hasErrorLabel('TransientTransactionError')) {
                // Retry the whole transaction
                console.log('TransientTransactionError, retrying transaction ...');
                continue;
            } else {
                // Cannot retry the transaction
                console.error('Error during transaction, aborting ...');
                await session.abortTransaction();
                throw error;
            }
        }
    }
};

This piece of code demonstrates the best practice of handling a TransientTransactionError by retrying the transaction, while aborting it on other errors.

Advanced Transactions and Concurrency

With more advanced use cases, you may encounter concurrency and isolation issues. Let’s explore how to handle subsequent transactions that must be queued:

// A hypothetical function to queue transactions
const transactionQueue = [];
const processNextTransaction = async (session) => {
    if (transactionQueue.length === 0) return;
    const nextTransaction = transactionQueue.shift();
    await nextTransaction(session);
    await processNextTransaction(session);
};

This simple queue ensures that transactions are processed in order, avoiding conflicts between concurrent operations.

Real-world Use Cases

Oftentimes, financial applications call for complex transactions. The next example simulates transferring funds from one account to another:

const transferFunds = async (fromAccountId, toAccountId, amount) => {
    const session = db.getMongo().startSession();
    try {
        session.startTransaction();
        // Withdraw funds from the 'fromAccount'
        await session.getDatabase('finances').collection('accounts').updateOne({ _id: fromAccountId }, { $inc: { balance: -amount } });
        // Deposit amounts into the 'toAccount'
        await session.getDatabase('finances').collection('accounts').updateOne({ _id: toAccountId }, { $inc: { balance: amount } });
        await session.commitTransaction();
        console.log('Funds transferred successfully.');
    } catch (error) {
        console.error('Failed to transfer funds:', error);
        await session.abortTransaction();
    } finally {
        session.endSession();
    }
};

In this example, the ‘withdraw’ and ‘deposit’ operations must both succeed, or no funds are transferred at all, ensuring the atomicity of the transaction.

Conclusion

MongoDB transactions provide a powerful mechanism to ensure data integrity in complex applications. They allow developers to apply multiple operations atomically, and with careful error handling, your databases can maintain consistent state even in the event of unforeseen errors. By following the practices outlined in this tutorial, you will be better prepared to implement transactions safely and efficiently in your MongoDB-based applications.