Skip to content

MongoDB 核心全过程原理

MongoDB 入门很容易:插入 JSON、查询 JSON、更新字段。但真正上线后,问题往往不是“CRUD 不会写”,而是:

  1. 文档到底该嵌入还是拆集合。
  2. 查询为什么明明有条件还是扫很多文档。
  3. 复合索引字段顺序为什么影响巨大。
  4. 聚合管道为什么越写越慢。
  5. 副本集主从切换时读写会发生什么。
  6. 分片键选错为什么会热点。
  7. MongoDB 事务能不能像关系库一样随便用。
  8. 慢查询、文档过大、索引膨胀、复制延迟怎么排查。

这一页把 MongoDB 的主线串起来:文档模型、读写过程、索引、聚合、副本集、分片、事务和生产排查。

学习目标

问题学完后要能说清
MongoDB 适合什么文档天然聚合、字段变化、读多写多、半结构化数据
文档怎么设计根据读取方式决定嵌入或引用,避免无限增长数组
查询怎么执行解析条件、选择执行计划、IXSCAN 或 COLLSCAN、FETCH 文档
写入怎么执行Driver 发请求,Primary 写入,Journal 持久化,复制到 Secondary
索引怎么设计从查询模式出发,关注复合索引顺序、排序和扫描量
聚合管道怎么优化$match、再 $project、最后 $group/$sort,避免大数据进入后段
副本集解决什么高可用和读扩展,Primary 写入,Secondary 复制
分片解决什么容量和吞吐扩展,但分片键决定数据分布和热点
事务怎么用支持多文档事务,但不能滥用成关系库复杂事务

MongoDB 总体架构

mermaid
flowchart TD
    A["应用服务"] --> B["MongoDB Driver"]
    B --> C["mongod 节点"]
    C --> D["Database"]
    D --> E["Collection"]
    E --> F["Document BSON"]
    C --> G["Index"]
    C --> H["Storage Engine"]
    H --> I["WiredTiger Cache"]
    H --> J["Data Files / Journal"]

核心理解:

层次作用
Driver连接池、序列化 BSON、路由请求、重试读写
mongod接收命令、解析查询、选择计划、执行读写
Collection保存同类文档
DocumentBSON 格式的具体数据
Index辅助定位文档,避免全集合扫描
WiredTiger默认存储引擎,负责缓存、压缩、并发、持久化
Journal写前日志,用于崩溃恢复

文档模型:嵌入还是引用

MongoDB 建模的第一原则不是“能不能存 JSON”,而是“业务怎么读写”。

mermaid
flowchart TD
    A["分析业务对象"] --> B{"是否经常一起读取"}
    B -- "是" --> C{"是否会无限增长"}
    C -- "否" --> D["嵌入同一文档"]
    C -- "是" --> E["拆集合引用"]
    B -- "否" --> E
    E --> F["通过 id 关联或应用层查询"]

适合嵌入

用户和地址:

javascript
{
  _id: ObjectId("..."),
  name: "Tom",
  address: {
    province: "浙江",
    city: "杭州",
    detail: "西湖区"
  }
}

理由:

  1. 地址通常和用户一起展示。
  2. 地址数量有限。
  3. 生命周期接近用户。

不适合无限嵌入

用户和所有订单:

javascript
{
  _id: ObjectId("..."),
  name: "Tom",
  orders: [
    "...",
    "...",
    "无限增长"
  ]
}

问题:

  1. 单文档越来越大。
  2. 更新数组成本越来越高。
  3. 读取用户基本信息时带出大量无用数据。
  4. 单文档有大小限制。

更合理:

javascript
// users
{ _id: ObjectId("..."), name: "Tom" }

// orders
{ _id: ObjectId("..."), userId: ObjectId("..."), amount: 100, createdAt: new Date() }

一次查询的全过程

示例:

javascript
db.asset.find(
  { ownerId: 1001, status: "ACTIVE" },
  { assetNo: 1, status: 1, createdAt: 1 }
).sort({ createdAt: -1 }).limit(20)

执行链路:

mermaid
flowchart TD
    A["Driver 发送 find 命令"] --> B["mongod 接收请求"]
    B --> C["解析 filter / projection / sort / limit"]
    C --> D["查询优化器选择计划"]
    D --> E{"是否有合适索引"}
    E -- "有" --> F["IXSCAN 扫描索引"]
    E -- "没有" --> G["COLLSCAN 扫描集合"]
    F --> H["FETCH 读取文档"]
    G --> H
    H --> I["过滤 / 投影 / 排序 / limit"]
    I --> J["返回 BSON 结果"]

关键执行阶段:

阶段说明风险
解析条件识别字段、操作符、排序条件写法不合理
选择计划比较可用索引和扫描成本统计和数据分布影响选择
IXSCAN扫描索引 key索引顺序不匹配会扫很多 key
COLLSCAN扫描集合文档大集合非常慢
FETCH根据索引定位文档返回字段不在索引中需要读文档
SORT内存或磁盘排序数据量大时很慢

explain 怎么看

javascript
db.asset.find({
  ownerId: 1001,
  status: "ACTIVE"
}).sort({ createdAt: -1 }).limit(20).explain("executionStats")

重点看:

字段含义判断
winningPlan最终选择的执行计划看是 IXSCAN 还是 COLLSCAN
totalKeysExamined扫描索引 key 数量应接近返回数量
totalDocsExamined扫描文档数量越大越可能慢
nReturned返回文档数量和扫描量对比
executionTimeMillis执行耗时结合数据量判断
stage执行阶段COLLSCANIXSCANFETCHSORT

理想情况:

text
totalKeysExamined 接近 nReturned
totalDocsExamined 接近 nReturned
没有大范围 COLLSCAN
没有大范围内存排序

如果扫描 100 万条只返回 20 条,说明索引或查询模式有问题。

复合索引为什么要按查询模式设计

假设高频查询:

javascript
db.asset.find({
  ownerId: 1001,
  status: "ACTIVE"
}).sort({ createdAt: -1 }).limit(20)

推荐索引:

javascript
db.asset.createIndex({ ownerId: 1, status: 1, createdAt: -1 })

为什么这个顺序:

  1. ownerId 是高频等值过滤。
  2. status 继续缩小范围。
  3. createdAt 用于排序和分页。
mermaid
flowchart TD
    A["复合索引 ownerId,status,createdAt"] --> B["先定位 ownerId=1001"]
    B --> C["再定位 status=ACTIVE"]
    C --> D["按 createdAt 顺序读取"]
    D --> E["limit 20 快速返回"]

错误索引:

javascript
db.asset.createIndex({ createdAt: -1, status: 1, ownerId: 1 })

如果查询先按 ownerId/status 过滤,这个索引可能无法高效缩小范围,会扫描更多 key。

一次写入的全过程

示例:

javascript
db.asset.updateOne(
  { assetNo: "A202607050001", status: "IDLE" },
  { $set: { status: "USED", updatedAt: new Date() } }
)

单副本集写入过程:

mermaid
flowchart TD
    A["Driver 发送 update"] --> B["Primary 接收写请求"]
    B --> C["根据条件定位文档"]
    C --> D["加文档级并发控制"]
    D --> E["修改内存中的数据"]
    E --> F["维护相关索引"]
    F --> G["写 Journal / oplog"]
    G --> H["根据 writeConcern 返回"]
    H --> I["Secondary 拉取 oplog 并重放"]

关键点:

机制说明
Primary 写入副本集中写请求默认发到 Primary
Journal用于崩溃恢复
oplog复制日志,Secondary 通过 oplog 追数据
writeConcern控制写入确认级别
readConcern控制读取可见性级别

writeConcern: { w: 1 } 表示 Primary 写入成功就返回。w: "majority" 表示多数节点确认后返回,更可靠但延迟更高。

副本集原理

副本集解决高可用和数据冗余。

mermaid
flowchart TD
    A["Primary"] --> B["oplog"]
    B --> C["Secondary 1"]
    B --> D["Secondary 2"]
    C --> E["复制并重放"]
    D --> F["复制并重放"]

主节点故障:

mermaid
flowchart TD
    A["Primary 故障"] --> B["成员检测心跳失败"]
    B --> C["发起选举"]
    C --> D["选出新的 Primary"]
    D --> E["Driver 发现拓扑变化"]
    E --> F["写请求切到新 Primary"]

注意:

  1. 选举期间写入可能短暂失败。
  2. Secondary 可能落后 Primary,读 Secondary 可能读到旧数据。
  3. readPreferencereadConcernwriteConcern 要结合一致性要求选择。

分片集群原理

分片用于解决单机容量和吞吐瓶颈。

mermaid
flowchart TD
    A["应用"] --> B["mongos 路由"]
    B --> C["Config Server 元数据"]
    B --> D["Shard 1"]
    B --> E["Shard 2"]
    B --> F["Shard 3"]

分片键决定数据怎么分布。

mermaid
flowchart TD
    A["写入文档"] --> B["取 shard key"]
    B --> C["mongos 查路由表"]
    C --> D["路由到目标 shard"]
    D --> E["目标 shard 写入"]

好分片键:

特点原因
高基数能分散到多个分片
写入分散避免所有写打到一个分片
查询常带上能精准路由到少数分片
不频繁变化分片键不可轻易改变

坏分片键:

分片键问题
单调递增时间新写入集中到一个分片,形成热点
低基数字段数据严重倾斜
查询很少带的字段查询变成广播到所有分片

聚合管道全过程

聚合管道是一批文档经过多个阶段加工。

mermaid
flowchart TD
    A["输入文档流"] --> B["$match 过滤"]
    B --> C["$project 裁剪字段"]
    C --> D["$group 分组"]
    D --> E["$sort 排序"]
    E --> F["$limit 限制"]
    F --> G["输出结果"]

优化原则:

  1. 能提前 $match 就提前,减少后续文档数。
  2. 能提前 $project 就提前,减少字段宽度。
  3. 大集合 $sort 尽量利用索引。
  4. $lookup 要控制关联规模,被关联字段要有索引。
  5. 报表统计优先考虑预聚合,而不是每次实时扫全量。

商业统计 Demo:

javascript
db.asset.aggregate([
  { $match: { hospitalId: 1001, status: "USED" } },
  { $group: { _id: "$departmentId", total: { $sum: 1 } } },
  { $sort: { total: -1 } },
  { $limit: 20 }
])

推荐索引:

javascript
db.asset.createIndex({ hospitalId: 1, status: 1, departmentId: 1 })

事务怎么理解

MongoDB 支持多文档事务,但不应该把它当成随便复杂 Join 和大事务的关系库。

适合事务:

场景说明
少量文档强一致修改例如账户余额和流水同时写
同一业务边界内短事务快速提交
需要失败整体回滚保证局部原子性

不适合:

场景问题
大批量长事务占资源、影响并发
跨大量分片事务延迟和复杂度高
复杂强关系业务关系库可能更适合

MongoDB 的强项仍然是文档模型和高吞吐读写,不是把所有关系型事务场景搬过来。

商业场景:医疗资产扩展属性

需求:不同医院的设备资产字段不完全一致,例如 CT、MR、呼吸机都有不同属性。

推荐文档:

javascript
{
  _id: ObjectId("..."),
  assetNo: "A202607050001",
  hospitalId: 1001,
  departmentId: 2001,
  status: "USED",
  attrs: {
    deviceType: "CT",
    manufacturer: "GE",
    model: "Revolution",
    warrantyEndDate: ISODate("2028-12-31")
  },
  createdAt: ISODate("2026-07-05T10:00:00Z"),
  updatedAt: ISODate("2026-07-05T10:00:00Z")
}

索引:

javascript
db.asset.createIndex({ assetNo: 1 }, { unique: true })
db.asset.createIndex({ hospitalId: 1, status: 1, createdAt: -1 })
db.asset.createIndex({ "attrs.deviceType": 1, hospitalId: 1 })

设计原因:

设计原因
高频固定字段放顶层方便索引和查询
扩展属性放 attrs不同类型设备字段差异大
assetNo 唯一索引防止重复资产
hospitalId/status/createdAt 复合索引支持列表查询
deviceType 索引支持设备类型筛选

不要把所有字段都塞进 attrs。否则高频查询、唯一约束、排序和统计都会变难。

线上排查流程

慢查询

mermaid
flowchart TD
    A["查询慢"] --> B["explain executionStats"]
    B --> C{"是否 COLLSCAN"}
    C -- "是" --> D["补索引或改查询"]
    C -- "否" --> E{"keys/docs examined 是否远大于返回"}
    E -- "是" --> F["调整复合索引顺序"]
    E -- "否" --> G{"是否 SORT 或聚合重"}
    G -- "是" --> H["缩小范围、加索引、预聚合"]
    G -- "否" --> I["看锁、复制延迟、磁盘、缓存"]

文档过大

现象可能原因处理
更新越来越慢数组无限增长拆集合
查询带出大量无用字段文档过宽拆冷热字段
单文档接近限制设计错误重新建模

复制延迟

原因说明
Primary 写入太快Secondary 追不上 oplog
Secondary 磁盘慢重放速度慢
大批量写入oplog 压力大
网络抖动节点复制延迟

分片热点

现象原因
单个 shard CPU/IO 高分片键倾斜
新写入集中一个 shard单调递增 shard key
查询广播所有 shard查询不带 shard key

常见坑

后果正确做法
把 MongoDB 当随便存 JSON字段混乱,查询困难维护文档模型规范
无限数组嵌入单文档变大,更新慢拆集合
不看 explain不知道 COLLSCAN用 executionStats 验证
索引越多越好写入慢,索引占内存只保留服务高频查询的索引
$lookup 滥用聚合慢,内存压力大控制关联规模或预聚合
分片键只看均匀查询变广播同时考虑查询路由
事务滥用延迟高、资源占用文档建模优先减少事务需求

面试标准回答

MongoDB 查询怎么执行

text
MongoDB 查询由 Driver 发送到 mongod,mongod 解析 filter、projection、sort、limit 后由查询优化器选择执行计划。有合适索引时走 IXSCAN,再 FETCH 文档;没有合适索引时可能 COLLSCAN 扫描集合。排查时要看 explain("executionStats"),重点关注 winningPlan、totalKeysExamined、totalDocsExamined、nReturned 和是否出现 COLLSCAN 或大范围 SORT。理想情况是扫描数量接近返回数量。

MongoDB 文档怎么设计

text
MongoDB 文档设计要从查询模式出发。经常一起读取、生命周期接近、数量有限的数据适合嵌入同一文档;不经常一起读取、会无限增长、生命周期不同的数据应该拆成集合引用。不能把 MongoDB 当随便存 JSON,也不能把无限数组一直塞进单文档。高频固定字段应该放顶层并建立索引,灵活扩展字段可以放到嵌套对象中。

副本集和分片区别

text
副本集解决高可用和数据冗余,一个 Primary 接收写入,Secondary 复制 oplog 并重放,Primary 故障后会选举新的 Primary。分片解决单机容量和吞吐瓶颈,通过 shard key 把数据分布到多个 shard,mongos 根据路由表把请求发到对应分片。副本集关注可用性,分片关注水平扩展;分片键选错会导致数据倾斜、写热点或查询广播。

关联知识点

本章小结

MongoDB 的核心不是“能存 JSON”,而是围绕文档模型组织读写。文档设计决定后续查询和更新成本,索引决定扫描范围,聚合管道决定数据加工成本,副本集解决高可用,分片解决水平扩展,事务只适合短小强一致边界。真正用好 MongoDB,要把查询模式、文档结构、索引、写入确认、复制延迟、分片键和生产排查一起考虑。