Transaction Processing: ACID and Multi-Document Consistency
Transactions ensure the atomicity of multi-document operations—mastering this concept enables the development of reliable financial and order systems.
1. What You'll Learn
- MongoDB 4.0+ Multi-Document Transactions
- session.startTransaction / commitTransaction
- ACID Properties
- readConcern / writeConcern / readPreference
- Causal Consistency
- Mongoose Transaction Wrappers
sequenceDiagram
participant App as Applications
participant DB as MongoDB<br/>Dungeon Collection
participant Log as Log
App->>DB: startTransaction()
activate DB
DB-->>App: session
App->>DB: Deduct $100
DB-->>App: OK
App->>DB: Add $100
DB-->>App: OK
App->>DB: Create a Transaction Log
DB-->>App: OK
alt All successful
App->>DB: commitTransaction()
DB-->>App: ✅ Committed
DB->>Log: Persistence
else Any failure
App->>DB: abortTransaction()
DB-->>App: ❌ Rollback
Note over DB: All changes have been reversed<br/>Data is rolled back to the state before the transaction
end
deactivate DB
2. Why Are Transactions Necessary?
Concept Explanation: A transaction is a logical unit of database operations that ensures all operations within it either succeed entirely or are rolled back entirely. When writing to multiple documents or collections (such as in fund transfers or order placements), data consistency cannot be guaranteed without transactions—partial success and partial failure would result in dirty data.
How It Works: MongoDB 4.0 and later support multi-document ACID transactions, implemented using snapshot isolation based on the WiredTiger engine. When a transaction begins, a snapshot is created, and all read and write operations are performed relative to that snapshot; upon commit, changes are atomically applied to the data files, and upon rollback, all changes are discarded. Under the hood, transactions rely on the replica set’s oplog to ensure persistence and replication.
A Detailed Explanation of the ACID Principles:
| Feature | Meaning | MongoDB Implementation | Principle |
|---|---|---|---|
| Atomicity | Transactions either succeed entirely or fail entirely | commit / abort | WiredTiger ensures this through the rollback log: atomic writes during commit, and recovery using the rollback log during rollback |
| Consistency | Data integrity constraints remain unchanged | Schema validation + transaction constraints | All constraints are checked before a transaction is committed; if any are violated, the transaction is rejected |
| Isolation | Concurrent transactions do not interfere with one another | Snapshot Isolation | A data snapshot is taken at the start of a transaction; all reads and writes are based on that snapshot throughout the transaction, and the transaction is not affected by other transactions |
| Durability | Permanently stored after transaction commit | Journal + replica set oplog | Writes are first recorded in the journal (WAL), then replicated to a majority of nodes in the replica set |
MVCC Mechanism: MongoDB implements snapshot isolation through Multi-Version Concurrency Control (MVCC). Each document maintains multiple historical versions; transactions read data from the version corresponding to their start timestamp, and write operations create new versions without overwriting the old ones. The new version becomes visible to other transactions only upon commit.
graph TB
subgraph "MVCC Multiple Versions"
D1["Document v1<br/>balance: 1000"]
D2["Document v2<br/>balance: 900<br/>(Transaction A Edit)"]
D3["Document v3<br/>balance: 1100<br/>(Transaction B Edit)"]
end
subgraph "Transaction Snapshot Read"
T1["TransactionsA (t1)<br/>Read v1"] --> R1["balance: 1000"]
T2["TransactionsB (t2)<br/>Read v1"] --> R2["balance: 1000"]
end
D1 --> D2
D1 --> D3
style D2 fill:#cce5ff
style D3 fill:#d4edda
Use Cases:
- Financial Transfers (Withdrawals and Deposits Must Be Atomic)
- E-commerce Order Placement (Order + Inventory Deduction + Balance Deduction + Log)
- Multi-table join update (synchronizing the user and role tables)
- Not suitable for: Single-document operations (MongoDB single documents are atomic by nature), high-frequency short transactions (transactions incur significant overhead)
// ❌ Counterexample: Transfer without a transaction
async function transfer(fromUserId, toUserId, amount) {
await User.updateOne({ _id: fromUserId }, { $inc: { balance: -amount } });
// System Crash!
await User.updateOne({ _id: toUserId }, { $inc: { balance: amount } });
// The user's balance was deducted, but the recipient did not receive the payment
}
3. Basic Usage of Transactions
Concept Explanation: MongoDB transactions are managed through Session objects—startSession() creates a session, startTransaction() starts a transaction, commitTransaction() commits, and abortTransaction() rolls back. All operations within a transaction must be passed as { session } parameters.
How It Works: The complete lifecycle of a transaction is as follows: start session → start transaction → execute operations (with session parameters) → commit/rollback → end session. Upon commit, WiredTiger atomically writes all changes to the journal; upon rollback, it reverts all changes using the rollback log. The default transaction timeout is 60 seconds; transactions automatically roll back if they time out.
Transaction Lifecycle:
stateDiagram-v2
[*] --> StartSession: startSession()
StartSession --> Active: startTransaction()
Active --> Active: Perform an action (with session)
Active --> Committed: commitTransaction()
Active --> Aborted: abortTransaction()
Committed --> [*]: endSession()
Aborted --> [*]: endSession()
note right of Active: Default 60s automatic rollback on timeout
note right of Committed: Persist changes to journal
Grammar Rules:
| Step | Method | Description |
|---|---|---|
| 1 | startSession() |
Create a session |
| 2 | startTransaction() |
Start Transaction |
| 3 | Operation + {session} |
All read and write operations must pass the session |
| 4a | commitTransaction() |
All successful → Submit |
| 4b | abortTransaction() |
Any failure → Rollback |
| 5 | endSession() |
Release session resources |
// === MongoDB 4.0+ Multi-document transactions ===
const session = db.getMongo().startSession();
session.startTransaction();
try {
// 1. Deduct
db.users.updateOne(
{ _id: fromUserId },
{ $inc: { balance: -amount } },
{ session }
);
// 2. Add
db.users.updateOne(
{ _id: toUserId },
{ $inc: { balance: amount } },
{ session }
);
// 3. Commit Transaction
await session.commitTransaction();
} catch (err) {
// 4. Rollback
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
Key Points Analysis:
- The operation to forget the transmission of
{ session }is not part of a transaction and is not protected by the transaction. commitTransactionandabortTransactionare idempotent operations; calling them repeatedly will not result in an error.- Transactions are automatically rolled back upon timeout; the application layer should set a reasonable timeout and implement retry logic.
4. ACID Properties
Concept Overview: ACID refers to the four core guarantees of database transactions—Atomicity, Consistency, Isolation, and Durability. Understanding how ACID is implemented in MongoDB is the foundation for designing a reliable transactional system.
Detailed Explanation of Isolation Levels: MongoDB supports three read isolation levels, which are controlled via readConcern:
| Isolation Level | readConcern | Behavior | Use Case |
|---|---|---|---|
| Read unsubmitted | local |
Read latest local data (may roll back) | Default, performance-first |
| Read Committed | majority |
Read data that has been confirmed by the majority | Strong consistency requirement |
| Snapshot Isolation | snapshot |
In-Transaction Read-Consistent Snapshot | In-Transaction Default |
sequenceDiagram
participant T1 as Transactions1
participant T2 as Transactions2
participant DB as MongoDB
Note over DB: Initial balance=1000
T1->>DB: startTransaction(readConcern: snapshot)
T1->>DB: Read balance → 1000
T2->>DB: startTransaction()
T2->>DB: balance -100 → Write 900
T2->>DB: commitTransaction()
T1->>DB: Read balance → 1000 (Snapshot isolation, can't see T2 changes)
Note over T1: Snapshots ensure intra-transaction consistency
T1->>DB: commitTransaction()
Note over DB: Conflict detection → If T1 also modifies balance, an error will be reported
| Feature | Meaning | MongoDB Implementation |
|---|---|---|
| Atomicity | The transaction either succeeds entirely or fails entirely | commit / abort |
| Consistency | Data Integrity Constraints | Schema Validation + Transactions |
| Isolation | Concurrent transactions do not interfere with one another | Snapshot isolation |
| Durability | Permanently stored after transaction commit | Journal + replica set |
5. readConcern / writeConcern / readPreference
Concept Explanation: These three settings form the "trinity" of MongoDB's transaction consistency control, governing read consistency, write durability, and read routing policies, respectively. They determine the trade-off between consistency and performance for transactions.
How It Works:
- writeConcern: The number of nodes that must confirm a write operation for it to be considered successful.
w: majorityEnsures that writes are not lost - readConcern: The version of data seen by a read operation.
majorityEnsures that reads are committed;snapshotensures intra-transaction consistency. - readPreference: Which node read operations are routed to.
primaryStrongest consistency,secondaryReduces load on the primary node
(1) Write Concern
Principle: After a write operation is written to the Primary, it must wait for a specified number of confirmations from the Secondary that the data has been replicated before returning a success.
| Parameter | Value | Behavior | Consistency | Performance |
|---|---|---|---|---|
w |
1 | Primary confirmation only | Low | Fastest |
w |
majority | confirmed by the majority of nodes | high | slow |
j |
true | Write to disk journal | Fastest | Slowest |
wtimeout |
ms | Wait timeout | — | Timeout error |
session.startTransaction({
writeConcern: {
w: 'majority', // Confirmed by a majority of nodes
j: true, // Write to disk journal
wtimeout: 5000 // 5 Timeout in seconds
}
});
(2) Read Concern
Principle: Controls which versions of data a read operation can access—whether the latest local version (which may not have been committed) or the version with the most confirmations (which has been committed).
| Level | Description | Applicable Scenarios |
|---|---|---|
local |
Read the latest local data (default) | Performance-oriented; allows reading uncommitted data |
majority |
Read data that has been mostly confirmed | Strong consistency |
snapshot |
Snapshot isolation (within a transaction only) | Default for transactions; prevents phantom reads |
session.startTransaction({
readConcern: {
level: 'majority' // Read Submitted Data
},
writeConcern: { w: 'majority' }
});
(3) Read Preferences
Principle: Controls whether read operations are routed to the Primary or Secondary node, thereby implementing read-write separation.
| Pattern | Behavior | Applicable Scenarios |
|---|---|---|
primary |
Read-only primary node | Strongly consistent transactions |
primaryPreferred |
Primary first; if unavailable, fallback to secondary | General scenarios |
secondary |
Read-only secondary node | Reporting/analysis, offloading the primary node |
secondaryPreferred |
Read-first, fallback to primary if unavailable | Read-heavy, write-light |
nearest |
Lowest Network Latency | Geographically Distributed Cluster |
session.startTransaction({
readPreference: 'primary' // Read-Only Primary Node
});
session.startTransaction({
readPreference: 'secondary' // Read from a child node
});
session.startTransaction({
readPreference: 'secondaryPreferred' // Priority Node
});
Recommended Three-Piece Sets:
| Scenario | writeConcern | readConcern | readPreference |
|---|---|---|---|
| Financial transactions | majority + j:true | snapshot | primary |
| General transactions | majority | majority | primary |
| Report Analysis | — | local | secondary |
| Development and Testing | w:1 | local | primaryPreferred |
6. Mongoose Transaction Encapsulation
Concept Explanation: Mongoose provides a more elegant transaction API—the startSession() and session parameters. However, the try-catch-commit-abort boilerplate code used for manually managing transactions is verbose; encapsulating it into the withTransaction utility function can significantly simplify business logic code.
How It Works: Mongoose’s withTransaction wrapper automates session lifecycle management (start → commit/abort → end), allowing business functions to focus solely on core logic. Mongoose also supports passing { session } parameters to Model operations (findById, create, updateOne), enabling seamless integration of CRUD operations within transactions.
Comparison of Transaction Encapsulation Patterns:
| Pattern | Code Size | Error Handling | Retry Support | Use Cases |
|---|---|---|---|---|
| Manual try-catch | Multiple | Manual | None | Simple scenarios |
| withTransaction Encapsulation | Few | Automatic | Can be added | Recommended for production |
| mongoose.connection.transaction | Minimum | Automatic | Built-in | Mongoose 6+ |
// === mongoose Transaction Encapsulation ===
async function withTransaction(callback) {
const session = await mongoose.startSession();
session.startTransaction();
try {
const result = await callback(session);
await session.commitTransaction();
return result;
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
}
// === Usage: Transfer ===
async function transfer(fromUserId, toUserId, amount) {
return withTransaction(async (session) => {
const fromUser = await User.findById(fromUserId).session(session);
if (fromUser.balance < amount) {
throw new Error('Insufficient balance');
}
await User.updateOne(
{ _id: fromUserId },
{ $inc: { balance: -amount } },
{ session }
);
await User.updateOne(
{ _id: toUserId },
{ $inc: { balance: amount } },
{ session }
);
await TransactionLog.create([{
fromUserId,
toUserId,
amount,
createdAt: new Date()
}], { session });
return { success: true };
});
}
▶ Example 2: Mongoose Transaction Retry Encapsulation
// Alice's ShopHub Financial System: Transaction encountered WriteConflict automatic retry
async function withRetryTransaction(callback, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
const session = await mongoose.startSession();
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' }
});
try {
const result = await callback(session);
await session.commitTransaction();
return result;
} catch (err) {
await session.abortTransaction();
lastError = err;
if (err.errorLabels && err.errorLabels.includes('TransientTransactionError')) {
console.log(`Retry ${i + 1}/${maxRetries} due to WriteConflict`);
continue;
}
throw err;
} finally {
session.endSession();
}
}
throw lastError;
}
// Usage
await withRetryTransaction(async (session) => {
await User.updateOne({ _id: fromId }, { $inc: { balance: -100 } }, { session });
await User.updateOne({ _id: toId }, { $inc: { balance: 100 } }, { session });
});
7. Transaction Limits
Concept Explanation: MongoDB transactions have clear usage boundaries—they must be performed within a replica set, are subject to size limits, and do not support certain operations. Understanding these limitations is key to avoiding production incidents.
Detailed Explanation of Restrictions:
| Constraint | Description | Reason | Mitigation Strategy |
|---|---|---|---|
| Replica Sets Are Required | Standalone MongoDB Does Not Support Transactions | Transactions Rely on the oplog for Persistence | A Single-Node Replica Set Is Permitted in Development Environments |
| 16MB document | Total of all operations within a transaction | WiredTiger single-document limit | Splitting large transactions into smaller ones |
| Default 60-second timeout | maxTransactionLockRequestTimeoutMillis | Prevent long transactions from holding locks | Adjust the timeout parameter |
| Cannot perform operations on a capped collection | Partial restrictions | Rollbacks are not supported for capped collections | Avoid operating on capped collections within transactions |
| Cannot create sets within a transaction | Partial restriction (relaxed in 4.4+) | Conflict between DDL and transactions | Create sets before the transaction |
| Writing Conflicts | Concurrent Modifications to the Same Document | Optimistic Locking Mechanism | Automatic Retry for TransientTransactionError |
| Lock wait | Long transactions block other operations | Intent-to-write lock | Shorten transactions to avoid time-consuming operations |
Impact on Transaction Performance:
| Operation | Non-transactional | Within a transaction | Reason for overhead |
|---|---|---|---|
| Single-document write | Benchmark | +30–50% | Snapshot maintenance + lock management |
| Multi-document write | N independent I/O operations | 1 commit | Merging transactions into a single I/O operation may actually be faster |
| Read | Baseline | +10–20% | Additional overhead for snapshot reads |
| Submit | — | 5–50 ms | journal fsync + oplog write |
Transaction Best Practices:
| Practice | Description |
|---|---|
| Keep transactions as short as possible | Avoid long transactions that hold locks; keep them under 100 ms |
| Avoid computations within transactions | Perform complex computations outside transactions; limit transactions to read and write operations only |
| Retrying WriteConflicts | MongoDB 4.0+ Provides errorLabels to Identify Retryable Errors |
| Prioritize single-document atomic operations | updateOne + $inc is atomic in itself; no transaction is required |
8. Causal Consistency
Concept Explanation: Causal consistency is a lighter-weight consistency model than strong consistency—it does not guarantee that all operations are globally ordered, but it does guarantee that operations with causal relationships are executed in the correct order. For example, “read the balance first, then deduct the amount”—the deduction operation must be based on the most recently read balance; this is a causal dependency.
How It Works: MongoDB achieves causal consistency through operationTime and clusterTime. Each operation within a session carries the logicalTime of the previous operation, and the server ensures that subsequent operations see the results of preceding operations. Enabling causal consistency requires readConcern: majority + writeConcern: majority.
Causal Consistency vs. Other Consistency Models:
| Model | Warranty | Performance | Applicability |
|---|---|---|---|
| Strong consistency (linearizable) | Globally ordered | Slowest | Financial core |
| Causal Consistency | Causal Order | Relatively Fast | Multi-step Operations |
| Eventual consistency | Unordered | Fastest | Logs, notifications |
| Read My Posts | My Posts Are Visible | Fast | User Experience |
sequenceDiagram
participant A as Alice
participant P as Primary
participant S as Secondary
A->>P: Read Balance (readConcern: majority)
P-->>A: balance=1000, clusterTime=t1
A->>P: Deduct $100 (writeConcern: majority)
Note over A,P: Carry afterClusterTime=t1
P->>S: Copy oplog
S-->>P: Confirm
P-->>A: OK, clusterTime=t2
A->>P: View Transaction History (readConcern: majority)
Note over A,P: Carry afterClusterTime=t2
P-->>A: Includes records of payments that have just been deducted ✅
Note over A,P: Consistency of Cause and Effect:If you read it, you're sure to recognize your own previous writing.
// === Causal Consistency:Ensure the Correct Order of Operations ===
const session = db.getMongo().startSession();
session.startTransaction({
readConcern: { level: 'majority' },
writeConcern: { w: 'majority' }
});
// Operation 1:Read the current balance
const account = db.accounts.findOne({ userId: 'user_001' }, { session });
// Operation 2:Write Based on Read Results
db.accounts.updateOne(
{ userId: 'user_001' },
{ $set: { balance: account.balance - 100 } },
{ session }
);
// Guarantee:Operation 2 What you see is the operation 1 Subsequent Status
Key Points Analysis:
- To ensure causal consistency, you must use a session, and both
readConcernandwriteConcernmust be set tomajority. - When reading across nodes (readPreference: secondary), causal consistency ensures that the read reflects the write made by the local node.
- Causal consistency is the underlying mechanism for MongoDB's multi-document transactions and Change Streams.
▶ Example: A Complete Hands-On Guide to E-commerce Order Transactions
// Scene: Order placement process (Order + Inventory Deduction + Wallet Deduction + Logging), Fully Atomic
// Introduction: A replica set is required. Transactions must be in progress
// Initialize Data
db.products.insertOne({ sku: 'PHONE-001', stock: 10, price: 599 });
db.users.insertOne({ _id: 'user_001', balance: 1000 });
db.transaction_logs.createIndex({ userId: 1, createdAt: -1 });
// Complete Transaction Functions
async function placeOrder(userId, items) {
const session = db.getMongo().startSession();
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' }
});
try {
// 1. Calculate the total amount + Check Inventory (Atomic Read)
let total = 0;
for (const item of items) {
const product = db.products.findOne(
{ sku: item.sku, stock: { $gte: item.qty } },
{ session }
);
if (!product) {
throw new Error(`Out of Stock: ${item.sku}`);
}
total += product.price * item.qty;
}
// 2. Check User Balance
const user = db.users.findOne({ _id: userId }, { session });
if (user.balance < total) {
throw new Error('Insufficient balance');
}
// 3. Inventory Deduction (Conditional, Preventing Overselling)
for (const item of items) {
const result = db.products.updateOne(
{ sku: item.sku, stock: { $gte: item.qty } },
{ $inc: { stock: -item.qty } },
{ session }
);
if (result.modifiedCount === 0) {
throw new Error(`Inventory deduction failed: ${item.sku}`);
}
}
// 4. Deduct from the user's balance
db.users.updateOne(
{ _id: userId, balance: { $gte: total } },
{ $inc: { balance: -total } },
{ session }
);
// 5. Create an Order
const orderResult = db.orders.insertOne({
userId,
items,
total,
status: 'paid',
createdAt: new Date()
}, { session });
// 6. Record the transaction log
db.transaction_logs.insertOne({
userId,
orderId: orderResult.insertedId,
amount: total,
type: 'purchase',
createdAt: new Date()
}, { session });
// 7. Commit Transaction
session.commitTransaction();
return { success: true, orderId: orderResult.insertedId };
} catch (err) {
// Any failure → Roll Back All
session.abortTransaction();
return { success: false, error: err.message };
} finally {
session.endSession();
}
}
// Execute: Place an Order
placeOrder('user_001', [
{ sku: 'PHONE-001', qty: 1 }
]);
// Testing Rollback Scenarios: Deliberately Creating Errors
placeOrder('user_001', [
{ sku: 'NONEXIST', qty: 1 } // The product does not exist.
]);
// Throw an exception → Transaction Rollback → Inventory, balance, order, all logs remain unchanged
// Verifying Atomicity:
// db.products.findOne({ sku: 'PHONE-001' }) → stock: 10 (Not deducted)
// db.users.findOne({ _id: 'user_001' }) → balance: 1000 (Not deducted)
Output: When a transaction succeeds, all changes are committed together; when a transaction fails, all changes are rolled back, ensuring data consistency.
❓ FAQ
📖 Summary
- MongoDB 4.0+ multi-document transactions require a replica set
- session.startTransaction / commit / abort
- ACID Properties: Atomicity / Consistency / Isolation / Durability
- The trinity: readConcern / writeConcern / readPreference
- Causal Consistency
- Mongoose Transaction Wrappers
📝 Exercises
- Basic Question (⭐): Implement a transfer transaction using mongosh (including try-catch rollback).
- Basic Problem (⭐): Use Mongoose to wrap the
withTransactionutility function. - Advanced Exercise (⭐⭐): Implement an order transaction (place order + deduct inventory + create order + clear shopping cart—all atomic).
- Advanced Exercise (⭐⭐): Test transaction rollback in case of failure (intentionally throw an error to verify atomicity).
- Challenge (⭐⭐⭐): Build a complete e-commerce transaction system (orders + inventory + wallet + logs) that supports distributed rollback.



