MongoDB WiredTiger 存储引擎深度工程实战:从文档级 MVCC、Journal WAL 到 Checkpoint 与 Rollback-to-Stable 的全链路解析

执行摘要:大多数人对 MongoDB 的理解停留在「文档模型 + 无 schema」,但真正决定它在生产里是跑 5 万 QPS 还是卡在 3000 QPS 的,是底下的 WiredTiger 存储引擎。WiredTiger 不是「又一个 B-tree」:它用内存中的 update structure 链表实现文档级 MVCC,用时间戳事务把副本集复制、快照读、多文档事务统一到一套并发控制里,用 fuzzy checkpoint + Rollback-to-Stable 取代传统 undo log 与 purge 线程。本文拆开这四层机制,解释为什么 MongoDB 的写放大比 MySQL 高一个量级、为什么一个长事务能让磁盘悄悄涨 40%、以及 eviction 卡死时到底该调哪个参数。


一、文档引擎的 MVCC 与关系引擎根本不是一回事

先建立一个对照坐标系。三种主流 MVCC 实现,解决的都是「让读者看到一致的过去」,但代价分布完全不同:

引擎版本存放位置回收机制典型代价
PostgreSQLheap 内原地追加 tupleVACUUM 清理 dead tuple表膨胀、vacuum IO 抖动
InnoDB独立 undo log + rollback segmentpurge 线程history list 过长导致读变慢
WiredTiger内存 KV 的 update 链(MVCC list)Rollback-to-Stable / reconciliation长事务 pin 住历史,链变长

关键差异来自文档模型。关系表里一行是定长结构的镜像,更新可以整体替换;MongoDB 的文档是变长、可嵌套、可含数组的 BSON,{$inc: {stock: -1}} 这类操作只修改文档中的一小段。WiredTiger 因此把「更新」抽象成修改操作(modification)而不是「新版本镜像」——链上挂的是一个 delta,不是整份文档。对热点计数器这种场景,这省下了大量内存和 WAL 字节数。


二、四层架构:WiredTiger 在 MongoDB 里的位置

MongoDB 层      : 复制 / 分片 / oplog / 查询计划 / 索引选择
                  ↓  (KVEngine 抽象接口)
WiredTiger      : B-tree / LSM · Journal(WAL) · Cache · Checkpoint
                  ↓
文件系统        : ext4 / XFS · 建议关闭 atime · 直接 IO 由 WiredTiger 管理

一个容易被忽略的事实:oplog 本身也是一个普通的 WiredTiger 集合。一次写操作会同时写用户集合和 local.oplog.rs,但二者在同一个 WiredTiger 事务里提交,所以原子性由存储引擎保证,而不是靠上层两阶段提交。这也是为什么 MongoDB 单文档写入天然原子——它本来就是一次 KV 事务。


三、磁盘格式:32KB page 与「双表示」

WiredTiger 的磁盘结构是 B-tree,leaf page 默认 32KB(对比 InnoDB 的 16KB)。每个 page 内部以 cell 为单位存放 key/value,支持两种压缩:

  • 块压缩(snappy / zstd / zlib):整个 page 压缩后落盘
  • 前缀压缩(prefix compression):相邻 key 共享公共前缀

内存里则是完全不同的表示:page 被读入 cache 时解压、转换为 WT_PAGE 的 in-memory 结构,所有修改作用于内存页,只在 checkpoint 或 eviction 时重新压缩写回磁盘。

这里埋着 MongoDB 写放大的根因:B-tree 的写放大是 page 级的。改一个 200 字节的文档,最终可能导致整个 32KB page 被重新压缩并写盘;若该 page 是 internal page,还会牵连子节点。这就是为什么同样硬件上,MongoDB 的磁盘写字节数常常是业务数据量的 5–15 倍,也解释了为什么 zstd 在这类负载下反而比 snappy 更划算——CPU 换 IO 在 page 粒度上非常值。


四、Update Structure:文档级 MVCC 的核心

WiredTiger 里一个 K/V 的 value 不是单个值,而是一条 WT_UPDATE 单向链表,最新版本在链头:

// 简化自 WiredTiger 源码 (src/include/btmem.h)
struct __wt_update {
    wt_timestamp_t start_ts;    // 版本生效时间戳
    wt_timestamp_t durable_ts;  // 落盘时间戳
    uint64_t       txnid;       // 产生该版本的事务 ID
    uint8_t        type;        // WT_UPDATE_STANDARD | MODIFY | TOMBSTONE
    uint8_t        prepare_state;
    WT_UPDATE     *next;        // 指向更旧的版本
    uint32_t       size;
    uint8_t        data[];      // 完整值 或 modify delta
};

读取时,读事务持有一个快照(read timestamp),沿链从头走到第一个可见节点:

// 可见性判断(简化)
static bool visible(WT_UPDATE *up, WT_TXN *txn) {
    // 1) 自己未提交的修改对自己可见
    if (up->txnid == txn->id) return true;
    // 2) 时间戳比我快照新 → 不可见(注意:这意味着"未来版本"要跳过,不是回滚)
    if (txn->read_timestamp < up->start_ts) return false;
    // 3) 已回滚 / 未提交的他人事务 → 不可见
    if (up->txnid >= txn->snap_max) return false;
    if (in_snapshot_array(up->txnid, txn->snapshot)) return false;
    // 4) 墓碑标记 → 文档已删除
    return up->type != WT_UPDATE_TOMBSTONE;
}

三个工程要点:

  1. WT_UPDATE_MODIFY 是双刃剑。$inc 只往链头挂一个增量 delta,代价 O(1);但读的时候必须把 delta 一层层应用到某个 STANDARD 基准值上,链长 100 就是 100 次 apply。所以 WiredTiger 会在链长或累计大小超阈值时触发 reconciliation,把整条链物化成一个新的完整值——这是一个隐藏的 CPU 尖峰来源。
  2. 没有 purge 线程。旧版本只在 checkpoint 推进 stable timestamp、或 RTS 时被摘除。好处是没有后台线程和你抢 IO,坏处是长事务会把链无限拉长。
  3. 链在内存里。重启后 update 链消失,只剩磁盘上已 checkpoint 的版本——这正是为什么崩溃恢复必须走 Rollback-to-Stable。

五、时间戳事务:把复制与快照读统一起来

WiredTiger 从 3.0 起引入时间戳事务(timestamped transactions),这是 MongoDB 4.0 多文档事务和 readConcern: majority 的底座。四个关键时间戳:

时间戳含义影响
oldest_timestamp最老的、仍可能被读取的快照早于它的历史版本可被回收;被长事务 pin 住则磁盘膨胀
stable_timestamp已确认可持久化的时间点checkpoint 的上界,RTS 的目标
commit timestamp事务的提交时刻决定读写可见顺序
durable timestamp事务日志已落盘的时刻决定 majority commit point

读快照是一个三元组 (snap_min, snap_max, 并发写事务 ID 数组),与 PostgreSQL 的 xmin/xmax/xip_list 同构。MongoDB 把 WiredTiger 的 all_durable 时间戳暴露为 majority commit point,副本集的 readConcern: majority 就是在这个时间点上开快照。

生产里最贵的坑在这里:一个跑了 30 分钟的 analytics 查询、一个没关的 change stream、或一个挂住的批量迁移,都会 pin 住 oldest_timestamp。此后所有更新产生的历史版本都无法回收,update 链持续变长、cache 命中率下降、磁盘悄悄涨几十 GB,而 db.stats() 看起来「数据量没变」。


六、Journal:WAL 与 oplog 的双重身份

WiredTiger 的 journal(journal/WiredTigerLog.*)是物理 WAL,每条 record 带 LSN 与 checksum。提交路径:

写操作 → 内存 update 链 → 生成 journal record(含 oplog 那一条)
                        ↓
              commitTimestamp 赋值
                        ↓
        j:true  → fsync journal(默认 100ms 组提交)
        j:false → 等下一次 group commit

两个容易混淆的点:

  • journal 不是 oplog。journal 是存储引擎的物理重做日志,用于本机崩溃恢复;oplog 是逻辑复制流,用于副本集同步。二者都写盘,但在同一事务内,因此不会不一致。
  • 日志文件回收依赖 checkpoint。只有 checkpoint 完成、且 LSN 已被覆盖的 journal 文件才能被归档删除。如果 checkpoint 卡住(见下节),journal 目录会持续增大直到撑爆磁盘。

七、Checkpoint 与 Rollback-to-Stable

WiredTiger 采用 fuzzy checkpoint:默认每 60 秒、或 journal 累计 2GB 触发一次,后台线程把脏页刷盘,最后写入一个包含 checkpoint LSN 的元数据块。它不冻结写入,因此 checkpoint 期间的并发修改仍在进行。

崩溃恢复路径因此是三段式:

  1. 找到最后一个完整 checkpoint;
  2. 从 checkpoint LSN 开始重放 journal;
  3. 执行 Rollback-to-Stable(RTS):扫描所有内存页的 update 链,把 start_ts > stable_timestamp 的修改全部摘掉。

RTS 是 WiredTiger 取代 undo log 的关键设计——回滚不是「反向执行」,而是「丢弃未来」。代价是启动阶段要遍历脏页,副本集发生 rollback(主节点降级前写入未复制)时,RTS 的耗时可能达到秒级到十秒级,表现为节点重启后长时间不可用。

这也是为什么 MongoDB 在 4.0 之前无法支持多文档事务:没有时间戳事务与 RTS,跨文档回滚无从实现。


八、Cache 与 Eviction:最常出事的一层

WiredTiger 拥有独立的 page cache,默认 max(256MB, 50% RAM - 1GB),刻意不依赖 OS page cache(推荐配合 direct_io 或至少保证 OS cache 不重复占用内存)。核心参数:

参数默认值语义
eviction_target80%cache 使用率回落到该值即停止激进淘汰
eviction_trigger95%超过后应用线程被迫参与 eviction
eviction_dirty_target5%脏页占比目标
eviction_dirty_trigger20%超过后写请求被限流(write stall)

当 dirty 比例逼近 trigger,写吞吐会断崖式下跌——这是 MongoDB 最典型的「莫名其妙变慢」。根因通常不是磁盘慢,而是:

  • 长事务 pin 住历史版本,脏页无法被 evict(页上存在其他读者仍需要的旧版本);
  • checkpoint 太稀疏(checkpoint=(wait=60) 在写密集场景偏长),脏页堆积;
  • 压缩算法过重,reconciliation 时 CPU 打满,eviction 线程跟不上。

诊断入口很直接:

// mongosh:一眼看清 cache 健康度
const c = db.serverStatus().wiredTiger.cache;
print({
  bytesInCache:   (c["bytes currently in the cache"] / 1e9).toFixed(2) + " GB",
  maxBytes:       (c["maximum bytes configured"] / 1e9).toFixed(2) + " GB",
  dirtyPct:       (c["tracked dirty bytes in the cache"] / c["maximum bytes configured"] * 100).toFixed(1),
  pagesEvicted:   c["pages evicted by application threads"],   // 非 0 即是危险信号
  evictionWalk:   c["pages walked for eviction"]
});

// 找出 pin 住 oldest timestamp 的元凶
db.adminCommand({ currentOp: true, $or: [
  { "op": "query", "secs_running": { $gt: 60 } } ] })
  .inprog.forEach(op => print(op.opid, op.secs_running, op.ns, op.command?.filter));

// 观察集合级别的写放大
db.orders.stats({ scale: 1 }).wiredTiger.uri;
db.serverStatus().wiredTiger["cache-write"];   // 应用线程因 cache 压力而阻塞的次数

写冲突在 WiredTiger 里是乐观并发:两个事务改同一文档不会加锁等待,后提交者直接收到 WriteConflict 并在驱动层重试。高频热点更新必须在应用层做退避:

import pymongo, random, time
from pymongo.errors import WriteConflict, OperationFailure

def decr_stock(col, sku: str, n: int, max_retry: int = 8) -> bool:
    """WiredTiger 乐观并发下的正确重试姿势:指数退避 + 抖动"""
    delay = 0.002
    for attempt in range(max_retry):
        try:
            r = col.update_one(
                {"_id": sku, "stock": {"$gte": n}},
                {"$inc": {"stock": -n}}          # 走 WT_UPDATE_MODIFY,只挂增量 delta
            )
            return r.modified_count == 1
        except (WriteConflict, OperationFailure) as e:
            if "WriteConflict" not in str(e):
                raise
            # 关键:抖动避免多个重试者同步共振,形成新的冲突风暴
            time.sleep(delay * (1 + random.random()))
            delay = min(delay * 2, 0.2)
    return False

九、生产调优化清单

  1. 压缩:写密集 OLTP 用 snappy(默认,CPU 省);读多写少或磁盘紧张用 zstd,page 级重压缩收益显著。
  2. cache:容器里务必显式设置 cacheSizeGB。WiredTiger 早期版本按宿主机内存计算默认值,在 K8s 里会直接 OOMKill。
  3. checkpoint:写密集场景把 wait 从 60 降到 30,让脏页更平滑地落盘,代价是 journal 回收更频繁。
  4. 索引:每个二级索引都是一棵独立的 WiredTiger B-tree,写放大按索引数线性叠加。删除冗余索引的收益远大于任何参数调优。
  5. 长事务:设置 transactionLifetimeLimitSeconds(默认 60s)并监控 oldest active transaction 年龄,这是磁盘膨胀的第一根因。
  6. 文件系统:XFS > ext4(大文件删除与并发写更好),挂载加 noatime。

十、结论

WiredTiger 的设计取舍非常清晰:用内存里的 update 链表换取无后台回收线程的低抖动,用时间戳事务换取复制与快照读的统一,用 RTS 换取无 undo log 的简洁性。这套取舍在读多写少、文档结构灵活的工作负载上极其优秀,但它把三个代价显式地留给了运维——page 级写放大、长事务导致的版本链膨胀、以及 dirty 比例触发的 write stall。

理解这四层之后,「MongoDB 慢」就不再是玄学:先看 pages evicted by application threads 是否非零,再看 oldest timestamp 是否被 pin,最后算一遍索引数量带来的写放大。真正的瓶颈几乎总在这三者之中,而不在查询本身。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部