MongoDB基础
MongoDB 是文档型数据库。它不像 MySQL 那样把数据拆成行和列,而是把一条数据保存成类似 JSON 的文档。MongoDB 适合字段结构变化较快、嵌套数据较多、读写吞吐要求较高的场景。
注意:MongoDB 不是“可以随便存”的数据库。字段设计、索引设计和查询模式仍然非常重要。
一句话理解:
MongoDB 的核心不是“把 JSON 塞进数据库”,而是围绕业务读取方式设计文档,让一次查询尽量拿到需要的数据,同时避免单个文档无限膨胀。
学习目标
学完本页,你要能做到:
- 解释 database、collection、document、field、
_id。 - 判断一个字段应该嵌入文档,还是拆成另一个集合。
- 写出 insert、find、projection、update、delete、软删除。
- 知道
$set、$inc、$push、$addToSet的区别。 - 能为列表查询设计复合索引。
- 能解释为什么无限增长数组是 MongoDB 常见坑。
- 能用
explain("executionStats")初步判断是否走索引。
为什么文档模型适合嵌套数据
关系型数据库倾向把数据拆成多张表,再通过外键或 join 组合起来。MongoDB 更适合把经常一起读取、生命周期接近的数据放进同一个文档。例如用户地址常和用户一起展示,嵌入文档可以减少额外查询。
如果把所有数据都嵌进去也会出问题。订单列表、评论列表、访问日志这类无限增长数据不适合一直追加到一个文档数组里,否则单个文档会越来越大,更新和读取都会变慢。
MongoDB 和关系库的思路差异
flowchart TD
A["业务需求:展示资产详情"] --> B{"关系型数据库"}
B --> C["资产表"]
B --> D["科室表"]
B --> E["扩展属性表"]
B --> F["多表 Join 或多次查询"]
A --> G{"MongoDB"}
G --> H["一个资产文档保存常用详情和扩展属性"]
H --> I["一次按 _id 或 assetNo 查询"]关系型数据库更强调规范化、约束、Join、事务;MongoDB 更强调按访问模式聚合数据。两者不是谁替代谁,而是适合不同问题。
| 问题 | 关系库常见思路 | MongoDB 常见思路 |
|---|---|---|
| 订单支付强事务 | 多表事务、强约束 | 不作为首选 |
| 商品详情页 | 多表 Join 或宽表 | 商品文档聚合详情 |
| 用户画像 | 多张属性表 | 文档字段灵活扩展 |
| 日志事件 | 分区表或日志系统 | 文档事件流 |
| 复杂报表 | SQL 聚合 | 聚合管道或离线预聚合 |
基本概念
| MongoDB | MySQL 类比 | 说明 |
|---|---|---|
| Database | Database | 数据库 |
| Collection | Table | 集合,保存一类文档 |
| Document | Row | 文档,一条具体数据 |
| Field | Column | 字段 |
_id | Primary Key | 文档唯一标识 |
文档示例
{
"_id": "1001",
"name": "Tom",
"age": 18,
"tags": ["vip", "student"],
"address": {
"city": "Hangzhou",
"street": "West Lake"
},
"createdAt": "2026-06-27T10:00:00Z"
}这个文档有几个特点:
- 字段可以是字符串、数字、布尔值、时间、数组、对象。
address是嵌套对象,适合和用户一起读取。tags是数组,可以保存多个标签。_id是唯一主键,没有提供时 MongoDB 会自动生成。
BSON 是什么
MongoDB 底层存储的不是普通 JSON 字符串,而是 BSON。BSON 是一种二进制文档格式,支持更多数据类型,比如 ObjectId、Date、Decimal128、二进制等。
flowchart TD
A["应用中的对象"] --> B["Driver 转成 BSON"]
B --> C["mongod 写入集合"]
C --> D["查询时 BSON 解码成对象"]这解释了两个现象:
- MongoDB 文档看起来像 JSON,但类型比 JSON 更丰富。
- 时间、ObjectId、Decimal 等要用驱动支持的类型,不要全部存成字符串。
CRUD 基础流程
flowchart TD
A["选择 database"] --> B["选择 collection"]
B --> C["插入 document"]
C --> D["按条件查询"]
D --> E["更新字段"]
E --> F["按业务规则删除或归档"]插入数据
db.users.insertOne({
name: "Tom",
age: 18,
tags: ["vip", "student"],
address: {
city: "Hangzhou"
}
})批量插入:
db.users.insertMany([
{ name: "Alice", age: 20 },
{ name: "Bob", age: 22 }
])ObjectId 怎么理解
如果不指定 _id,MongoDB 通常会生成 ObjectId。ObjectId 大体包含时间戳、机器/进程相关信息和计数器,因此大致趋势递增。
db.users.insertOne({
name: "Tom",
createdAt: new Date()
})生产建议:
- 内部主键可以使用 ObjectId。
- 对外业务编号不要直接依赖 ObjectId。
- 跨系统幂等要有业务唯一键,例如
assetNo、orderNo。 - 需要按业务编号查询时,要给业务编号建唯一索引。
查询数据
查询全部:
db.users.find()按条件查询:
db.users.find({ age: { $gte: 18 } })查询嵌套字段:
db.users.find({ "address.city": "Hangzhou" })只返回指定字段:
db.users.find(
{ age: { $gte: 18 } },
{ name: 1, age: 1, _id: 0 }
)常见查询操作符:
| 操作符 | 含义 | 示例 |
|---|---|---|
$gt | 大于 | { age: { $gt: 18 } } |
$gte | 大于等于 | { age: { $gte: 18 } } |
$lt | 小于 | { age: { $lt: 60 } } |
$in | 在列表中 | { status: { $in: ["NEW", "PAID"] } } |
$regex | 正则匹配 | { name: { $regex: "^T" } } |
Projection 为什么重要
Projection 是只返回需要的字段。
db.users.find(
{ "address.city": "Hangzhou" },
{ name: 1, tags: 1, _id: 0 }
)如果文档很大,比如资产文档里有扩展属性、采集原文、历史快照,列表页不应该每次都返回整份文档。只返回列表需要字段,可以减少网络传输、反序列化和内存压力。
排序和分页
db.users.find({ status: "ACTIVE" })
.sort({ createdAt: -1 })
.skip(100)
.limit(20)小页码可以这样写,但深分页会越来越慢。skip(100000) 意味着数据库仍然要找到并跳过大量文档。
更适合滚动列表的方式:
db.users.find({
status: "ACTIVE",
createdAt: { $lt: ISODate("2026-07-06T10:00:00Z") }
}).sort({ createdAt: -1 }).limit(20)并配合索引:
db.users.createIndex({ status: 1, createdAt: -1 })更新数据
只更新匹配到的第一条:
db.users.updateOne(
{ name: "Tom" },
{ $set: { age: 19 } }
)更新多条:
db.users.updateMany(
{ "address.city": "Hangzhou" },
{ $set: { cityLevel: "new-first-tier" } }
)数组追加:
db.users.updateOne(
{ name: "Tom" },
{ $addToSet: { tags: "active" } }
)常见更新操作符:
| 操作符 | 作用 |
|---|---|
$set | 设置字段 |
$unset | 删除字段 |
$inc | 数字递增 |
$push | 向数组追加元素 |
$addToSet | 数组追加但避免重复 |
$push 和 $addToSet 区别
db.users.updateOne(
{ _id: "1001" },
{ $push: { tags: "vip" } }
)$push 每次都会追加,可能出现重复值。
db.users.updateOne(
{ _id: "1001" },
{ $addToSet: { tags: "vip" } }
)$addToSet 在数组里没有该值时才追加,适合标签、权限码这类不希望重复的数组。
注意:数组不是无限容器。用户标签可以放数组,用户全部订单、所有登录日志不应该无限追加到用户文档里。
条件更新:防止并发覆盖
比如资产状态只能从 WAIT_CHECK 变成 CHECKED:
db.assets.updateOne(
{ assetNo: "A-001", status: "WAIT_CHECK" },
{
$set: {
status: "CHECKED",
checkedAt: new Date()
}
}
)如果 matchedCount 或 modifiedCount 为 0,说明资产不存在或状态已经被别人改过。业务层不能继续认为审核成功。
删除数据
db.users.deleteOne({ name: "Tom" })
db.users.deleteMany({ age: { $lt: 18 } })生产系统中不建议随意物理删除重要业务数据。常见做法是软删除:
db.users.updateOne(
{ _id: "1001" },
{ $set: { deleted: true, deletedAt: new Date() } }
)软删除要配合查询条件和索引:
db.users.createIndex({ deleted: 1, createdAt: -1 })所有列表查询都要带:
db.users.find({ deleted: { $ne: true } })如果忘记过滤软删除字段,已删除数据会重新出现在页面或统计里。
文档设计原则
flowchart TD
A["分析业务读取方式"] --> B{"数据是否经常一起读取"}
B -- "是" --> C["可以嵌入到同一文档"]
B -- "否" --> D["拆成不同集合"]
C --> E{"数组是否可能无限增长"}
E -- "是" --> D
E -- "否" --> F["保留嵌套结构"]设计建议:
- 经常一起读取的数据可以嵌入,例如用户地址。
- 无限增长的数据不要放在一个数组里,例如用户所有订单。
- 高频查询字段要提前考虑索引。
- 字段命名要统一,避免同一个含义出现
userId、uid、user_id三种写法。 - 对重要集合维护文档结构说明,即使 MongoDB 不强制 schema。
嵌入还是引用:详细判断
| 判断问题 | 适合嵌入 | 适合引用/拆集合 |
|---|---|---|
| 是否经常一起读取 | 是 | 否 |
| 子数据数量是否有限 | 有限 | 无限增长 |
| 生命周期是否一致 | 一致 | 不一致 |
| 是否需要单独高频查询 | 不需要 | 需要 |
| 是否需要独立权限/审计 | 不需要 | 需要 |
| 更新频率是否很高 | 低或中 | 很高 |
适合嵌入:资产扩展属性
{
assetNo: "A-001",
assetName: "CT设备",
hospitalId: "H001",
attrs: {
brand: "Demo",
model: "CT-64",
room: "3F-CT"
}
}资产详情通常一起展示,扩展属性数量有限,适合嵌入。
不适合嵌入:资产采集日志
{
assetNo: "A-001",
collectLogs: [
"...每天持续追加..."
]
}采集日志会无限增长,不应该一直追加到资产文档中。更适合拆成 asset_collect_logs 集合,用 assetNo 关联,并按时间建索引。
索引入门:不是字段有就建
MongoDB 没有合适索引时,可能扫描整个集合。索引要根据查询模式设计。
资产列表查询:
db.assets.find({
hospitalId: "H001",
status: "NORMAL"
}).sort({ createdAt: -1 }).limit(20)索引:
db.assets.createIndex({
hospitalId: 1,
status: 1,
createdAt: -1
})解释:
hospitalId、status是等值过滤,放前面。createdAt用于排序,放后面。- 查询条件和排序顺序能匹配索引时,扫描更少。
使用执行计划:
db.assets.find({
hospitalId: "H001",
status: "NORMAL"
}).sort({ createdAt: -1 }).limit(20).explain("executionStats")重点看:
| 字段 | 含义 |
|---|---|
winningPlan | 最终使用的计划 |
totalKeysExamined | 扫描了多少索引项 |
totalDocsExamined | 扫描了多少文档 |
nReturned | 返回多少文档 |
如果 totalDocsExamined 远大于 nReturned,说明扫描了很多无用文档。
最小商业 Demo:资产详情和采集日志
资产集合:
db.assets.insertOne({
assetNo: "A-001",
assetName: "CT设备",
hospitalId: "H001",
deptId: "D001",
status: "NORMAL",
attrs: {
brand: "Demo",
model: "CT-64"
},
deleted: false,
createdAt: new Date(),
updatedAt: new Date()
})
db.assets.createIndex({ assetNo: 1 }, { unique: true })
db.assets.createIndex({ hospitalId: 1, status: 1, createdAt: -1 })采集日志集合:
db.asset_collect_logs.insertOne({
assetNo: "A-001",
collectTime: new Date(),
status: "SUCCESS",
payload: {
cpu: 18,
memory: 64
}
})
db.asset_collect_logs.createIndex({ assetNo: 1, collectTime: -1 })为什么这样拆:
- 资产详情字段有限,适合一个文档。
- 采集日志无限增长,拆集合。
- 资产按
assetNo唯一定位。 - 日志按
assetNo + collectTime查询最近记录。
常见问题
| 问题 | 原因 | 建议 |
|---|---|---|
| 查询越来越慢 | 没有索引或查询条件不合理 | 使用 explain 分析 |
| 文档越来越大 | 把无限增长数组放进文档 | 拆分集合 |
| 字段混乱 | 没有约定文档结构 | 建立字段规范 |
| 删除后难恢复 | 直接物理删除 | 重要数据使用软删除 |
| 排序很慢 | 排序字段没有索引 | 建复合索引 |
| 深分页慢 | skip 跳过大量文档 | 用游标分页 |
| 数组无限增长 | 把日志、订单、评论放进一个文档 | 拆集合 |
| 写入重复业务数据 | 缺少唯一索引 | 给业务唯一键建唯一索引 |
| 列表返回慢 | 返回整个大文档 | 使用 projection |
面试标准回答
MongoDB 是文档型数据库,核心是根据查询模式设计文档,而不是随便存 JSON。经常一起读取、生命周期接近、数量有限的数据适合嵌入;无限增长、需要独立查询或独立审计的数据应该拆成集合。CRUD 中要掌握 insertOne/insertMany、find、projection、updateOne/updateMany、$set、$inc、$push、$addToSet 和软删除。生产中必须根据查询设计索引,并用 explain("executionStats") 看 totalKeysExamined、totalDocsExamined 和 nReturned,避免 COLLSCAN、大范围 SORT、无限增长数组和无约束重复写入。练习
- 设计一个商品文档,包含商品名、价格、标签、创建时间。
- 查询价格大于 100 的商品,只返回名称和价格。
- 给商品追加一个标签,要求不能重复。
- 把删除商品改造成软删除。
- 思考订单和订单明细是否应该放在同一个文档中。
下一步学习 MongoDB 索引设计 和 MongoDB 聚合管道。
