MongoDB Internals

How MongoDB actually stores, replicates, and serves documents underneath mongosh — the storage engine, the oplog, how an election really resolves, and the write-concern arithmetic that decides whether a failover loses data. For the operational side of running this on real VMs (firewall rules, mongod.conf, the Prometheus exporter, a step-by-step bootstrap), see on-prem-vm/mongodb.md; for the Kubernetes-operator version, see on-prem-k8s/mongodb.md.

0/0 checks

WiredTiger Storage Engine

Every write goes through the same three structures, whether it's a single insertOne or a bulk load:

graph TD
    classDef engine fill:#2c3e50,stroke:#1a252f,color:#fff
    classDef mem fill:#3498db,stroke:#2471a3,color:#fff
    classDef durable fill:#e67e22,stroke:#ba6018,color:#fff
    classDef disk fill:#7f8c8d,stroke:#616a6b,color:#fff

    CLIENT["MongoDB client"] -->|"insert / update / delete"| MONGOD["mongod process"]
    MONGOD --> WT["WiredTiger Storage Engine"]:::engine

    subgraph WT["WiredTiger"]
        CACHE["WT Cache — in-memory B-tree pages<br/>size: wiredTigerCacheSizeGB<br/>default: 50% of (RAM − 1GB)"]:::mem
        JOURNAL["Journal (WAL)<br/>append-only, fsynced every 100ms<br/>or immediately if j:true"]:::durable
        CHECKPOINT["Checkpoint<br/>every 60s or 2GB of journal written<br/>flushes dirty cache pages to disk"]:::durable
    end

    CACHE -->|"dirty pages"| CHECKPOINT
    CHECKPOINT --> DATA["Collection files (.wt)<br/>B-tree, snappy-compressed by default"]:::disk
    CHECKPOINT --> IDX["Index files (.wt)<br/>B-tree on _id + user indexes"]:::disk
    JOURNAL -.->|"replayed on crash recovery<br/>if newer than last checkpoint"| DATA

The journal and the checkpoint solve two different failure windows. A crash between checkpoints only loses what's replayable from the journal — that's why the journal is fsynced far more often (100ms) than a full checkpoint runs (60s). Without the journal, a crash mid-checkpoint could lose everything written since the previous checkpoint, not just the last 100ms.

mongod crashes 30 seconds after the last checkpoint, with the journal enabled. How much data is lost?

Sizing the cache

1. Start from total RAM. A dedicated MongoDB host with 32 GB RAM is the baseline for this example.
2. Apply roughly 40–50% of RAM, not the raw default. WiredTiger's own default is (RAM − 1GB) × 0.5, but on a real production host you also want headroom for per-connection memory (allocated outside the WiredTiger cache) and the OS page cache. 32 GB → cacheSizeGB: 12–14 is a safer starting point than the raw default.
3. Watch eviction, not just hit ratio. db.serverStatus().wiredTiger.cache exposes "pages evicted because they exceeded the in-memory maximum" and "tracked dirty bytes in the cache." Rising eviction under normal load means the cache is undersized for the working set.
4. Never approach 100% of RAM. A saturated WiredTiger cache starves the OS page cache — which is what makes oplog reads and secondary catch-up fast — and starves per-connection buffers, which live outside WiredTiger entirely. The result is worse than a smaller, stable cache: constant eviction pressure and possible swapping.

A host has 64 GB RAM. Someone sets cacheSizeGB: 60 to "maximize" MongoDB's cache. What breaks in production?


Document Model and BSON

MongoDB stores documents as BSON (Binary JSON), not JSON text:

BSON document: {_id: ObjectId("..."), name: "Alice", age: 30}

Binary encoding:
[doc_length 4B][type 1B][key "name\0"][value "Alice"][type 1B][key "age\0"][value 30 4B]...[terminator 0x00]

Why BSON, not JSON: the length-prefixed encoding means WiredTiger (and any driver) can skip over a field without parsing its contents — it just reads the 4-byte length and jumps. BSON also has native types JSON lacks: Date, ObjectId, Binary, Decimal128 — a JSON encoding would have to represent all of these as strings and lose type fidelity.

ObjectId is 12 bytes: 4-byte timestamp + 5-byte random value + 3-byte incrementing counter. That construction is deliberate — it's sortable by creation time, globally unique without a central sequence generator, and safe to generate on any node (including offline clients) without coordination.

Two documents are inserted from different application servers within the same second, with no shared database sequence. Why don't their ObjectIds collide?


Replication: Oplog, Elections & Quorum

The oplog

The oplog is a capped collection in the local database (local.oplog.rs). Every write on the primary is recorded as an idempotent operation — replaying the same oplog entry twice produces the same result, which is what makes replica catch-up and resync safe to retry.

sequenceDiagram
    participant P as Primary — local.oplog.rs
    participant S1 as Secondary 1
    participant S2 as Secondary 2

    Note over P: client write commits locally first
    P->>P: append idempotent entry to oplog
    par tailing cursor per secondary
        S1->>P: tail oplog (long-lived cursor, not polling)
        P-->>S1: stream new entries as they're appended
        S1->>S1: apply entry, advance local optime
    and
        S2->>P: tail oplog (long-lived cursor)
        P-->>S2: stream new entries
        S2->>S2: apply entry, advance local optime
    end
    Note over S1,S2: each secondary's "optime" is how far it has replayed —<br/>this is exactly what rs.status() reports as replication lag

Op types: i (insert), u (update), d (delete), c (command — createCollection, dropCollection), n (no-op / keepalive, also used to fill in a stable point for chained replication).

Oplog window: capped at a fixed size — default 5% of disk space, minimum ~1 GB. If a secondary falls behind by more than the oplog window (its next needed entry has already rolled off), it can no longer catch up incrementally and must perform a full initial resync instead.

A secondary was network-partitioned for 6 hours. The primary's oplog only covers the last 4 hours of writes at current write volume. What happens when the secondary reconnects?

Elections and quorum

A replica set elects a primary by majority vote among voting members. An arbiter is a voting member that holds no data — it exists purely to break ties cheaply, without paying for a full extra data copy.

graph TD
    classDef primary fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef secondary fill:#3498db,stroke:#2471a3,color:#fff
    classDef arbiter fill:#f39c12,stroke:#ba6018,color:#fff

    HB["Secondary misses primary heartbeat<br/>(default electionTimeoutMillis: 10s)"] --> NOM["Eligible secondary calls for election<br/>(nominates itself as candidate)"]
    NOM --> VOTE["Every voting member casts one vote<br/>higher priority + more up-to-date optime wins ties"]
    VOTE --> MAJ{"Candidate got<br/>a majority of votes?"}
    MAJ -->|Yes| NEWP["Candidate becomes PRIMARY<br/>starts accepting writes"]:::primary
    MAJ -->|No — split vote or<br/>no majority reachable| RETRY["Election fails, cluster stays without<br/>a primary; retried after a randomized backoff"]

    subgraph "3-member set example"
        P["Primary (1 vote)"]:::primary
        S["Secondary (1 vote)"]:::secondary
        A["Arbiter (1 vote, no data)"]:::arbiter
    end

Why votes ≠ data copies: an arbiter's vote counts exactly the same as a data-bearing secondary's vote when computing whether a write reached w: "majority", or whether an election has a quorum — but it holds zero bytes of actual data. A 3-member Primary-Secondary-Arbiter (PSA) set only needs 2 of 3 votes to elect a new primary or acknowledge a majority write, and primary + arbiter alone satisfies that majority — even though the arbiter can never serve that data if the primary then dies.

3 voting members, only 2 hold data. Cheaper to run (the arbiter needs almost no resources), and still gets majority-write durability in the common case. The gap: if the primary and arbiter both acknowledge a write but the secondary hasn't replicated it yet, and the primary then dies, that write is gone — the arbiter has no copy to hand to a new primary.
3 voting members, all 3 hold data. Costs a full third data copy, but a majority write is guaranteed to exist on at least 2 real copies at all times — there's no "phantom vote" scenario. This is the safer topology when write durability matters more than infrastructure cost.

In a PSA set, the secondary is temporarily unreachable. A write arrives at the primary with w: "majority". Does it succeed, and is it fully safe?

Try It Yourself: Live PSA Election

This is a different election model than the Raft demo elsewhere in this repo (replication.md) — worth being precise about the difference rather than treating "leader election" as one interchangeable mechanic. Raft's demo picks whichever node happens to time out first and wins purely on term number and majority; the first candidate to campaign wins as long as its log qualifies. MongoDB's election is priority-weighted: each member has a configured priority (default 1, arbiters always 0), a member only calls an election after missing heartbeats for electionTimeoutMillis (default 10s), and a higher-priority secondary that is otherwise healthy can trigger its own election and take over from a lower-priority primary even with no failure at all — a "priority takeover," not just a race to time out first. The demo below models that, plus the PSA topology's specific risk: an arbiter votes but holds no data and can never itself become primary.

Default topology is PSA: P1 (priority 1, data), S1 (priority 1, data, starts as PRIMARY), A1 (arbiter — priority 0, no data). Click any node to kill or revive it, use "Kill Primary" to force a failover, or add a differently-prioritized secondary and watch it take over. Majority here is computed against the full configured voting membership (not just currently up nodes) — this is what makes the risk case below possible: kill the arbiter, then kill either remaining data node, and the cluster has only 1 of 3 votes left and cannot elect anyone.

Primary Secondary (up) Down Arbiter (diamond, no data) Click any node box to kill/revive it.

Rollback on rejoin

If the old primary had writes that were never replicated to any secondary before it crashed, and a new primary was elected and continued accepting new writes, the two oplogs have diverged. When the old primary rejoins as a secondary, MongoDB rolls back its un-replicated writes — moving them out to a rollback directory as BSON files rather than silently discarding them — so it can resync onto the new primary's oplog history.

After a rollback, where do the discarded writes actually go, and how would you recover one if it turned out to matter?


Write Concern and Read Concern — Deep Dive

Write concern controls how many replica set members must acknowledge a write before the client is told it succeeded. Getting this wrong is the single most common cause of "the failover ate my data."

sequenceDiagram
    participant APP as Application
    participant PRI as Primary
    participant SEC as Secondary

    Note over APP,SEC: writeConcern: {w: "majority", j: true}
    APP->>PRI: insert document
    PRI->>PRI: write to journal (j:true = fsync before ack)
    PRI->>SEC: replicate via oplog tailing
    SEC->>SEC: write to journal + apply
    SEC-->>PRI: acknowledge
    Note over PRI: majority (2/3 votes) confirmed
    PRI-->>APP: write result: confirmed

    Note over APP,SEC: readConcern: "majority"
    APP->>PRI: find({_id: ...}) with majority read concern
    PRI->>PRI: only return data already committed to a majority
    Note over PRI: won't return data that could still be rolled back
    PRI-->>APP: document (guaranteed stable, won't vanish on failover)
Fire and forget. mongod returns immediately without waiting for any acknowledgement — not even from the primary's own in-memory buffer. Maximum throughput. Fine for metrics/logs/events where losing a few is acceptable; never for financial, user, or transactional data.
Primary only (default before MongoDB 5.0). Fastest option that still confirms the write landed somewhere. Risk: if the primary crashes before replicating to any secondary, the write is lost — whichever secondary gets elected next never had it.
Majority of voting members (recommended). At least 2 of 3 votes must acknowledge before the client sees success. If the primary then fails, whoever gets elected next already has the data. One extra round-trip of latency, in exchange for a durability guarantee that survives failover.
Write concern Data loss on failover Latency
{w: 0} Yes, silently Lowest
{w: 1} Yes, if only the primary had it Low
{w: "majority"} No +1 replica round-trip
{w: "majority", j: true} No, and disk-durable Highest

Why does pairing w: "majority" with j: true matter, when majority already implies more than one copy exists?


Aggregation Pipeline

An aggregation is a sequence of stages, each one transforming the document stream from the previous stage:

graph LR
    COLL["orders collection"] --> MATCH["$match<br/>{status:'paid', created_at:{$gte:...}}"]
    MATCH --> LOOKUP["$lookup<br/>join users by user_id"]
    LOOKUP --> UNWIND["$unwind<br/>flatten joined array"]
    UNWIND --> GROUP["$group<br/>{_id:'$user_id', total:{$sum:'$amount'}}"]
    GROUP --> MATCH2["$match<br/>post-group filter: total >= 1000"]
    MATCH2 --> SORT["$sort<br/>{total:-1}"]
    SORT --> LIMIT["$limit: 100"]
    LIMIT --> OUT["Result stream"]
1. Early $match. Filtering before anything else shrinks the document set every later stage has to process — and, critically, an early $match can use an index the same way a normal find() would. A $match placed after other stages can't.
2. $lookup + $unwind. $lookup joins in an array of matching documents from another collection (like a left outer join); $unwind flattens that array back into one document per match so later stages can treat it as a flat field.
3. $group. Collapses many documents into one per group key, computing accumulators ($sum, $avg, $push, ...) along the way. This is usually the point where the pipeline stops being index-eligible — the output no longer resembles the original documents.
4. Post-group $match, $sort, $limit. Filtering and sorting on computed fields (like the group's total) has to happen after $group produces them — there's no index to use here since these are runtime-computed values.
db.orders.aggregate([
    {$match:  {status:"paid", created_at:{$gte: new Date("2024-01-01")}}},  // filter early, index-eligible
    {$lookup: {from:"users", localField:"user_id", foreignField:"_id", as:"user"}},
    {$unwind: "$user"},
    {$group:  {_id:"$user_id", total:{$sum:"$amount"}, count:{$sum:1}}},
    {$match:  {total:{$gte:1000}}},   // post-group filter, no index available here
    {$sort:   {total:-1}},
    {$limit:  100},
    {$project:{_id:0, user_id:"$_id", total:1, count:1}}
], {allowDiskUse: true})   // needed once an intermediate stage's working set exceeds 100MB RAM

explain('executionStats') on an aggregation shows exactly which stages used an index (IXSCAN) versus a full scan — the same distinction as a plain find().

Why can't the $match after $group in the pipeline above use an index, even though there's an index on total?


Index Types

// Standard B-tree
db.users.createIndex({email: 1})                    // ascending
db.users.createIndex({email: 1, status: 1})         // compound
db.users.createIndex({email: 1}, {unique: true})    // unique

// Partial index — only indexes matching documents, smaller and faster
db.users.createIndex({email: 1}, {
    partialFilterExpression: {deleted: {$exists: false}}
})

// TTL index — auto-deletes documents N seconds after createdAt
db.sessions.createIndex({createdAt: 1}, {expireAfterSeconds: 3600})

// Text index — full-text search
db.posts.createIndex({body: "text", title: "text"})
db.posts.find({$text: {$search: "kubernetes failover"}})

// Geospatial
db.locations.createIndex({coords: "2dsphere"})
db.locations.find({coords: {$near: {$geometry: {type:"Point", coordinates:[77.2,28.6]}, $maxDistance: 1000}}})

A TTL index is set with expireAfterSeconds: 3600 on a field called createdAt. A background job deletes stale sessions after exactly 60 minutes, right on the second. Is that guaranteed?


explain() — Query Analysis

// Find execution plan
db.orders.find({user_id: "123", status: "paid"}).explain("executionStats")
// winningPlan.stage: "COLLSCAN" = full scan (bad) | "IXSCAN" = index (good)
// executionStats.totalDocsExamined vs totalDocsReturned: large ratio = missing index

// Compound index matching the query shape
db.orders.createIndex({user_id: 1, status: 1})

// Covering index: every projected field is in the index itself — zero document fetches
db.orders.createIndex({user_id: 1, status: 1, amount: 1})
db.orders.find({user_id: "123"}, {status: 1, amount: 1, _id: 0})
// explain shows "Using index" — no heap/document reads at all

explain() shows totalDocsExamined: 50,000 and totalDocsReturned: 12 for a query that IS using an index (IXSCAN, not COLLSCAN). Is that fine?


Transactions (4.0+)

const session = db.getMongo().startSession();
session.startTransaction({ readConcern: {level: "snapshot"}, writeConcern: {w: "majority"} });
try {
    const accounts = session.getDatabase("bank").accounts;
    accounts.updateOne({_id: "alice"}, {$inc: {balance: -100}}, {session});
    accounts.updateOne({_id: "bob"},   {$inc: {balance:  100}}, {session});
    session.commitTransaction();
} catch (err) {
    session.abortTransaction();
    throw err;
} finally {
    session.endSession();
}

readConcern: "snapshot" gives every read inside the transaction a consistent point-in-time view — as if the whole transaction ran instantaneously — even though the two updateOne calls execute sequentially.

The commitTransaction() call above uses writeConcern: {w: "majority"}. If the primary crashes between the two updateOne calls and before commitTransaction, what happens to Alice's already-decremented balance?


Change Streams

A real-time feed of insert/update/delete events, built directly on top of the oplog.

const stream = db.orders.watch([
    {$match: {"operationType": {$in: ["insert", "update"]}}},
    {$match: {"fullDocument.status": "paid"}}
]);
stream.on("change", change => processOrder(change.fullDocument));

// Crash recovery: persist the resumeToken, restart from exactly where you left off
const stream2 = db.orders.watch([], {resumeAfter: lastToken});

A change-stream consumer crashes and restarts 20 minutes later without a saved resumeToken. What happens to the events it missed?


Sharding

graph TD
    classDef router fill:#8e44ad,stroke:#6c3483,color:#fff
    classDef cfg fill:#f39c12,stroke:#ba6018,color:#fff
    classDef shard fill:#2980b9,stroke:#1f618d,color:#fff

    APP["Application"] --> MONGOS["mongos router<br/>stateless — no data of its own"]:::router
    MONGOS -->|"chunk map lookup"| CFG["Config server replica set<br/>authoritative chunk-to-shard mapping"]:::cfg
    MONGOS -->|"routes query to the<br/>right shard(s) only"| S1["Shard 1 replica set<br/>user_id: 0 – 500K"]:::shard
    MONGOS --> S2["Shard 2 replica set<br/>user_id: 500K – 1M"]:::shard
    CFG -.->|"balancer moves chunks<br/>to keep shards even"| S1
    CFG -.-> S2
sh.enableSharding("mydb")
sh.shardCollection("mydb.orders", {user_id: "hashed"})
// hashed  = even write distribution, but range queries scatter across every shard
// ranged  = supports efficient range queries, but monotonically increasing keys hotspot one shard

sh.getBalancerState()  // is the auto-balancer currently moving chunks?
MongoDB hashes the key before assigning it to a chunk range, so writes spread evenly across every shard regardless of the key's natural distribution. The cost: a query for a range of the original key (e.g. "all orders from March") can no longer target one shard — it has to scatter-gather across all of them, since hashed values don't preserve ordering.
Chunks are contiguous ranges of the actual key value, so range queries stay efficient — a query for "user_id between 100K and 200K" targets exactly the shard(s) holding that range. The cost: a monotonically increasing key (like an auto-incrementing ID or a timestamp) sends every new write to whichever shard currently holds the highest range — a hotspot, not distributed at all.

Try It Yourself: Live Chunk Split & Migration

Pick a shard-key type, then insert values yourself. Try a monotonically increasing sequence (1, 2, 3, 4, …) under each mode and watch what happens to where the documents land — this is the same mechanism the toggle above describes and the quiz below tests, just running live instead of asserted in prose. Once one shard is visibly overloaded, click "Run balancer" and watch it split that shard's biggest chunk and migrate half of it to the least-loaded shard.

Just inserted Just split/migrated Each box is a chunk (key range + doc count); the bar shows load relative to the busiest chunk. Past 4 chunks on one shard, extras collapse into a "+N chunks" summary so the layout never overflows.

A collection is sharded on {createdAt: 1} (ranged) because "we need to query recent orders fast." Six months in, one shard is consistently at 90% disk while the others sit at 20%. Why?


Monitoring

db.currentOp({secs_running: {$gt: 5}})   // find long-running ops
db.killOp(opid)
db.setProfilingLevel(1, {slowms: 100})   // log queries slower than 100ms
db.system.profile.find().sort({ts: -1}).limit(10)
rs.status()                              // replica set health + per-member replication lag