Multi-document transactions
ACID across documents and collections: sessions, the callback API, retries, write conflicts, limits and when not to use transactions.
- Use sessions and withTransaction
- Understand snapshot isolation and write conflicts
- Handle TransientTransactionError and UnknownTransactionCommitResult
- Know the limits: 60 s default lifetime, oplog size, performance cost
conceptThe concept
A single-document write is always atomic. Multi-document transactions (replica sets since 4.0, sharded clusters since 4.2) give all-or-nothing semantics with snapshot isolation across documents, collections and databases.
const session = db.getMongo().startSession()
session.withTransaction(() => {
const s = session.getDatabase("shop")
s.products.updateOne({ _id: "P001", stock: { $gte: 1 } }, { $inc: { stock: -1 } })
s.orders.insertOne({ ... })
}, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } })
withTransaction (the callback API) retries the whole callback on TransientTransactionError (e.g. WriteConflict) and retries the commit on UnknownTransactionCommitResult. With the core API (startTransaction/commitTransaction) you must write those retry loops yourself.
Two transactions modifying the same document conflict: the second writer gets a WriteConflict immediately and must retry. A non-transactional write to a document locked by an open transaction waits for it to finish (up to maxTransactionLockRequestTimeoutMillis).
whyWhy it matters
Transactions make some invariants simple (transfer money between two accounts, decrement stock and create an order). But they cost latency, hold resources and abort under contention, so a good schema uses them for the few flows that truly span documents.
Use caseReal-world scenario
Checkout: decrement stock for each product, insert the order, and record the payment intent. If any step fails (stock went to zero), the whole transaction aborts and nothing is half-written.
Try itExamples
Run and edit these in the interactive lesson.
const session = db.getMongo().startSession()
const s = session.getDatabase("shop")
session.withTransaction(() => {
const r = s.products.updateOne({ _id: "P005", stock: { $gte: 2 } }, { $inc: { stock: -2 } })
if (r.modifiedCount !== 1) throw new Error("out of stock")
s.orders.insertOne({ orderNo: "ORD-TXN-1", customerId: 1, status: "paid", createdAt: new Date(), items: [{ productId: "P005", qty: 2, price: 100 }], total: 200 })
})
db.orders.findOne({ orderNo: "ORD-TXN-1" }, { status: 1, total: 1 })
session.endSession()const s1 = db.getMongo().startSession()
s1.startTransaction()
s1.getDatabase("shop").customers.updateOne({ _id: 3 }, { $set: { tier: "platinum" } })
print("inside txn :", s1.getDatabase("shop").customers.findOne({ _id: 3 }).tier)
print("outside txn:", db.customers.findOne({ _id: 3 }).tier)
s1.abortTransaction()
print("after abort:", db.customers.findOne({ _id: 3 }).tier)const a = db.getMongo().startSession(), b = db.getMongo().startSession()
a.startTransaction(); b.startTransaction()
a.getDatabase("shop").products.updateOne({ _id: "P007" }, { $inc: { stock: -1 } })
try { b.getDatabase("shop").products.updateOne({ _id: "P007" }, { $inc: { stock: -1 } }) }
catch (e) { print(e.codeName, JSON.stringify(e.errorLabels)) }
a.commitTransaction()
try { b.abortTransaction() } catch (e) {}internalsUnder the hood
A transaction runs on a WiredTiger snapshot; its writes are private until commit. On commit, all operations are written to the oplog as one applyOps entry (or a chain of entries for large transactions since 4.2). The default transactionLifetimeLimitSeconds is 60: longer transactions are aborted, because an open snapshot pins old versions in the cache and creates cache pressure. In sharded clusters, a transaction touching several shards uses two-phase commit coordinated by one shard.
Avoid / preferDo this, not that
session.withTransaction(() =>
orders.updateOne({ _id: id }, { $set: { status: "paid" } }, { session }))db.orders.updateOne({ _id: id }, { $set: { status: "paid" } })A single-document write is already atomic. Wrapping it in a transaction only adds round trips and overhead.
await session.withTransaction(async () => {
await orders.insertOne(order, { session })
await sendConfirmationEmail(order)
})await session.withTransaction(async () => {
await orders.insertOne(order, { session })
await outbox.insertOne({ type: "order-confirmation", orderId: order._id }, { session })
})
// a worker reads the outbox and sends the emailThe callback may run several times on transient errors, so the email could go out twice, and locks are held while a slow API call waits. The outbox pattern commits the intent atomically and sends it afterwards.
PitfallsCommon mistakes
- Using transactions to compensate for a relational-style schema; embedding often removes the need.
- Long-running transactions (batch jobs updating 100,000 documents in one transaction).
- Not retrying on TransientTransactionError with the core API.
- Doing network calls or user interaction inside a transaction.
- Expecting transactions on a standalone mongod; they need a replica set (even a single-node one).
LimitsWhen not to use it
Transactions are not for bulk loads or long jobs: keep them well under the 60-second default lifetime and to a modest number of documents, because old versions stay pinned in the cache until they finish. They are also not a substitute for schema design; if most operations need one, the data is probably split across too many documents.
SecuritySecurity notes
Transactions protect integrity rules that span documents, such as double-entry ledgers: a debit that commits without its credit creates money. Keep both legs in one transaction, and make the ledger append-only for the application role (grant insert, not update or remove).
ChallengeTry it yourself
In a scratch collection accounts_lab with alice at 100 and bob at 50, transfer 25 from alice to bob in a transaction that refuses to overdraw, then show both balances.
Show one solution
db.accounts_lab.drop()
db.accounts_lab.insertMany([{ _id: "alice", balance: 100 }, { _id: "bob", balance: 50 }])
const s = db.getMongo().startSession()
const accts = s.getDatabase("shop").accounts_lab
s.withTransaction(() => {
if (accts.updateOne({ _id: "alice", balance: { $gte: 25 } }, { $inc: { balance: -25 } }).modifiedCount !== 1) throw new Error("insufficient funds")
accts.updateOne({ _id: "bob" }, { $inc: { balance: 25 } })
})
s.endSession()
db.accounts_lab.find().sort({ _id: 1 })InterviewQuestions you should be able to answer
When should you use multi-document transactions?
When an invariant spans multiple documents and cannot be modelled into one document: transfers between accounts, inventory plus order creation, maintaining a separate uniqueness or ledger collection. Prefer single-document atomic updates when the schema allows, because transactions add latency, contention and abort/retry handling.
What happens when two transactions update the same document?
The first to write holds the document; the second receives a WriteConflict error labelled TransientTransactionError immediately and its transaction is aborted. The driver callback API retries the whole transaction. Conflicts are detected at write time, not at commit (first-writer-wins).
What limits apply to transactions?
Default 60 s lifetime (transactionLifetimeLimitSeconds), the 16 MB limit per oplog entry is avoided since 4.2 by splitting into multiple entries, but large transactions still increase cache pressure and replication lag. Lock acquisition timeout defaults to 5 ms (maxTransactionLockRequestTimeoutMillis). Some operations are not allowed (e.g. writes to capped collections, operations on config/admin/local).
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.