MongoDB Transactions: Multi-Document ACID
• MongoDB transactions provide ACID guarantees across multiple documents and collections since version 4.0, eliminating the need for application-level compensating transactions in complex operations
Key Insights
• MongoDB transactions provide ACID guarantees across multiple documents and collections since version 4.0, eliminating the need for application-level compensating transactions in complex operations • Transaction performance degrades with document contention and long-running operations—design your schema to minimize cross-document updates and keep transactions under 60 seconds • Replica sets require specific configuration (write concern majority, read concern snapshot) and proper error handling with retry logic to handle transient failures like TransientTransactionError
Why Multi-Document Transactions Matter
MongoDB’s document model typically allows you to embed related data within a single document, making many operations atomic by default. However, real-world applications often require coordinated updates across multiple documents or collections—transferring funds between accounts, maintaining inventory across warehouses, or updating user profiles alongside audit logs.
Before MongoDB 4.0, developers implemented application-level compensating transactions or two-phase commits. These patterns added complexity and couldn’t guarantee true ACID properties. Native transactions eliminate this burden while providing snapshot isolation and all-or-nothing execution guarantees.
Setting Up Your Replica Set
Transactions require a replica set configuration, even for development. Here’s a minimal setup using Docker Compose:
version: '3.8'
services:
mongo1:
image: mongo:7.0
command: mongod --replSet rs0 --port 27017
ports:
- "27017:27017"
volumes:
- mongo1_data:/data/db
networks:
- mongo-cluster
volumes:
mongo1_data:
networks:
mongo-cluster:
Initialize the replica set:
// Connect to MongoDB and run in mongosh
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "localhost:27017" }
]
})
Basic Transaction Implementation
Here’s a practical example of a banking transfer using the Node.js driver:
const { MongoClient } = require('mongodb');
async function transferFunds(fromAccount, toAccount, amount) {
const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
try {
await client.connect();
const session = client.startSession();
try {
await session.withTransaction(async () => {
const db = client.db('banking');
const accounts = db.collection('accounts');
// Debit source account
const debitResult = await accounts.updateOne(
{
accountId: fromAccount,
balance: { $gte: amount }
},
{ $inc: { balance: -amount } },
{ session }
);
if (debitResult.matchedCount === 0) {
throw new Error('Insufficient funds or account not found');
}
// Credit destination account
await accounts.updateOne(
{ accountId: toAccount },
{ $inc: { balance: amount } },
{ session }
);
// Record transaction
await db.collection('transactions').insertOne({
from: fromAccount,
to: toAccount,
amount: amount,
timestamp: new Date()
}, { session });
}, {
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
readPreference: 'primary'
});
console.log('Transfer completed successfully');
} finally {
await session.endSession();
}
} finally {
await client.close();
}
}
Handling Transaction Errors
Transactions can fail due to write conflicts, network issues, or primary elections. Implement proper retry logic:
async function executeWithRetry(transactionFunc, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
return await transactionFunc();
} catch (error) {
attempt++;
// Retry on transient errors
if (error.hasErrorLabel('TransientTransactionError') &&
attempt < maxRetries) {
console.log(`Retrying transaction, attempt ${attempt}`);
await new Promise(resolve => setTimeout(resolve, 100 * attempt));
continue;
}
// Retry commit on unknown commit result
if (error.hasErrorLabel('UnknownTransactionCommitResult') &&
attempt < maxRetries) {
console.log('Unknown commit result, retrying');
await new Promise(resolve => setTimeout(resolve, 100 * attempt));
continue;
}
throw error;
}
}
}
// Usage
await executeWithRetry(() => transferFunds('ACC001', 'ACC002', 100));
Cross-Collection Inventory Management
A more complex example demonstrating transactions across multiple collections:
async function processOrder(orderId, items) {
const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
await client.connect();
const session = client.startSession();
try {
const result = await session.withTransaction(async () => {
const db = client.db('ecommerce');
const orders = db.collection('orders');
const inventory = db.collection('inventory');
const reservations = db.collection('reservations');
// Validate and reserve inventory
for (const item of items) {
const stock = await inventory.findOne(
{ sku: item.sku },
{ session }
);
if (!stock || stock.available < item.quantity) {
throw new Error(`Insufficient stock for SKU: ${item.sku}`);
}
// Decrement available inventory
await inventory.updateOne(
{ sku: item.sku },
{
$inc: {
available: -item.quantity,
reserved: item.quantity
}
},
{ session }
);
// Create reservation record
await reservations.insertOne({
orderId: orderId,
sku: item.sku,
quantity: item.quantity,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
}, { session });
}
// Create order
await orders.insertOne({
_id: orderId,
items: items,
status: 'pending',
createdAt: new Date()
}, { session });
return { orderId, status: 'reserved' };
});
return result;
} finally {
await session.endSession();
await client.close();
}
}
Performance Considerations
Transaction performance depends on several factors. Keep these guidelines in mind:
// BAD: Long-running transaction with external API call
await session.withTransaction(async () => {
await collection.updateOne({...}, {...}, { session });
await fetch('https://external-api.com/notify'); // Don't do this
await collection.insertOne({...}, { session });
});
// GOOD: Keep transactions focused on database operations
const result = await session.withTransaction(async () => {
await collection.updateOne({...}, {...}, { session });
await collection.insertOne({...}, { session });
return { success: true };
});
// Make external calls after transaction commits
if (result.success) {
await fetch('https://external-api.com/notify');
}
Monitor transaction metrics:
// Enable profiling to track slow transactions
db.setProfilingLevel(1, { slowms: 100 });
// Query slow transactions
db.system.profile.find({
'command.lsid': { $exists: true },
millis: { $gt: 100 }
}).sort({ ts: -1 }).limit(10);
Transaction Limitations and Workarounds
MongoDB transactions have specific constraints you must design around:
16MB document size limit: If your transaction modifies documents that together exceed 16MB, split the operation or redesign your schema.
60-second default timeout: Configure transactionLifetimeLimitSeconds if needed, but prefer shorter transactions.
No DDL operations: You cannot create collections or indexes inside transactions. Create them beforehand:
async function ensureCollectionsExist(db) {
const collections = await db.listCollections().toArray();
const collectionNames = collections.map(c => c.name);
if (!collectionNames.includes('accounts')) {
await db.createCollection('accounts');
}
if (!collectionNames.includes('transactions')) {
await db.createCollection('transactions');
}
}
// Call before starting transactions
await ensureCollectionsExist(db);
Monitoring Transaction Health
Implement observability for transaction performance:
async function monitoredTransaction(transactionFunc, metadata = {}) {
const startTime = Date.now();
const session = client.startSession();
try {
const result = await session.withTransaction(transactionFunc);
const duration = Date.now() - startTime;
console.log('Transaction succeeded', {
...metadata,
duration,
sessionId: session.id
});
return result;
} catch (error) {
const duration = Date.now() - startTime;
console.error('Transaction failed', {
...metadata,
duration,
error: error.message,
errorLabels: error.errorLabels
});
throw error;
} finally {
await session.endSession();
}
}
Multi-document transactions in MongoDB provide the ACID guarantees required for complex business operations while maintaining the flexibility of the document model. Design your schema to minimize cross-document updates, implement robust retry logic, and monitor transaction performance to build reliable distributed systems.