MongoDB 核心全过程原理
MongoDB 入门很容易:插入 JSON、查询 JSON、更新字段。但真正上线后,问题往往不是“CRUD 不会写”,而是:
- 文档到底该嵌入还是拆集合。
- 查询为什么明明有条件还是扫很多文档。
- 复合索引字段顺序为什么影响巨大。
- 聚合管道为什么越写越慢。
- 副本集主从切换时读写会发生什么。
- 分片键选错为什么会热点。
- MongoDB 事务能不能像关系库一样随便用。
- 慢查询、文档过大、索引膨胀、复制延迟怎么排查。
这一页把 MongoDB 的主线串起来:文档模型、读写过程、索引、聚合、副本集、分片、事务和生产排查。
学习目标
| 问题 | 学完后要能说清 |
|---|---|
| MongoDB 适合什么 | 文档天然聚合、字段变化、读多写多、半结构化数据 |
| 文档怎么设计 | 根据读取方式决定嵌入或引用,避免无限增长数组 |
| 查询怎么执行 | 解析条件、选择执行计划、IXSCAN 或 COLLSCAN、FETCH 文档 |
| 写入怎么执行 | Driver 发请求,Primary 写入,Journal 持久化,复制到 Secondary |
| 索引怎么设计 | 从查询模式出发,关注复合索引顺序、排序和扫描量 |
| 聚合管道怎么优化 | 先 $match、再 $project、最后 $group/$sort,避免大数据进入后段 |
| 副本集解决什么 | 高可用和读扩展,Primary 写入,Secondary 复制 |
| 分片解决什么 | 容量和吞吐扩展,但分片键决定数据分布和热点 |
| 事务怎么用 | 支持多文档事务,但不能滥用成关系库复杂事务 |
MongoDB 总体架构
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 | 保存同类文档 |
| Document | BSON 格式的具体数据 |
| Index | 辅助定位文档,避免全集合扫描 |
| WiredTiger | 默认存储引擎,负责缓存、压缩、并发、持久化 |
| Journal | 写前日志,用于崩溃恢复 |
文档模型:嵌入还是引用
MongoDB 建模的第一原则不是“能不能存 JSON”,而是“业务怎么读写”。
flowchart TD
A["分析业务对象"] --> B{"是否经常一起读取"}
B -- "是" --> C{"是否会无限增长"}
C -- "否" --> D["嵌入同一文档"]
C -- "是" --> E["拆集合引用"]
B -- "否" --> E
E --> F["通过 id 关联或应用层查询"]适合嵌入
用户和地址:
{
_id: ObjectId("..."),
name: "Tom",
address: {
province: "浙江",
city: "杭州",
detail: "西湖区"
}
}理由:
- 地址通常和用户一起展示。
- 地址数量有限。
- 生命周期接近用户。
不适合无限嵌入
用户和所有订单:
{
_id: ObjectId("..."),
name: "Tom",
orders: [
"...",
"...",
"无限增长"
]
}问题:
- 单文档越来越大。
- 更新数组成本越来越高。
- 读取用户基本信息时带出大量无用数据。
- 单文档有大小限制。
更合理:
// users
{ _id: ObjectId("..."), name: "Tom" }
// orders
{ _id: ObjectId("..."), userId: ObjectId("..."), amount: 100, createdAt: new Date() }一次查询的全过程
示例:
db.asset.find(
{ ownerId: 1001, status: "ACTIVE" },
{ assetNo: 1, status: 1, createdAt: 1 }
).sort({ createdAt: -1 }).limit(20)执行链路:
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 怎么看
db.asset.find({
ownerId: 1001,
status: "ACTIVE"
}).sort({ createdAt: -1 }).limit(20).explain("executionStats")重点看:
| 字段 | 含义 | 判断 |
|---|---|---|
winningPlan | 最终选择的执行计划 | 看是 IXSCAN 还是 COLLSCAN |
totalKeysExamined | 扫描索引 key 数量 | 应接近返回数量 |
totalDocsExamined | 扫描文档数量 | 越大越可能慢 |
nReturned | 返回文档数量 | 和扫描量对比 |
executionTimeMillis | 执行耗时 | 结合数据量判断 |
stage | 执行阶段 | COLLSCAN、IXSCAN、FETCH、SORT |
理想情况:
totalKeysExamined 接近 nReturned
totalDocsExamined 接近 nReturned
没有大范围 COLLSCAN
没有大范围内存排序如果扫描 100 万条只返回 20 条,说明索引或查询模式有问题。
复合索引为什么要按查询模式设计
假设高频查询:
db.asset.find({
ownerId: 1001,
status: "ACTIVE"
}).sort({ createdAt: -1 }).limit(20)推荐索引:
db.asset.createIndex({ ownerId: 1, status: 1, createdAt: -1 })为什么这个顺序:
ownerId是高频等值过滤。status继续缩小范围。createdAt用于排序和分页。
flowchart TD
A["复合索引 ownerId,status,createdAt"] --> B["先定位 ownerId=1001"]
B --> C["再定位 status=ACTIVE"]
C --> D["按 createdAt 顺序读取"]
D --> E["limit 20 快速返回"]错误索引:
db.asset.createIndex({ createdAt: -1, status: 1, ownerId: 1 })如果查询先按 ownerId/status 过滤,这个索引可能无法高效缩小范围,会扫描更多 key。
一次写入的全过程
示例:
db.asset.updateOne(
{ assetNo: "A202607050001", status: "IDLE" },
{ $set: { status: "USED", updatedAt: new Date() } }
)单副本集写入过程:
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" 表示多数节点确认后返回,更可靠但延迟更高。
副本集原理
副本集解决高可用和数据冗余。
flowchart TD
A["Primary"] --> B["oplog"]
B --> C["Secondary 1"]
B --> D["Secondary 2"]
C --> E["复制并重放"]
D --> F["复制并重放"]主节点故障:
flowchart TD
A["Primary 故障"] --> B["成员检测心跳失败"]
B --> C["发起选举"]
C --> D["选出新的 Primary"]
D --> E["Driver 发现拓扑变化"]
E --> F["写请求切到新 Primary"]注意:
- 选举期间写入可能短暂失败。
- Secondary 可能落后 Primary,读 Secondary 可能读到旧数据。
readPreference、readConcern、writeConcern要结合一致性要求选择。
分片集群原理
分片用于解决单机容量和吞吐瓶颈。
flowchart TD
A["应用"] --> B["mongos 路由"]
B --> C["Config Server 元数据"]
B --> D["Shard 1"]
B --> E["Shard 2"]
B --> F["Shard 3"]分片键决定数据怎么分布。
flowchart TD
A["写入文档"] --> B["取 shard key"]
B --> C["mongos 查路由表"]
C --> D["路由到目标 shard"]
D --> E["目标 shard 写入"]好分片键:
| 特点 | 原因 |
|---|---|
| 高基数 | 能分散到多个分片 |
| 写入分散 | 避免所有写打到一个分片 |
| 查询常带上 | 能精准路由到少数分片 |
| 不频繁变化 | 分片键不可轻易改变 |
坏分片键:
| 分片键 | 问题 |
|---|---|
| 单调递增时间 | 新写入集中到一个分片,形成热点 |
| 低基数字段 | 数据严重倾斜 |
| 查询很少带的字段 | 查询变成广播到所有分片 |
聚合管道全过程
聚合管道是一批文档经过多个阶段加工。
flowchart TD
A["输入文档流"] --> B["$match 过滤"]
B --> C["$project 裁剪字段"]
C --> D["$group 分组"]
D --> E["$sort 排序"]
E --> F["$limit 限制"]
F --> G["输出结果"]优化原则:
- 能提前
$match就提前,减少后续文档数。 - 能提前
$project就提前,减少字段宽度。 - 大集合
$sort尽量利用索引。 $lookup要控制关联规模,被关联字段要有索引。- 报表统计优先考虑预聚合,而不是每次实时扫全量。
商业统计 Demo:
db.asset.aggregate([
{ $match: { hospitalId: 1001, status: "USED" } },
{ $group: { _id: "$departmentId", total: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 20 }
])推荐索引:
db.asset.createIndex({ hospitalId: 1, status: 1, departmentId: 1 })事务怎么理解
MongoDB 支持多文档事务,但不应该把它当成随便复杂 Join 和大事务的关系库。
适合事务:
| 场景 | 说明 |
|---|---|
| 少量文档强一致修改 | 例如账户余额和流水同时写 |
| 同一业务边界内短事务 | 快速提交 |
| 需要失败整体回滚 | 保证局部原子性 |
不适合:
| 场景 | 问题 |
|---|---|
| 大批量长事务 | 占资源、影响并发 |
| 跨大量分片事务 | 延迟和复杂度高 |
| 复杂强关系业务 | 关系库可能更适合 |
MongoDB 的强项仍然是文档模型和高吞吐读写,不是把所有关系型事务场景搬过来。
商业场景:医疗资产扩展属性
需求:不同医院的设备资产字段不完全一致,例如 CT、MR、呼吸机都有不同属性。
推荐文档:
{
_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")
}索引:
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。否则高频查询、唯一约束、排序和统计都会变难。
线上排查流程
慢查询
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 查询怎么执行
MongoDB 查询由 Driver 发送到 mongod,mongod 解析 filter、projection、sort、limit 后由查询优化器选择执行计划。有合适索引时走 IXSCAN,再 FETCH 文档;没有合适索引时可能 COLLSCAN 扫描集合。排查时要看 explain("executionStats"),重点关注 winningPlan、totalKeysExamined、totalDocsExamined、nReturned 和是否出现 COLLSCAN 或大范围 SORT。理想情况是扫描数量接近返回数量。MongoDB 文档怎么设计
MongoDB 文档设计要从查询模式出发。经常一起读取、生命周期接近、数量有限的数据适合嵌入同一文档;不经常一起读取、会无限增长、生命周期不同的数据应该拆成集合引用。不能把 MongoDB 当随便存 JSON,也不能把无限数组一直塞进单文档。高频固定字段应该放顶层并建立索引,灵活扩展字段可以放到嵌套对象中。副本集和分片区别
副本集解决高可用和数据冗余,一个 Primary 接收写入,Secondary 复制 oplog 并重放,Primary 故障后会选举新的 Primary。分片解决单机容量和吞吐瓶颈,通过 shard key 把数据分布到多个 shard,mongos 根据路由表把请求发到对应分片。副本集关注可用性,分片关注水平扩展;分片键选错会导致数据倾斜、写热点或查询广播。关联知识点
本章小结
MongoDB 的核心不是“能存 JSON”,而是围绕文档模型组织读写。文档设计决定后续查询和更新成本,索引决定扫描范围,聚合管道决定数据加工成本,副本集解决高可用,分片解决水平扩展,事务只适合短小强一致边界。真正用好 MongoDB,要把查询模式、文档结构、索引、写入确认、复制延迟、分片键和生产排查一起考虑。
