TL;DR: I a previous post I explained why Why isn't "majority" the default read concern in MongoDB. If you’re used to SQL databases, you’ll likely prefer the snapshot read concern, which guarantees all rows are read from the same snapshot. Alternatively, run your operations in an explicit transaction, as transactions are ACID.
With majority, you avoid dirty or uncommitted reads because you only see data acknowledged by a majority. However, a scan can yield while the majority state advances, so some rows may come from a newer majority snapshot.
By default, MongoDB’s consistency boundary is the document—similar to an aggregate in domain‑driven design. For multi‑document consistency, use explicit transactions or the snapshot read concern. This is MongoDB’s tunable consistency model, whose defaults often suit event‑driven architectures more than traditional SQL workloads.
To illustrate this, here’s a demo.
I start by setting up the collection, the document count, and other parameters:
const NUM_ACCOUNTS = 1_000_000;
const INITIAL_BALANCE = 10000; // cents
const BATCH_SIZE = 10_000;
const TOTAL_TRANSFERS = 100_000;
const NUM_WRITERS = 5;
const mydb = db.getSiblingDB("demo");
const coll = mydb.accounts;
I load one million accounts, all with the same amount:
for (let i = 0; i < NUM_ACCOUNTS; i += BATCH_SIZE) {
const batch = [];
for (let j = i; j < Math.min(i + BATCH_SIZE, NUM_ACCOUNTS); j++) {
batch.push({ _id: j, balance: INITIAL_BALANCE });
}
coll.insertMany(batch, { ordered: false });
if (i % 100_000 === 0) print(" " + (i / 1000) + "k...");
}
print("✅ Loaded " + NUM_ACCOUNTS.toLocaleString() + " accounts\n");
This function checks the total balance every second:
async function periodicReader(readConcern) {
while (readingActive) {
const result = coll.aggregate([
{ $group: { _id: null, total: { $sum: "$balance" } } }
], { readConcern: { level: readConcern } }
// -> majority or snapshot
).toArray();
const total = result.length ? result[0].total : "N/A";
print(" 📊 Aggregate read — total balance: " + total +
" | transfers so far: " + transfersDone);
// Wait ~1 second before next read
await new Promise(r => setTimeout(r, 1000));
}
}
// run it:
let transfersDone = 0;
let readingActive = true;
const readerPromise = periodicReader("majority");
With no writes and one million accounts initialized with ten thousand each, the total balance is 10,000,000,000:
📊 Aggregate read — total balance: 10000000000 | transfers so far: 0
📊 Aggregate read — total balance: 10000000000 | transfers so far: 0
📊 Aggregate read — total balance: 10000000000 | transfers so far: 0
📊 Aggregate read — total balance: 10000000000 | transfers so far: 0
📊 Aggregate read — total balance: 10000000000 | transfers so far: 0
📊 Aggregate read — total balance: 10000000000 | transfers so far: 0
I perform random account-to-account transfers that debit one account and credit another for the same amount in a single transaction, so the total balance stays the same, and run it in five threads:
print("⏳ Launching " + NUM_WRITERS + " writers + 1 reader...\n");
let writingDone = false;
let transfersDone = 0;
async function writer(id, count) {
const s = mydb.getMongo().startSession();
const sc = s.getDatabase("demo").accounts;
for (let t = 0; t < count; t++) {
// pick two random accounts
const from = Math.floor(Math.random() * NUM_ACCOUNTS);
let to = Math.floor(Math.random() * NUM_ACCOUNTS);
while (to === from) to = Math.floor(Math.random() * NUM_ACCOUNTS);
// same amount to debit on one, credit on the other
const amount = Math.floor(Math.random() * 1000);
// do that in a transaction
try {
s.startTransaction();
sc.updateOne({ _id: from }, { $inc: { balance: -amount } });
sc.updateOne({ _id: to }, { $inc: { balance: amount } });
s.commitTransaction();
transfersDone++;
} catch (e) {
try { s.abortTransaction(); } catch (_) {}
}
}
s.endSession();
print(" ✅ Writer " + id + " done");
}
// Run in threads:
const writerPromises = [];
for (let i = 0; i < NUM_WRITERS; i++) {
writerPromises.push(writer(i + 1, TOTAL_TRANSFERS / NUM_WRITERS));
}
The reader thread is still running, but it now reports an inconsistent total balance due to the "majority" read concern:
📊 Aggregate read — total balance: 10000002595 | transfers so far: 634
📊 Aggregate read — total balance: 10000003902 | transfers so far: 1177
📊 Aggregate read — total balance: 9999999180 | transfers so far: 1742
📊 Aggregate read — total balance: 10000002564 | transfers so far: 2325
📊 Aggregate read — total balance: 9999995030 | transfers so far: 2900
📊 Aggregate read — total balance: 10000001154 | transfers so far: 3462
📊 Aggregate read — total balance: 9999996910 | transfers so far: 4029
📊 Aggregate read — total balance: 9999992085 | transfers so far: 4655
📊 Aggregate read — total balance: 9999995372 | transfers so far: 5215
📊 Aggregate read — total balance: 9999999916 | transfers so far: 5792
📊 Aggregate read — total balance: 9999998316 | transfers so far: 6396
📊 Aggregate read — total balance: 9999997128 | transfers so far: 6976
📊 Aggregate read — total balance: 10000006447 | transfers so far: 7516
📊 Aggregate read — total balance: 9999998330 | transfers so far: 8091
📊 Aggregate read — total balance: 10000001286 | transfers so far: 8656
📊 Aggregate read — total balance: 10000001899 | transfers so far: 9240
📊 Aggregate read — total balance: 9999996708 | transfers so far: 9845
📊 Aggregate read — total balance: 10000005159 | transfers so far: 10444
📊 Aggregate read — total balance: 10000002749 | transfers so far: 11012
📊 Aggregate read — total balance: 9999999925 | transfers so far: 11623
If you’re coming from SQL databases, it may be surprising: in SQL, every statement runs in an explicit transaction with a defined isolation level. MongoDB instead offers several consistency boundaries:
- Document-level consistency by default
- Statement-level consistency with the
snapshotread concern - Transaction-level consistency with explicit transactions
I’ve been using the default document-level consistency, so the total was inconsistent, and I’ll now show the other levels that provide stronger read-time consistency.
I stop the reading thread:
readingActive = false;
Then I restart it with a snapshot read concern:
let readingActive = true;
const readerPromise = periodicReader("snapshot");
Now the results are consistent: the total balance stays the same while money is being transferred between accounts.
📊 Aggregate read — total balance: 10000000000 | transfers so far: 92845
📊 Aggregate read — total balance: 10000000000 | transfers so far: 93439
📊 Aggregate read — total balance: 10000000000 | transfers so far: 94022
📊 Aggregate read — total balance: 10000000000 | transfers so far: 94590
📊 Aggregate read — total balance: 10000000000 | transfers so far: 95161
📊 Aggregate read — total balance: 10000000000 | transfers so far: 95737
📊 Aggregate read — total balance: 10000000000 | transfers so far: 96307
📊 Aggregate read — total balance: 10000000000 | transfers so far: 96835
📊 Aggregate read — total balance: 10000000000 | transfers so far: 97353
📊 Aggregate read — total balance: 10000000000 | transfers so far: 97920
📊 Aggregate read — total balance: 10000000000 | transfers so far: 98478
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99038
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99621
✅ Writer 3 done
✅ Writer 1 done
✅ Writer 4 done
✅ Writer 2 done
✅ Writer 5 done
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99999
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99999
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99999
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99999
📊 Aggregate read — total balance: 10000000000 | transfers so far: 99999
This doesn't lock anything because it uses WiredTiger Multi-Version Concurrency Control (MVCC) and may even be faster as it doesn't have to wait for the majority committed snapshot that may be delayed if secondaries are not available.
I start the writes again, as they ended, without starting the reader thread as I'll run it manually:
let readingActive = false;
for (let i = 0; i < NUM_WRITERS; i++) {
writerPromises.push(writer(i + 1, TOTAL_TRANSFERS / NUM_WRITERS));
}
I run a manual aggregate with the default read concern, and it shows an inconsistent balance:
const s = mydb.getMongo().startSession();
const sc = s.getDatabase("demo").accounts;
sc.aggregate([
{ $group: { _id: null, total: { $sum: "$balance" } } }
]
)
test> sc.aggregate([
... { $group: { _id: null, total: { $sum: "$balance" } } }
... ]
... )
[ { _id: null, total: 9999997810 } ]
Instead of specifying "snapshot" read concern, I run it in a transaction:
s.startTransaction();
sc.aggregate([
{ $group: { _id: null, total: { $sum: "$balance" } } }
]
)
s.commitTransaction();
test> s.startTransaction();
test> sc.aggregate([
... { $group: { _id: null, total: { $sum: "$balance" } } }
... ]
... )
[ { _id: null, total: 10000000000 } ]
test> s.commitTransaction();
As you would expect, transactions are ACID and consistent. It's another way to get snapshot isolation.
Before giving more explanation I stop all:
(async () => {
await Promise.all(writerPromises);
readingActive = false;
await readerPromise;
})();
All databases provide different levels of consistency, and “consistency” actually means two things:
Snapshot consistency: all data is read from a single commit point in time. The result is then stale because the read timestamp is fixed at the start of the statement (e.g., SQL Read Committed) or at the start of the transaction (e.g., SQL Repeatable Read). This is the consistency as defined by the ACID properties.
Read-your-writes consistency: you see the latest committed state, and the read time can advance during the scan to reduce staleness. This is the consistency as defined by the CAP theorem.
SQL databases don’t always use the first model. Isolation levels guarantee that you see committed changes, but not that all rows were committed at the same instant. MVCC databases usually provide snapshot consistency by setting a read timestamp in the past, but some may re-read rows later if they change and are about to be updated, to avoid restarting the whole query at a later point in time.
To validate a total balance, you need a consistent snapshot. In some other cases, reading the latest values is preferable to using a stale snapshot. Neither approach is always best.
In the SQL bubble, inconsistent snapshots often seem unacceptable, whereas in event-driven NoSQL systems, reading the latest writes makes more sense. Any database can work well if you understand it, and poorly if you ignore its behavior under race conditions.
Non-MVCC databases can return the latest writes within a single, consistent state but require heavy locking to do so. In contrast, MVCC databases avoid blocking reads, but must choose between a stable, timeline-consistent snapshot that may be stale or a latest-write view whose read times keep moving.
For SQL databases, you need to understand the isolation levels:
- Per-statement read time in Read Committed Snapshot Isolation.
- Per-transaction read time in Snapshot Isolation
For MongoDB, you should understand the consistency boundaries:
- Document-level consistency by default
- Statement-level consistency with the "snapshot" read concern
- Transaction-level consistency with explicit transactions
Top comments (0)