Skip to content

MongoDB基础

MongoDB 是文档型数据库。它不像 MySQL 那样把数据拆成行和列,而是把一条数据保存成类似 JSON 的文档。MongoDB 适合字段结构变化较快、嵌套数据较多、读写吞吐要求较高的场景。

注意:MongoDB 不是“可以随便存”的数据库。字段设计、索引设计和查询模式仍然非常重要。

一句话理解:

MongoDB 的核心不是“把 JSON 塞进数据库”,而是围绕业务读取方式设计文档,让一次查询尽量拿到需要的数据,同时避免单个文档无限膨胀。

学习目标

学完本页,你要能做到:

  1. 解释 database、collection、document、field、_id
  2. 判断一个字段应该嵌入文档,还是拆成另一个集合。
  3. 写出 insert、find、projection、update、delete、软删除。
  4. 知道 $set$inc$push$addToSet 的区别。
  5. 能为列表查询设计复合索引。
  6. 能解释为什么无限增长数组是 MongoDB 常见坑。
  7. 能用 explain("executionStats") 初步判断是否走索引。

为什么文档模型适合嵌套数据

关系型数据库倾向把数据拆成多张表,再通过外键或 join 组合起来。MongoDB 更适合把经常一起读取、生命周期接近的数据放进同一个文档。例如用户地址常和用户一起展示,嵌入文档可以减少额外查询。

如果把所有数据都嵌进去也会出问题。订单列表、评论列表、访问日志这类无限增长数据不适合一直追加到一个文档数组里,否则单个文档会越来越大,更新和读取都会变慢。

MongoDB 和关系库的思路差异

mermaid
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 聚合聚合管道或离线预聚合

基本概念

MongoDBMySQL 类比说明
DatabaseDatabase数据库
CollectionTable集合,保存一类文档
DocumentRow文档,一条具体数据
FieldColumn字段
_idPrimary Key文档唯一标识

文档示例

json
{
  "_id": "1001",
  "name": "Tom",
  "age": 18,
  "tags": ["vip", "student"],
  "address": {
    "city": "Hangzhou",
    "street": "West Lake"
  },
  "createdAt": "2026-06-27T10:00:00Z"
}

这个文档有几个特点:

  1. 字段可以是字符串、数字、布尔值、时间、数组、对象。
  2. address 是嵌套对象,适合和用户一起读取。
  3. tags 是数组,可以保存多个标签。
  4. _id 是唯一主键,没有提供时 MongoDB 会自动生成。

BSON 是什么

MongoDB 底层存储的不是普通 JSON 字符串,而是 BSON。BSON 是一种二进制文档格式,支持更多数据类型,比如 ObjectId、Date、Decimal128、二进制等。

mermaid
flowchart TD
    A["应用中的对象"] --> B["Driver 转成 BSON"]
    B --> C["mongod 写入集合"]
    C --> D["查询时 BSON 解码成对象"]

这解释了两个现象:

  1. MongoDB 文档看起来像 JSON,但类型比 JSON 更丰富。
  2. 时间、ObjectId、Decimal 等要用驱动支持的类型,不要全部存成字符串。

CRUD 基础流程

mermaid
flowchart TD
    A["选择 database"] --> B["选择 collection"]
    B --> C["插入 document"]
    C --> D["按条件查询"]
    D --> E["更新字段"]
    E --> F["按业务规则删除或归档"]

插入数据

javascript
db.users.insertOne({
  name: "Tom",
  age: 18,
  tags: ["vip", "student"],
  address: {
    city: "Hangzhou"
  }
})

批量插入:

javascript
db.users.insertMany([
  { name: "Alice", age: 20 },
  { name: "Bob", age: 22 }
])

ObjectId 怎么理解

如果不指定 _id,MongoDB 通常会生成 ObjectId。ObjectId 大体包含时间戳、机器/进程相关信息和计数器,因此大致趋势递增。

javascript
db.users.insertOne({
  name: "Tom",
  createdAt: new Date()
})

生产建议:

  1. 内部主键可以使用 ObjectId。
  2. 对外业务编号不要直接依赖 ObjectId。
  3. 跨系统幂等要有业务唯一键,例如 assetNoorderNo
  4. 需要按业务编号查询时,要给业务编号建唯一索引。

查询数据

查询全部:

javascript
db.users.find()

按条件查询:

javascript
db.users.find({ age: { $gte: 18 } })

查询嵌套字段:

javascript
db.users.find({ "address.city": "Hangzhou" })

只返回指定字段:

javascript
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 是只返回需要的字段。

javascript
db.users.find(
  { "address.city": "Hangzhou" },
  { name: 1, tags: 1, _id: 0 }
)

如果文档很大,比如资产文档里有扩展属性、采集原文、历史快照,列表页不应该每次都返回整份文档。只返回列表需要字段,可以减少网络传输、反序列化和内存压力。

排序和分页

javascript
db.users.find({ status: "ACTIVE" })
  .sort({ createdAt: -1 })
  .skip(100)
  .limit(20)

小页码可以这样写,但深分页会越来越慢。skip(100000) 意味着数据库仍然要找到并跳过大量文档。

更适合滚动列表的方式:

javascript
db.users.find({
  status: "ACTIVE",
  createdAt: { $lt: ISODate("2026-07-06T10:00:00Z") }
}).sort({ createdAt: -1 }).limit(20)

并配合索引:

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

更新数据

只更新匹配到的第一条:

javascript
db.users.updateOne(
  { name: "Tom" },
  { $set: { age: 19 } }
)

更新多条:

javascript
db.users.updateMany(
  { "address.city": "Hangzhou" },
  { $set: { cityLevel: "new-first-tier" } }
)

数组追加:

javascript
db.users.updateOne(
  { name: "Tom" },
  { $addToSet: { tags: "active" } }
)

常见更新操作符:

操作符作用
$set设置字段
$unset删除字段
$inc数字递增
$push向数组追加元素
$addToSet数组追加但避免重复

$push$addToSet 区别

javascript
db.users.updateOne(
  { _id: "1001" },
  { $push: { tags: "vip" } }
)

$push 每次都会追加,可能出现重复值。

javascript
db.users.updateOne(
  { _id: "1001" },
  { $addToSet: { tags: "vip" } }
)

$addToSet 在数组里没有该值时才追加,适合标签、权限码这类不希望重复的数组。

注意:数组不是无限容器。用户标签可以放数组,用户全部订单、所有登录日志不应该无限追加到用户文档里。

条件更新:防止并发覆盖

比如资产状态只能从 WAIT_CHECK 变成 CHECKED

javascript
db.assets.updateOne(
  { assetNo: "A-001", status: "WAIT_CHECK" },
  {
    $set: {
      status: "CHECKED",
      checkedAt: new Date()
    }
  }
)

如果 matchedCountmodifiedCount 为 0,说明资产不存在或状态已经被别人改过。业务层不能继续认为审核成功。

删除数据

javascript
db.users.deleteOne({ name: "Tom" })
db.users.deleteMany({ age: { $lt: 18 } })

生产系统中不建议随意物理删除重要业务数据。常见做法是软删除:

javascript
db.users.updateOne(
  { _id: "1001" },
  { $set: { deleted: true, deletedAt: new Date() } }
)

软删除要配合查询条件和索引:

javascript
db.users.createIndex({ deleted: 1, createdAt: -1 })

所有列表查询都要带:

javascript
db.users.find({ deleted: { $ne: true } })

如果忘记过滤软删除字段,已删除数据会重新出现在页面或统计里。

文档设计原则

mermaid
flowchart TD
    A["分析业务读取方式"] --> B{"数据是否经常一起读取"}
    B -- "是" --> C["可以嵌入到同一文档"]
    B -- "否" --> D["拆成不同集合"]
    C --> E{"数组是否可能无限增长"}
    E -- "是" --> D
    E -- "否" --> F["保留嵌套结构"]

设计建议:

  1. 经常一起读取的数据可以嵌入,例如用户地址。
  2. 无限增长的数据不要放在一个数组里,例如用户所有订单。
  3. 高频查询字段要提前考虑索引。
  4. 字段命名要统一,避免同一个含义出现 userIduiduser_id 三种写法。
  5. 对重要集合维护文档结构说明,即使 MongoDB 不强制 schema。

嵌入还是引用:详细判断

判断问题适合嵌入适合引用/拆集合
是否经常一起读取
子数据数量是否有限有限无限增长
生命周期是否一致一致不一致
是否需要单独高频查询不需要需要
是否需要独立权限/审计不需要需要
更新频率是否很高低或中很高

适合嵌入:资产扩展属性

javascript
{
  assetNo: "A-001",
  assetName: "CT设备",
  hospitalId: "H001",
  attrs: {
    brand: "Demo",
    model: "CT-64",
    room: "3F-CT"
  }
}

资产详情通常一起展示,扩展属性数量有限,适合嵌入。

不适合嵌入:资产采集日志

javascript
{
  assetNo: "A-001",
  collectLogs: [
    "...每天持续追加..."
  ]
}

采集日志会无限增长,不应该一直追加到资产文档中。更适合拆成 asset_collect_logs 集合,用 assetNo 关联,并按时间建索引。

索引入门:不是字段有就建

MongoDB 没有合适索引时,可能扫描整个集合。索引要根据查询模式设计。

资产列表查询:

javascript
db.assets.find({
  hospitalId: "H001",
  status: "NORMAL"
}).sort({ createdAt: -1 }).limit(20)

索引:

javascript
db.assets.createIndex({
  hospitalId: 1,
  status: 1,
  createdAt: -1
})

解释:

  1. hospitalIdstatus 是等值过滤,放前面。
  2. createdAt 用于排序,放后面。
  3. 查询条件和排序顺序能匹配索引时,扫描更少。

使用执行计划:

javascript
db.assets.find({
  hospitalId: "H001",
  status: "NORMAL"
}).sort({ createdAt: -1 }).limit(20).explain("executionStats")

重点看:

字段含义
winningPlan最终使用的计划
totalKeysExamined扫描了多少索引项
totalDocsExamined扫描了多少文档
nReturned返回多少文档

如果 totalDocsExamined 远大于 nReturned,说明扫描了很多无用文档。

最小商业 Demo:资产详情和采集日志

资产集合:

javascript
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 })

采集日志集合:

javascript
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 })

为什么这样拆:

  1. 资产详情字段有限,适合一个文档。
  2. 采集日志无限增长,拆集合。
  3. 资产按 assetNo 唯一定位。
  4. 日志按 assetNo + collectTime 查询最近记录。

常见问题

问题原因建议
查询越来越慢没有索引或查询条件不合理使用 explain 分析
文档越来越大把无限增长数组放进文档拆分集合
字段混乱没有约定文档结构建立字段规范
删除后难恢复直接物理删除重要数据使用软删除
排序很慢排序字段没有索引建复合索引
深分页慢skip 跳过大量文档用游标分页
数组无限增长把日志、订单、评论放进一个文档拆集合
写入重复业务数据缺少唯一索引给业务唯一键建唯一索引
列表返回慢返回整个大文档使用 projection

面试标准回答

text
MongoDB 是文档型数据库,核心是根据查询模式设计文档,而不是随便存 JSON。经常一起读取、生命周期接近、数量有限的数据适合嵌入;无限增长、需要独立查询或独立审计的数据应该拆成集合。CRUD 中要掌握 insertOne/insertMany、find、projection、updateOne/updateMany、$set、$inc、$push、$addToSet 和软删除。生产中必须根据查询设计索引,并用 explain("executionStats") 看 totalKeysExamined、totalDocsExamined 和 nReturned,避免 COLLSCAN、大范围 SORT、无限增长数组和无约束重复写入。

练习

  1. 设计一个商品文档,包含商品名、价格、标签、创建时间。
  2. 查询价格大于 100 的商品,只返回名称和价格。
  3. 给商品追加一个标签,要求不能重复。
  4. 把删除商品改造成软删除。
  5. 思考订单和订单明细是否应该放在同一个文档中。

下一步学习 MongoDB 索引设计MongoDB 聚合管道