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 实现,解决的都是「让读者看到一致的过去」,但代价分布完全不同:
| 引擎 | 版本存放位置 | 回收机制 | 典型代价 |
|---|---|---|---|
| PostgreSQL | heap 内原地追加 tuple | VACUUM 清理 dead tuple | 表膨胀、vacuum IO 抖动 |
| InnoDB | 独立 undo log + rollback segment | purge 线程 | 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;
}
三个工程要点:
WT_UPDATE_MODIFY是双刃剑。$inc只往链头挂一个增量 delta,代价 O(1);但读的时候必须把 delta 一层层应用到某个STANDARD基准值上,链长 100 就是 100 次 apply。所以 WiredTiger 会在链长或累计大小超阈值时触发 reconciliation,把整条链物化成一个新的完整值——这是一个隐藏的 CPU 尖峰来源。- 没有 purge 线程。旧版本只在 checkpoint 推进 stable timestamp、或 RTS 时被摘除。好处是没有后台线程和你抢 IO,坏处是长事务会把链无限拉长。
- 链在内存里。重启后 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 期间的并发修改仍在进行。
崩溃恢复路径因此是三段式:
- 找到最后一个完整 checkpoint;
- 从 checkpoint LSN 开始重放 journal;
- 执行 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_target | 80% | cache 使用率回落到该值即停止激进淘汰 |
| eviction_trigger | 95% | 超过后应用线程被迫参与 eviction |
| eviction_dirty_target | 5% | 脏页占比目标 |
| eviction_dirty_trigger | 20% | 超过后写请求被限流(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
九、生产调优化清单
- 压缩:写密集 OLTP 用
snappy(默认,CPU 省);读多写少或磁盘紧张用zstd,page 级重压缩收益显著。 - cache:容器里务必显式设置
cacheSizeGB。WiredTiger 早期版本按宿主机内存计算默认值,在 K8s 里会直接 OOMKill。 - checkpoint:写密集场景把
wait从 60 降到 30,让脏页更平滑地落盘,代价是 journal 回收更频繁。 - 索引:每个二级索引都是一棵独立的 WiredTiger B-tree,写放大按索引数线性叠加。删除冗余索引的收益远大于任何参数调优。
- 长事务:设置
transactionLifetimeLimitSeconds(默认 60s)并监控oldest active transaction年龄,这是磁盘膨胀的第一根因。 - 文件系统:XFS > ext4(大文件删除与并发写更好),挂载加
noatime。
十、结论
WiredTiger 的设计取舍非常清晰:用内存里的 update 链表换取无后台回收线程的低抖动,用时间戳事务换取复制与快照读的统一,用 RTS 换取无 undo log 的简洁性。这套取舍在读多写少、文档结构灵活的工作负载上极其优秀,但它把三个代价显式地留给了运维——page 级写放大、长事务导致的版本链膨胀、以及 dirty 比例触发的 write stall。
理解这四层之后,「MongoDB 慢」就不再是玄学:先看 pages evicted by application threads 是否非零,再看 oldest timestamp 是否被 pin,最后算一遍索引数量带来的写放大。真正的瓶颈几乎总在这三者之中,而不在查询本身。

发表评论 取消回复