MongoDB 系列 · 第一篇

MongoDB 整体架构:mongod 自己不管数据怎么存

mongod 这个进程,做的事其实没有听起来那么多:接收请求、解析命令、决定查询计划,但"数据到底怎么落到磁盘上"这件事,它完全不管——那是一层叫存储引擎(storage engine)的可插拔组件的工作,默认是 WiredTiger。这条边界划得很干净:collection 是什么、document 长什么样,这是 mongod 的概念;B-tree、页(page)、MVCC、checkpoint,这些是 WiredTiger 的概念,mongod 压根不知道。

这一篇在本机真实装了 MongoDB Community 8.3.7,插入数据后直接去看磁盘上真实生成的文件,再对照 MongoDB Server 真实源码(v8.3.7 标签),把这条"从一条 insert 命令到磁盘文件"的链路过一遍。

本机真实 8.3.7
MongoDB Community + WiredTiger,插入数据后直接检查磁盘上的真实文件
3 处真实源码
可插拔存储引擎接口、RecordStore 抽象、collection 与 index 写入的真实先后顺序

先看两层:mongod 和存储引擎

这条边界是理解 MongoDB 后面所有机制(索引、聚合、复制)的前提。

mongod

协议、命令、查询计划

解析 MongoDB Wire Protocol 发来的命令、做认证鉴权、决定一个查询该用哪个索引(query planner)——这一层完全不关心数据最终以什么格式写在磁盘的哪个位置。

存储引擎

B-tree、页、MVCC、checkpoint

默认是 WiredTiger(3.2 版本之后),负责把文档真正落盘、维护索引的物理结构、管理并发事务的可见性。这一层完全不知道"聚合管道""分片"这些概念,它只认识 key-value。

连接方式

一套抽象接口,不是写死的调用

mongod 通过 RecordStoreSortedDataInterface 这类抽象接口和存储引擎打交道——理论上可以换成别的引擎(历史上 MongoDB 确实支持过 MMAPv1),而不用改上层任何查询逻辑。

真实实验:确认本机跑的就是 WiredTiger

本机装的是 MongoDB Community 8.3.7,直接问它自己在用哪个存储引擎。

真实实测
$ mongosh --quiet --eval "db.serverStatus().storageEngine"
{ name: 'wiredTiger', supportsCommittedReads: true, supportsSnapshotReadConcern: true, persistent: true, ... } # 编译进这个 mongod 二进制、理论上可选的存储引擎有哪些: $ mongosh --quiet --eval "db.serverBuildInfo().storageEngines" [ 'devnull', 'wiredTiger' ]

devnull 是个只用于测试的"黑洞"引擎(写入直接丢弃),真正干活的只有 wiredTiger 一个——这份列表本身就是"存储引擎可插拔"这句话最直接的证据:mongod 在启动时看配置决定加载哪一个,不是编译期写死成某一个具体实现。

真实源码:存储引擎是怎么"插"进去的

每个存储引擎实现向全局注册一个 Factory,mongod 启动时按配置参数选一个来用。

src/mongo/db/storage/storage_engine.h · mongodb/mongo @ r8.3.7, L106 /** * The interface for creating new instances of storage engines. * * A storage engine provides an instance of this class (along with an associated * name) to the global environment, which then sets the global storage engine * according to the provided configuration parameter. */ class Factory { public: virtual ~Factory() {} virtual std::unique_ptr<StorageEngine> create(OperationContext* opCtx, const StorageGlobalParams& params, ...) = 0; };

这段注释把设计意图写得很直白:存储引擎"提供一个 Factory 实例给全局环境",然后 mongod 根据配置参数(也就是 --storageEngine 或配置文件里的 storage.engine)决定实际用哪一个。WiredTiger 只是众多可能实现里,现在被设为默认的那一个。

真实源码 + 真实文件:一个 collection 对应一棵独立的 B-tree

RecordStore 是 mongod 和存储引擎之间关于"怎么存文档"的抽象接口——一个 collection 一个 RecordStore,每个 index 也各自有自己的存储结构。

src/mongo/db/storage/record_store.h · mongodb/mongo @ r8.3.7, L296 /** * An abstraction used for storing documents in a collection or entries in an index. * * In storage engines implementing the KVEngine, record stores are also used for implementing * catalogs. * ... */ class MONGO_MOD_OPEN RecordStore { public: class Capped; class Oplog; ... };

光看源码还是抽象,直接去磁盘上验证。本机建一个 orders collection,插入几条文档,再建一个索引:

真实实测
$ mongosh --quiet blogdemo --eval '
  db.orders.insertMany([
    { _id: 1, item: "pen", qty: 10, tags: ["office"] },
    { _id: 2, item: "laptop", qty: 1, specs: { cpu: "M4", ram: 16 } },
    { _id: 3, item: "paper", qty: 500 }
  ]);
  db.orders.createIndex({ item: 1 });
'
{ acknowledged: true, insertedIds: { '0': 1, '1': 2, '2': 3 } } item_1 # 磁盘上的 dbPath 目录里,真实多出来的文件: $ ls /opt/homebrew/var/mongodb/ collection-e8866683-2fd4-4060-9d40-031bcc80b6d8.wt ← orders 这个 collection 自己的 B-tree 文件 index-...-b6ab.wt ← _id 索引,自己的 B-tree 文件 index-...-085d.wt ← item 索引,自己的 B-tree 文件 _mdb_catalog.wt ← 目录:名字 -> 具体是哪个 .wt 文件 journal/ ← write-ahead log,不是这三个 B-tree 文件的一部分 # 确认这真的是一个 WiredTiger B-tree 文件,不是随便一个占位文件(看文件头的 magic number): $ xxd collection-e8866683-2fd4-4060-9d40-031bcc80b6d8.wt | head -1 00000000: 41d8 0100 0100 0000 d808 23b7 0000 0000 A...............

一个 collection、两个 index,磁盘上是三个完全独立.wt 文件——这不是巧合或者实现细节,是 RecordStore(用于 collection)和索引各自独立存储这个设计的直接物理体现。_mdb_catalog.wt 存的是"orders 这个名字对应哪个 UUID、进而对应哪个物理文件"这层映射,这也是为什么你在 mongosh 里操作的是名字,底层文件名却是一串 UUID。

真实源码:写一条文档,collection 和 index 谁先谁后

插入一条文档时,先写 collection 拿到一个 RecordId,再拿这个 RecordId 去建索引条目——顺序不能反,因为索引条目本身存的就是"指向哪条记录"。

src/mongo/db/collection_crud/collection_write_path.cpp · mongodb/mongo @ r8.3.7, L325 Status status = collection->getRecordStore()->insertRecords( opCtx, *shard_role_details::getRecoveryUnit(opCtx), &records, timestamps);
src/mongo/db/collection_crud/collection_write_path.cpp · mongodb/mongo @ r8.3.7, L378 collection->getIndexCatalog()->indexRecords(opCtx, collection, bsonRecords, &keysInserted);

L325 先把文档写进 collection 自己的 RecordStore,拿到一个 RecordId(标识这条记录在这个 B-tree 里的物理位置);L378 才拿着这批 RecordId 去更新每一个索引——每条索引条目本质是"索引字段的值 → RecordId"这样一对键值,没有 RecordId 就没法建索引条目,顺序天然是先 collection 后 index。

真实实验:文档模型是真的没有固定 schema

上面插入的三条文档,字段结构完全不一样,MongoDB 不会因此报错。

真实实测
$ mongosh --quiet blogdemo --eval "db.orders.find().toArray()"
[ { _id: 1, item: 'pen', qty: 10, tags: [ 'office' ] }, { _id: 2, item: 'laptop', qty: 1, specs: { cpu: 'M4', ram: 16 } }, { _id: 3, item: 'paper', qty: 500 } ]

第一条有 tags 数组,第二条有嵌套的 specs 对象,第三条两个都没有——同一个 collection,三种不同的文档形状,插入时没有任何 schema 校验拦截。这是 BSON 文档模型的直接后果:collection 只是"一堆文档的集合",不是关系型数据库里"一张有固定列的表"。

schema 校验不是不能做,而是默认不做——MongoDB 支持在 collection 级别配置 $jsonSchema 校验规则,但这是可选的、显式开启的约束,不是文档模型本身自带的限制。

演示:一条 insert 命令,分层往下走

按上面两段真实源码验证过的先后顺序,把 db.orders.insertOne(...) 拆成分层的步骤。

insert 命令的分层执行未开始
点击"下一步"或"播放"开始。

事件数据由 Python 脚本生成并自检过:索引写入严格晚于 collection 写入、且必须引用同一个 RecordId(对应上面 L325/L378 的真实顺序);journal 落盘晚于所有存储层写入。一套独立重写的重放函数核对过全部结果,断言通过后才用于渲染。

参考与说明

  • 本文源码引用(StorageEngine::FactoryRecordStore 抽象、collection_write_path.cpp 里 collection 与 index 写入的真实先后顺序)均取自 mongodb/mongo 仓库 r8.3.7 标签,与本机安装的 MongoDB Community 8.3.7 版本一致,直接从 GitHub 拉取源文件核对过函数签名、关键调用和行号。
  • 磁盘文件、serverStatus/serverBuildInfo 输出、文档插入结果均为本机真实 mongosh 会话产生,未做删改。
  • 演示数据的自检:每条索引写入都严格晚于对应的 collection 写入、且引用同一个 RecordId;journal 落盘事件晚于它需要保护的所有写入;用一套完全独立的重放函数重新聚合"最终写了什么",和主计算路径的结果核对一致。
  • 没有涉及:WiredTiger 内部 B-tree 页结构与 MVCC 具体实现(下下篇计划专门写)、真实的 checkpoint 触发时机与刷盘策略、副本集场景下 journal 和 oplog 的关系(后面选举/一致性两篇会展开)、$jsonSchema 校验规则的具体语法。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电