MongoDB 从零到精通验收清单
这页用来验收你是否真的把 MongoDB 学到了能建模、能写查询、能设计索引、能做商业项目、能排查线上问题、能面试回答的程度。
MongoDB 不是“能存 JSON 的 MySQL”,也不是“随便丢文档不用设计”。真正学懂要能解释:
- MongoDB 为什么使用 BSON 文档,文档模型和关系模型的差异是什么。
- 什么数据适合嵌入,什么数据必须拆集合引用。
- 一次查询从 Driver 到 mongod,再到索引扫描和文档返回的全过程。
- 为什么有索引仍可能慢,
explain("executionStats")应该看什么。 - 复合索引、ESR 原则、多键索引、TTL 索引、唯一索引怎么设计。
- 聚合管道为什么要尽早
$match,$lookup为什么要谨慎。 - 副本集、oplog、选举、writeConcern、readPreference 怎么影响一致性。
- 分片集群中 shard key 为什么决定数据分布、写热点和查询广播。
- MongoDB 事务什么时候适合用,什么时候说明建模不合理。
- 商业系统中 MongoDB、MySQL、Elasticsearch 应该如何配合。
总学习路线
flowchart TD
A["阶段1:理解文档数据库定位"] --> B["阶段2:BSON、集合、文档和字段"]
B --> C["阶段3:文档建模:嵌入还是引用"]
C --> D["阶段4:CRUD、更新数组和分页"]
D --> E["阶段5:查询执行和 explain"]
E --> F["阶段6:索引设计和 ESR 原则"]
F --> G["阶段7:聚合管道和 lookup"]
G --> H["阶段8:副本集和读写一致性"]
H --> I["阶段9:分片和 shard key"]
I --> J["阶段10:事务、商业场景和生产排查"]这条路线的核心是:MongoDB 的设计入口不是“表结构”,而是“查询模式”。先想清楚业务怎么读、怎么写、数据是否一起变化,再决定文档结构、索引和部署方式。
阶段1:MongoDB 到底解决什么问题
关系型数据库擅长结构稳定、强事务、多表关系清晰的业务。例如订单、支付、库存、账务。
MongoDB 更适合文档天然聚合、字段变化较快、嵌套结构明显、读写吞吐较高的业务。例如:
| 场景 | 为什么适合 MongoDB |
|---|---|
| 商品详情 | 图片、规格、属性、扩展字段经常整体读取 |
| 用户画像 | 字段灵活,标签和偏好变化快 |
| 内容草稿 | 嵌套段落、组件配置、版本草稿结构不固定 |
| 埋点事件 | 不同事件字段不同,写入量大 |
| 医疗数据资产扩展属性 | 不同资产字段、标签、血缘、展示配置差异大 |
但 MongoDB 不是万能替代 MySQL。
不适合的场景:
| 场景 | 原因 |
|---|---|
| 订单支付账务核心链路 | 强事务和一致性要求高 |
| 大量复杂 Join | MongoDB 不是为关系 Join 优化的 |
| 高度规范化的主数据 | 关系库约束和事务更合适 |
| 跨集合强一致频繁修改 | 建模方式可能不适合 MongoDB |
一句话:MongoDB 用文档模型换取灵活结构和更贴近聚合对象的读写方式,但你仍然必须设计数据模型和索引。
阶段2:BSON、集合和文档
MongoDB 文档看起来像 JSON,但底层是 BSON。BSON 是二进制文档格式,支持 ObjectId、Date、Decimal128、二进制等类型。
{
_id: ObjectId("64f1..."),
assetCode: "DATASET_001",
name: "门诊就诊明细",
ownerDept: "信息科",
tags: ["医疗", "门诊"],
fields: [
{ name: "patient_id", type: "string", sensitive: true },
{ name: "visit_time", type: "date", sensitive: false }
],
createdAt: ISODate("2026-07-06T10:00:00Z")
}核心概念:
| MongoDB | 关系库类比 | 注意点 |
|---|---|---|
| database | database | 数据库 |
| collection | table | 集合不强制固定 schema |
| document | row | 文档可以嵌套对象和数组 |
| field | column | 字段可以灵活变化 |
| index | index | 查询性能仍依赖索引 |
文档结构:
flowchart TD
A["Database"] --> B["Collection"]
B --> C["Document"]
C --> D["普通字段"]
C --> E["嵌套对象"]
C --> F["数组字段"]
B --> G["Index"]常见误区:
- MongoDB 不是存 JSON 字符串,而是 BSON 文档。
- 时间、金额、ID 不要全部当字符串存。
- 集合 schema 灵活,不代表字段可以没有规范。
_id默认有唯一索引,但业务唯一性仍要设计业务键。
阶段3:文档建模:嵌入还是引用
MongoDB 建模第一问不是“建几张表”,而是“业务怎么读写”。
flowchart TD
A["一组相关数据"] --> B{"是否经常一起读取"}
B -- "是" --> C{"数量是否有限"}
C -- "是" --> D["优先嵌入到同一文档"]
C -- "否" --> E["拆成独立集合引用"]
B -- "否" --> E
E --> F{"是否需要强事务频繁维护"}
F -- "是" --> G["考虑关系库或重新建模"]
F -- "否" --> H["用业务 ID 关联查询"]嵌入适合:
| 关系 | 原因 |
|---|---|
| 用户和少量地址 | 数量有限,经常一起读取 |
| 商品和规格参数 | 商品详情页整体展示 |
| 资产和字段定义 | 数据资产详情常一起读取 |
| 文章和草稿组件 | 文档结构天然聚合 |
引用适合:
| 关系 | 原因 |
|---|---|
| 用户和订单 | 订单无限增长 |
| 文章和海量评论 | 评论数量大,分页独立 |
| 资产和访问日志 | 日志无限增长,生命周期不同 |
| 设备和采集记录 | 采集记录写入量大 |
如果把无限增长数组嵌入单文档:
- 文档越来越大,接近 16MB 限制。
- 更新数组需要移动或重写较大文档。
- 单文档成为热点。
- 数组分页困难。
- 多键索引膨胀,写入成本变高。
阶段4:CRUD 和更新数组
插入数据资产:
db.data_asset.insertOne({
assetCode: "DATASET_001",
name: "门诊就诊明细",
hospitalId: "H001",
ownerDept: "信息科",
status: "PUBLISHED",
tags: ["医疗", "门诊"],
fields: [
{ name: "patient_id", type: "string", sensitive: true },
{ name: "visit_time", type: "date", sensitive: false }
],
deleted: false,
createdAt: new Date()
})条件查询和投影:
db.data_asset.find(
{
hospitalId: "H001",
status: "PUBLISHED",
deleted: false,
tags: "医疗"
},
{
assetCode: 1,
name: 1,
ownerDept: 1,
createdAt: 1
}
).sort({ createdAt: -1 }).limit(20)更新数组中的某个字段:
db.data_asset.updateOne(
{
assetCode: "DATASET_001",
"fields.name": "patient_id"
},
{
$set: { "fields.$.sensitive": true }
}
)追加标签但不重复:
db.data_asset.updateOne(
{ assetCode: "DATASET_001" },
{ $addToSet: { tags: "重点资产" } }
)$push 和 $addToSet 区别:
| 操作符 | 行为 | 场景 |
|---|---|---|
$push | 直接追加,允许重复 | 追加事件,但不适合无限数组 |
$addToSet | 不存在才追加 | 标签、权限码等去重数组 |
阶段5:分页为什么不能只用 skip
普通分页:
db.data_asset.find({ status: "PUBLISHED" })
.sort({ createdAt: -1 })
.skip(100000)
.limit(20)skip 很大时会慢,因为数据库仍要按顺序扫描并丢弃前 100000 条,再返回 20 条。
更适合滚动加载的游标分页:
db.data_asset.find({
status: "PUBLISHED",
$or: [
{ createdAt: { $lt: ISODate("2026-07-06T10:00:00Z") } },
{
createdAt: ISODate("2026-07-06T10:00:00Z"),
_id: { $lt: ObjectId("64f1...") }
}
]
}).sort({ createdAt: -1, _id: -1 }).limit(20)为什么要带 _id?
如果多个文档 createdAt 相同,只用时间做游标可能重复或漏数据。加 _id 作为稳定排序补充字段,可以让分页顺序稳定。
阶段6:一次查询怎么执行
flowchart TD
A["应用发起 find"] --> B["MongoDB Driver 编码 BSON"]
B --> C["mongod 接收请求"]
C --> D["解析 filter、sort、projection、limit"]
D --> E["查询优化器选择执行计划"]
E --> F{"是否有合适索引"}
F -- "有" --> G["IXSCAN 扫描索引"]
G --> H["FETCH 读取文档"]
F -- "没有" --> I["COLLSCAN 扫描集合"]
H --> J["过滤、排序、投影"]
I --> J
J --> K["返回 BSON 结果"]
K --> L["Driver 解码成对象"]explain("executionStats") Demo:
db.data_asset.find({
hospitalId: "H001",
status: "PUBLISHED",
deleted: false
}).sort({ createdAt: -1 }).explain("executionStats")重点指标:
| 指标 | 含义 | 怎么判断 |
|---|---|---|
winningPlan | 选中的执行计划 | 看是否走 IXSCAN |
stage: "COLLSCAN" | 集合扫描 | 大集合高风险 |
stage: "IXSCAN" | 索引扫描 | 还要看扫描量 |
totalKeysExamined | 扫描索引项数量 | 应接近返回数量 |
totalDocsExamined | 扫描文档数量 | 远大于返回量说明过滤差 |
nReturned | 返回文档数 | 和扫描量对比 |
executionTimeMillis | 执行耗时 | 结合业务 SLA |
SORT | 内存排序 | 可能缺排序索引 |
有索引但仍慢的常见原因:
- 索引选择性差,扫描大量索引。
- 复合索引字段顺序不匹配查询。
- 排序没有被索引覆盖,出现内存排序。
- 查询返回大量文档,FETCH 成本高。
- 文档太大,网络和反序列化成本高。
阶段7:索引设计和 ESR 原则
常见查询:
db.data_asset.find({
hospitalId: "H001",
status: "PUBLISHED",
deleted: false
}).sort({ createdAt: -1 })可以考虑复合索引:
db.data_asset.createIndex({
hospitalId: 1,
status: 1,
deleted: 1,
createdAt: -1,
_id: -1
})ESR 原则:
| 字母 | 含义 | 例子 |
|---|---|---|
| E | Equality 等值条件 | hospitalId、status |
| S | Sort 排序字段 | createdAt |
| R | Range 范围条件 | $gt、$lt |
ESR 不是死规则。还要看字段区分度、查询频率、排序方向和 explain 结果。
常见索引类型:
| 索引 | 作用 | 注意 |
|---|---|---|
| 单字段索引 | 按一个字段查询 | 简单但不能满足多条件排序 |
| 复合索引 | 多条件过滤和排序 | 顺序很重要 |
| 多键索引 | 数组字段索引 | 数组过大索引膨胀 |
| 唯一索引 | 保证业务唯一 | 软删除要考虑唯一冲突 |
| TTL 索引 | 自动过期删除 | 不适合核心业务数据 |
索引不是越多越好:
- 每次写入都要维护索引。
- 索引占磁盘和内存。
- 多个相似索引增加优化器选择成本。
- 无用索引拖慢写入。
阶段8:覆盖查询
覆盖查询是指查询所需字段都在索引里,不需要 FETCH 原始文档。
db.data_asset.createIndex({
hospitalId: 1,
status: 1,
createdAt: -1,
assetCode: 1,
name: 1
})查询:
db.data_asset.find(
{ hospitalId: "H001", status: "PUBLISHED" },
{ _id: 0, assetCode: 1, name: 1, createdAt: 1 }
).sort({ createdAt: -1 })覆盖查询适合列表页只展示少量字段的场景。但不要为了覆盖把大量字段塞进索引,否则索引会变大,写入变慢。
阶段9:聚合管道全过程
聚合管道是一条文档处理流水线。
flowchart TD
A["原始集合文档"] --> B["$match 过滤"]
B --> C["$project 裁剪字段"]
C --> D["$group 分组统计"]
D --> E["$sort 排序"]
E --> F["$limit 限制返回"]统计每个科室发布资产数量:
db.data_asset.aggregate([
{ $match: { hospitalId: "H001", status: "PUBLISHED", deleted: false } },
{ $group: { _id: "$ownerDept", total: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 20 }
])为什么 $match 要尽量靠前?
因为后面的 $group、$sort、$lookup 都是重操作。越早过滤,后续处理文档越少,CPU、内存和磁盘压力越小。
$lookup 风险:
db.data_asset.aggregate([
{ $match: { hospitalId: "H001" } },
{
$lookup: {
from: "department",
localField: "ownerDeptId",
foreignField: "_id",
as: "dept"
}
}
])$lookup 要注意:
- 主集合先
$match缩小范围。 foreignField要建索引。- 控制返回字段。
- 高频关联字段可适当冗余。
- 复杂报表考虑预聚合或数仓。
阶段10:副本集原理
副本集解决高可用和数据冗余。
flowchart TD
A["Client 写入"] --> B["Primary"]
B --> C["写入数据和 oplog"]
C --> D["Secondary 1 拉取 oplog"]
C --> E["Secondary 2 拉取 oplog"]
D --> F["重放操作保持同步"]
E --> G["重放操作保持同步"]Primary 故障选举:
flowchart TD
A["Primary 心跳异常"] --> B["Secondary 发起选举"]
B --> C["多数节点投票"]
C --> D["选出新 Primary"]
D --> E["客户端重新发现拓扑"]
E --> F["写入切换到新 Primary"]核心概念:
| 概念 | 说明 |
|---|---|
| Primary | 接收写入 |
| Secondary | 复制 oplog 并重放 |
| oplog | 复制操作日志 |
| writeConcern | 写入确认级别 |
| readConcern | 读取可见性 |
| readPreference | 读主还是读从 |
writeConcern:
| 设置 | 含义 | 取舍 |
|---|---|---|
w: 1 | Primary 写成功就返回 | 延迟低,可靠性弱 |
w: "majority" | 多数节点确认才返回 | 更可靠,延迟更高 |
j: true | 写 journal 后确认 | 更可靠,成本更高 |
读从库的风险:Secondary 可能有复制延迟,用户刚写完马上读可能读到旧数据。
阶段11:副本集不能替代备份
副本集解决的是节点故障,不解决误删误改。
flowchart TD
A["Primary 误删数据"] --> B["写入 oplog"]
B --> C["Secondary 同步误删操作"]
C --> D["所有副本都删除了数据"]所以生产必须有:
- 定期备份。
- 备份可恢复验证。
- 关键集合误删恢复预案。
- 审计和权限控制。
- 高风险操作审批。
“有三个副本”不等于“有备份”。
阶段12:分片集群原理
分片解决单机容量和吞吐瓶颈。
flowchart TD
A["Client"] --> B["mongos 路由"]
B --> C["Config Server 保存路由元数据"]
B --> D["Shard 1"]
B --> E["Shard 2"]
B --> F["Shard 3"]查询带 shard key:
flowchart TD
A["查询带 hospitalId"] --> B["mongos 根据路由表定位分片"]
B --> C["只访问目标 Shard"]
C --> D["返回结果"]查询不带 shard key:
flowchart TD
A["查询不带 shard key"] --> B["mongos 无法定位单个分片"]
B --> C["广播到所有 Shard"]
C --> D["汇总结果"]
D --> E["延迟和资源消耗上升"]shard key 选择错误的后果:
| 错误 shard key | 后果 |
|---|---|
| 低基数字段,如 status | 数据严重倾斜 |
| 单调递增字段,如 createdAt | 新写入集中到一个分片 |
| 查询很少携带的字段 | 大量广播查询 |
| 热点业务 ID | 单个分片压力高 |
分片不是性能问题第一解。先优化索引、查询、文档模型、归档、读写分离,再评估分片。
阶段13:事务边界
MongoDB 支持事务,但事务不是默认推荐的建模方式。
适合事务:
| 场景 | 原因 |
|---|---|
| 少量文档跨集合一致修改 | 边界清晰 |
| 状态变更和审计记录一起写 | 短事务 |
| 管理后台低频强一致操作 | 量小、可控 |
不适合事务:
| 场景 | 原因 |
|---|---|
| 大批量导入 | 事务太长,资源占用大 |
| 跨大量分片修改 | 延迟和协调成本高 |
| 高频交易核心链路 | 关系库更合适 |
| 本可嵌入的数据还跨集合事务 | 建模可能不合理 |
事务 Demo:
const session = db.getMongo().startSession()
session.startTransaction()
try {
const assetDb = session.getDatabase("asset_db")
assetDb.data_asset.updateOne(
{ assetCode: "DATASET_001" },
{ $set: { status: "PUBLISHED" } }
)
assetDb.asset_audit.insertOne({
assetCode: "DATASET_001",
action: "PUBLISH",
createdAt: new Date()
})
session.commitTransaction()
} catch (e) {
session.abortTransaction()
throw e
}事务越多,越要反思文档模型是否把本该一起变化的数据拆散了。
阶段14:商业项目架构组合
医疗数据资产平台可以这样组合:
flowchart TD
A["MySQL"] --> B["核心主数据<br/>资产编号、状态、权限、流程"]
C["MongoDB"] --> D["扩展属性<br/>字段列表、标签、配置、血缘快照"]
E["Elasticsearch"] --> F["全文搜索<br/>资产名称、字段、描述"]
G["Redis"] --> H["热点缓存<br/>权限、字典、临时状态"]为什么不只用一个数据库?
| 数据 | 更适合 | 原因 |
|---|---|---|
| 资产状态、审批流程 | MySQL | 强一致、事务、关系清晰 |
| 字段定义、展示配置 | MongoDB | 结构灵活、文档聚合 |
| 搜索资产和字段 | Elasticsearch | 倒排索引、相关性排序 |
| 权限缓存和临时数据 | Redis | 低延迟、高并发 |
商业选型原则:不同数据库解决不同问题,不要把 MongoDB 当搜索引擎,也不要把 MySQL 当文档扩展字段垃圾桶。
阶段15:生产排查
查询慢
flowchart TD
A["查询慢"] --> B["打印 filter、sort、projection"]
B --> C["执行 explain executionStats"]
C --> D["是否 COLLSCAN"]
D --> E["totalDocsExamined 是否远大于 nReturned"]
E --> F["是否出现 SORT"]
F --> G["索引顺序是否匹配查询"]
G --> H["文档是否过大或返回字段太多"]写入慢
flowchart TD
A["写入慢"] --> B["索引数量是否过多"]
B --> C["文档是否过大"]
C --> D["数组是否频繁增长"]
D --> E["writeConcern 是否过高"]
E --> F["磁盘 IO 是否瓶颈"]
F --> G["是否单文档或单分片热点"]复制延迟
flowchart TD
A["复制延迟"] --> B["Secondary 是否资源不足"]
B --> C["Primary 写入是否突增"]
C --> D["oplog 窗口是否足够"]
D --> E["网络是否异常"]
E --> F["是否有大事务或大批量写入"]分片倾斜
flowchart TD
A["分片倾斜"] --> B["检查 shard key 基数"]
B --> C["检查 chunk 分布"]
C --> D["检查写入是否集中到单分片"]
D --> E["查询是否大量广播"]
E --> F["评估重新分片或调整模型"]常用命令:
db.data_asset.find({ status: "PUBLISHED" }).explain("executionStats")
db.data_asset.getIndexes()
db.currentOp()
db.serverStatus()
rs.status()
rs.printReplicationInfo()
rs.printSecondaryReplicationInfo()
sh.status()阶段16:常见坑和后果
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 把 MongoDB 当随便存 JSON | 字段混乱,查询不可维护 | 建字段规范和文档模型 |
| 无限数组嵌入文档 | 文档过大,更新慢 | 拆集合引用 |
| 看到字段就建索引 | 写入变慢,索引膨胀 | 按查询模式建索引 |
| 只看到 IXSCAN 就以为快 | 扫描量仍可能很大 | 看 keys/docs examined |
| 大量使用 skip 深分页 | 越翻越慢 | 游标分页 |
高频 $lookup | 退化成关系 Join | 冗余或重新建模 |
| 无脑读从库 | 读到旧数据 | 按一致性要求选择 |
| 用副本集替代备份 | 误删同步到所有副本 | 必须做备份和恢复演练 |
| 随便选 shard key | 数据倾斜、写热点 | 根据查询和写入分布设计 |
| 滥用事务 | 延迟和资源占用上升 | 优先合理文档建模 |
阶段17:面试标准回答
问:MongoDB 适合什么场景?
标准回答:
MongoDB 适合文档天然聚合、字段结构变化较快、嵌套数据较多、读写吞吐要求较高的场景,比如商品详情、用户画像、内容草稿、日志事件、数据资产扩展属性。它不适合大量复杂关联、强事务和高度规范化的核心交易场景。MongoDB 不是随便存 JSON,仍然要根据查询模式设计文档结构和索引。
问:嵌入和引用怎么选?
标准回答:
经常一起读取、生命周期接近、数量有限的数据适合嵌入同一文档;不经常一起读取、数量无限增长、生命周期不同的数据适合拆集合引用。比如用户地址可以嵌入用户文档,但用户订单、评论、日志不适合无限追加到同一个文档里。
问:MongoDB 查询为什么快?
标准回答:
MongoDB 查询快主要依赖合理文档模型和合适索引。有索引时可以通过 B-Tree 索引快速定位候选文档,再 FETCH 文档;如果经常一起读取的数据被嵌入在同一文档里,也减少了关系库多表 Join 的成本。但没有合适索引时同样会 COLLSCAN,大集合性能会很差。
问:副本集和分片区别?
标准回答:
副本集解决高可用和数据冗余,Primary 接收写入,Secondary 复制 oplog,Primary 故障后通过选举产生新主。分片解决容量和吞吐扩展,通过 shard key 把数据分布到多个 shard,mongos 根据路由表转发请求。副本集关注可用性,分片关注水平扩展。
最终验收题
如果下面问题答不清楚,说明还没有真正掌握 MongoDB:
- MongoDB 文档模型和 MySQL 表模型最大差异是什么?
- BSON 和 JSON 有什么区别?
- 为什么业务唯一键不能完全依赖 ObjectId?
- 嵌入和引用怎么选?无限数组有什么风险?
skip深分页为什么慢?游标分页为什么要加_id?explain("executionStats")里totalDocsExamined远大于nReturned说明什么?- 有 IXSCAN 为什么仍然可能慢?
- ESR 原则怎么用于复合索引设计?
- 多键索引为什么可能膨胀?
- 聚合管道为什么要先
$match? $lookup为什么不能滥用?- oplog 是什么?Secondary 为什么会延迟?
- writeConcern 和 readPreference 分别影响什么?
- 为什么副本集不能替代备份?
- shard key 选错会有哪些后果?
- MongoDB 事务为什么不能滥用?
- 医疗数据资产平台中 MySQL、MongoDB、ES 应该怎么分工?
关联知识点跳转
- MongoDB 总览
- MongoDB 从零到生产级掌握
- MongoDB 商业场景训练营
- MongoDB 基础
- MongoDB 核心全过程原理
- MongoDB 索引设计
- MongoDB 聚合管道
- MongoDB 高级
- MongoDB 面试
- MySQL 从零到生产级掌握
- Elasticsearch 从零到生产级掌握
- Redis 从零到生产级掌握
本章小结
MongoDB 学到精通,关键是理解文档模型背后的取舍:用嵌入减少关联读取,用索引支撑查询和排序,用聚合管道处理统计,用副本集保证高可用,用分片扩展容量。但它仍然需要设计文档结构、索引、备份、权限和排查流程。会 CRUD 只是入门,能解释查询为什么慢、索引为什么不生效、分片为什么倾斜、事务为什么不该滥用,才算真正掌握。
