MongoDB · lesson 7 of 10
Intermediate
6- 2 Embedding vs Referencing: A 5.9MB Document and a 50x Fetch Time
- 3 explain() Proves It: 200,000 Docs Scanned vs. a Covered Query That Touches Zero
- 4 The Aggregation Stage-Order Bug: Wrong Results, No Error
- 5 When the Aggregation Optimizer Can (and Can't) Use Your Index
- 6 Change Streams: Watching a Collection in Real Time (Replica Set Required)
- 7 Multi-Document Transactions: When One Update Isn't Enough
Advanced
3Also part of MongoDB
1Multi-Document Transactions: When One Update Isn't Enough
Transfer money between two accounts as a single atomic transaction — then abort one on purpose and prove the balance actually rolls back, not just that the code stopped running.
By SkillByExample · Published
Two accounts, one transfer, and a guarantee that either both balances change or neither does — even when the code decides midway through that it shouldn’t happen.
The bug: transactions on a standalone server
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
try {
session.getDatabase("bookshop").accounts.updateOne({ name: "alice" }, { $inc: { balance: -50 } });
session.commitTransaction();
print("committed");
} catch (e) {
print("ERROR: " + e.message);
session.abortTransaction();
}
'
ERROR: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.
Same underlying cause as the change-streams lesson’s error: a standalone server has no replication machinery. Multi-document transactions need the replica set from that lesson.
A successful transfer
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.accounts.insertMany([{ name: "alice", balance: 100 }, { name: "bob", balance: 50 }]);
'
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bookshop").accounts;
accounts.updateOne({ name: "alice" }, { $inc: { balance: -30 } });
accounts.updateOne({ name: "bob" }, { $inc: { balance: 30 } });
session.commitTransaction();
print("committed");
'
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
printjson(db.accounts.find({}, {_id:0}).toArray());
'
committed
[ { name: 'alice', balance: 70 }, { name: 'bob', balance: 80 } ]
Two separate updateOne calls, on two different documents, wrapped in one
startTransaction()/commitTransaction() pair — both changes land together.
Aborting mid-transaction: the rollback is real
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bookshop").accounts;
accounts.updateOne({ name: "alice" }, { $inc: { balance: -1000 } });
const aliceBalance = accounts.findOne({ name: "alice" }).balance;
print("balance inside uncommitted transaction:", aliceBalance);
session.abortTransaction();
'
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
print("balance after abort:", db.accounts.findOne({ name: "alice" }).balance);
'
balance inside uncommitted transaction: -930
balance after abort: 70
Inside the transaction’s own session, the read sees the pending write — -930 (70 - 1000).
Once abortTransaction() runs, a completely separate read (outside that session) shows 70,
the original balance, as though the -1000 update never happened. This is the actual
guarantee, not just “the script stopped”: the write was applied and then genuinely undone.
A real business rule enforced with an abort
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
function transfer(from, to, amount) {
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bookshop").accounts;
try {
const sender = accounts.findOne({ name: from });
if (sender.balance < amount) {
throw new Error("insufficient funds");
}
accounts.updateOne({ name: from }, { $inc: { balance: -amount } });
accounts.updateOne({ name: to }, { $inc: { balance: amount } });
session.commitTransaction();
return "committed";
} catch (e) {
session.abortTransaction();
return "aborted: " + e.message;
} finally {
session.endSession();
}
}
print(transfer("alice", "bob", 1000));
printjson(db.accounts.find({}, {_id:0}).toArray());
'
aborted: insufficient funds
[ { name: 'alice', balance: 70 }, { name: 'bob', balance: 80 } ]
The insufficient-funds check runs inside the transaction, before either updateOne call —
throwing sends control to the catch block, which explicitly aborts. Balances stay exactly
where they were; nothing was ever really written.
Practice
1. Confirm a committed transaction's changes are visible from a completely separate connection.
docker exec mongo-rs2 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
printjson(db.accounts.find({}, {_id:0}).toArray());
'
[ { name: 'alice', balance: 70 }, { name: 'bob', balance: 80 } ]
Reading from mongo-rs2 — a different node in the same replica set, not the primary the
transaction ran against — confirms the committed values replicated, not just that they exist
on whichever node happened to run the transaction.
2. Try committing a transaction twice and see what actually happens the second time.
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
session.getDatabase("bookshop").accounts.updateOne({ name: "alice" }, { $inc: { balance: 1 } });
session.commitTransaction();
try {
session.commitTransaction();
print("second commit succeeded");
} catch (e) {
print("second commit failed: " + e.codeName);
}
print("alice balance:", db.accounts.findOne({ name: "alice" }).balance);
'
second commit succeeded
alice balance: 71
Worth checking rather than assuming: re-committing an already-committed transaction is
idempotent, not an error — commitTransaction() is safe to retry (which matters for a
driver automatically retrying a commit whose acknowledgment got lost on the network). The
balance confirms it: 71 (70 + 1), applied exactly once, not 72 — the second call didn’t
redo the increment.
3. Check both balances sum to the same total before and after several transfers, proving no money was created or destroyed.
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const docs = db.accounts.find({}, {_id:0,balance:1}).toArray();
print("total:", docs.reduce((sum, d) => sum + d.balance, 0));
'
total: 151
150 (the original combined 100 + 50) plus the 1 added in the previous task’s committed transfer — every successful transaction moved money between accounts without ever creating or losing any along the way.
Next: replication and failover — killing the primary and measuring how long an election actually takes.