The idea: a transfer that happens all at once or not at all
Moving 30 from Ann to Bob is two updates on two documents. If only the first one happens, 30 is gone. A multi-document transaction (MongoDB 4.0 for replica sets, 4.2 for sharded clusters) groups the two updates so that nobody ever sees one without the other: other readers see the old balances until the commit, then both new ones together, and the secondaries copy both in one step.
The canvas shows two driver sessions on the left, the server in the middle with both documents and their
update chains (every version of the document, newest first), and the oplog on the right. The
collection is test.accounts with {_id: 1, owner: "Ann", balance: 100} and
{_id: 2, owner: "Bob", balance: 50}. How a single write goes through mongod, the WiredTiger cache,
the journal and the replica set is on How MongoDB Runs a Query; this page assumes that
and looks at what changes when several writes must be one.
Sessions, lsid and txnNumber
A transaction always lives in a logical session. The driver creates the session and gives it an
lsid (a UUID). Each transaction in the session gets the next txnNumber. Every command
of the transaction carries both, and the first one also says startTransaction: true, so the server can
tie commands from any connection to the right transaction:
{update: "accounts", updates: [{q: {_id: 1}, u: {$inc: {balance: -30}}}],
lsid: {id: UUID("a1f3…")}, txnNumber: 1, startTransaction: true,
autocommit: false, readConcern: {level: "snapshot"}}
{update: "accounts", updates: [{q: {_id: 2}, u: {$inc: {balance: 30}}}],
lsid: {id: UUID("a1f3…")}, txnNumber: 1, autocommit: false}
{commitTransaction: 1, lsid: {id: UUID("a1f3…")}, txnNumber: 1, autocommit: false}
The server keeps the state of each session's newest transaction in memory (the canvas calls it the session catalog): its number, whether it is in progress, committed or aborted, and its WiredTiger transaction. A command with an older txnNumber is refused, which is also how retried commands are recognised.
Snapshot reads: a read timestamp and update chains
When the transaction starts, the server opens a WiredTiger transaction with a read timestamp: the newest point at which everything before it is committed. On the canvas that is the cluster time, t11 at the start. The transaction then reads that snapshot for its whole life, whatever others commit meanwhile. Its own writes are uncommitted versions at the head of the documents' update chains, in orange.
| Version on the chain | The writer itself | Another transaction (read ts t11) |
|---|---|---|
70 · S1 txn 1 · uncommitted | sees it | skips it |
70 · committed t12 | sees it | skips it: t12 is after its snapshot |
100 · committed t10 | (older) | sees it |
In Demo: another session reads the old snapshot, S2 reads in the middle of S1's transfer and gets Ann 100, Bob 50: the sum is 150, never 120. S1 commits at t12, and S2 reads again in the same transaction: still 100 and 50, because its snapshot is t11. Only S2's next transaction, with read timestamp t12, sees 70 and 80. The ▶ on each document marks the version the read used. This is snapshot isolation, the same idea as in MVCC and Isolation Levels, without an undo log: the old versions stay on the update chain (and in the history store) as long as a snapshot needs them.
Commit: one timestamp, one oplog entry
commitTransaction gives every write of the transaction the same commit timestamp. A
reader's snapshot is either before it, and sees neither write, or at or after it, and sees both. Then the server writes
one oplog entry for the whole transaction (Demo: transfer commits):
{ts: t12, op: "c", ns: "admin.$cmd", lsid: {id: UUID("a1f3…")}, txnNumber: 1,
o: {applyOps: [{op: "u", ns: "test.accounts", o2: {_id: 1}, o: {$set: {balance: 70}}},
{op: "u", ns: "test.accounts", o2: {_id: 2}, o: {$set: {balance: 80}}}]}}
A secondary applies an applyOps entry as a unit, so a read on a secondary cannot see half a transfer
either. Like every oplog entry it stores the result ($set: 70), not the operation ($inc: −30),
so applying it twice is harmless. A very large transaction is split over several oplog entries, chained by
prevOpTime, but it still becomes visible at one timestamp. A transaction that only read writes nothing to
the oplog.
Write conflicts and TransientTransactionError
Two transactions may read the same document, but only one may write it. WiredTiger checks the head of the update chain when a transaction writes: if it is an uncommitted version of another transaction, or a version committed after the writer's read timestamp, the write fails with WriteConflict (code 112). Inside a transaction the server does not wait: it aborts the transaction at once. (A plain write outside a transaction that hits a transaction's document waits and retries until the transaction ends.)
The error carries the label TransientTransactionError, meaning "the whole transaction may succeed
if you run it again". The driver's session.withTransaction(callback) does exactly that: it starts a new
transaction (txnNumber + 1, a new snapshot), runs the callback again and commits, until it succeeds or 120 s have
passed. In Demo: write conflict S1 has an uncommitted 70 on Ann when S2 tries to charge a fee of 5; S2 is
aborted. S1 commits at t12, and S2's retry reads 70 at snapshot t12, writes 65 and commits at t13. The total is 145:
the fee was taken exactly once and the transfer was not lost.
await session.withTransaction(async () => {
await accounts.updateOne({_id: 1}, {$inc: {balance: -30}}, {session});
await accounts.updateOne({_id: 2}, {$inc: {balance: 30}}, {session});
}); // retries on TransientTransactionError; retries the commit on UnknownTransactionCommitResult
A second label, UnknownTransactionCommitResult, is set when the commit itself failed in a way that
leaves the outcome open (a network error, a failover). Then only commitTransaction is retried, with the same
txnNumber, and the server answers from its session state whether the commit already happened.
Limits: 60 seconds and a few others
An open transaction pins its snapshot, so WiredTiger must keep every newer version of every document it could read.
The server therefore aborts any transaction older than transactionLifetimeLimitSeconds (default 60). In
Demo: 60 s lifetime limit, S1 updates Ann and then stalls for 61 s; the server's periodic check aborts the
transaction and drops its write. S1's next command still says txnNumber 1 and gets NoSuchTransaction
(code 251), which is also labelled TransientTransactionError, so withTransaction runs the transfer again as txnNumber 2.
| Setting or limit | Default | What happens |
|---|---|---|
transactionLifetimeLimitSeconds | 60 s | The server aborts older transactions. |
maxTransactionLockRequestTimeoutMillis | 5 ms | A transaction that cannot get a lock (for example during an index build) aborts quickly instead of waiting. |
| withTransaction retry budget | 120 s | The driver stops retrying and throws. |
| Oplog entry size | 16 MB | Bigger transactions are split over several entries (4.2+). |
Keep transactions short: read and write only what is needed, never wait for a user inside one, and remember that a transaction that conflicts often is retried often. When one document can hold the change (an embedded array, a counter), a single-document write is atomic without any transaction.
Across shards: two-phase commit
On a sharded cluster the client talks to mongos. mongos picks one read timestamp
(atClusterTime) for the transaction and adds it, with startTransaction, to the first command each
shard receives, so every shard reads the same snapshot. If the transaction wrote to one shard only, the commit is the
simple one above. If it wrote to several, mongos hands the commit to a transaction coordinator that runs
on the first shard the transaction wrote to, and the coordinator runs two-phase commit
(Demo: 2 shards):
- The coordinator writes the participant list to
config.transaction_coordinators, so a new primary can finish the job after a failover. - Prepare: each participant writes its part to its oplog as
applyOpswithprepare: true, majority-committed, and replies with itsprepareTimestamp(t12 on A, t13 on B). A prepared shard may no longer abort on its own. - Decision: the coordinator picks
commitTimestamp= the largest prepare timestamp (t13) and writes the decision durably. - Commit: each participant writes a
commitTransactionoplog entry and makes its version visible at t13. Then the coordinator deletes its document and mongos replies{ok: 1}.
Between prepare and commit a read that needs a prepared document waits (a prepare conflict), because its outcome is not known yet. That window is the price of atomic commits across shards; the Saga vs Two-Phase Commit page compares it with the alternative.
Compared with other databases
| MongoDB | PostgreSQL / InnoDB | Redis MULTI/EXEC | |
|---|---|---|---|
| Isolation | snapshot (readConcern "snapshot") | read committed or repeatable read by default | runs queued commands back to back |
| Two writers, one row | second one fails at once (WriteConflict), retry | second one waits for the row lock | no conflict: commands are serialised; WATCH detects changes |
| Rollback | drop the uncommitted versions | undo log / dead tuples | none |
| Time limit | 60 s by default | none by default | none |
See also Redis Transactions and Scripts and Database Replication.
What the page leaves out
- Write concern and read concern "majority" of the commit; here every commit is acknowledged at once (see write concern).
- Prepare conflicts, coordinator failover and recovery, and the
abortTransactiondecision in two-phase commit. - Large transactions split over several oplog entries, and the history store that keeps old versions when the cache is full.
- Locks: transactions take intent locks on the database and collection; DDL and index builds can block them.
- Retryable writes (the same lsid and txnNumber on a single write, without a transaction), causal consistency and reading from secondaries.
- Time: every command takes 1 s here, so the 60 s limit shows up only with the Wait button.