MongoDB 从零到能用:文档、CRUD、聚合与 Node.js 实战

文章来源声明: 原文作者:唐青枫; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685591445945499674; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

内容由浅入深、示例可跑,把文档建模与索引优化讲得清楚,适合有 SQL 基础、准备在 Node.js 项目中落地 MongoDB 的开发者。

MongoDB 从零到能用:文档、CRUD、聚合与 Node.js 实战 ------------------------------------

很多数据库教程停在“插入一条数据、查询一条数据”。真正开始写业务后,马上会遇到更多问题:字段怎么设计?数组怎么查?分页怎么写?索引到底有没有生效?应用代码如何安全地复用数据库连接?

这篇文章用一个商品目录 Demo 串起 MongoDB 的核心能力:从启动数据库开始,依次完成文档建模、增删改查、数组和嵌套对象查询、分页、聚合、索引,再用 Node.js 官方驱动写一个可以直接运行的示例。

示例使用 MongoDB 8.0、mongosh 和 Node.js。MongoDB 的命令可以在 MongoDB Shell、Compass 的命令区域或应用驱动中使用。

一、MongoDB 到底存什么

MongoDB 是文档数据库。数据以 BSON 文档保存,BSON 可以理解成“比 JSON 多了一些类型支持的二进制 JSON”。一条商品数据大致长这样:

{
  <span>_id</span>: <span>ObjectId</span>(<span>"66b000000000000000000001"</span>),
  <span>name</span>: <span>"机械键盘"</span>,
  <span>price</span>: <span>399</span>,
  <span>stock</span>: <span>42</span>,
  <span>category</span>: <span>"键盘"</span>,
  <span>tags</span>: [<span>"办公"</span>, <span>"RGB"</span>, <span>"蓝牙"</span>],
  <span>specs</span>: {
    <span>layout</span>: <span>"87键"</span>,
    <span>connection</span>: [<span>"USB-C"</span>, <span>"Bluetooth"</span>]
  },
  <span>createdAt</span>: <span>ISODate</span>(<span>"2026-09-15T08:00:00Z"</span>)
}

这里有普通字段、数组、嵌套对象和日期,数据结构比一张扁平的表更贴近商品详情页。

和 MySQL 的概念对应关系

MySQLMongoDB说明
DatabaseDatabase数据库
TableCollection集合,类似数据表
RowDocument文档,类似一行记录
ColumnField字段
Primary Key`_id`文档唯一标识
WHEREFilter查询条件
GROUP BY`$group`聚合分组
ORDER BY`sort()`排序
INDEXIndex索引

MongoDB 不要求先执行 CREATE TABLE。第一次向集合写入文档时,数据库可以自动创建数据库和集合:

shop(数据库)
└── products(集合)
    ├── document
    ├── document
    └── document

“无固定 Schema”不等于“字段可以随便写”。它表示数据库不会强迫同一个集合中的每条文档完全相同。业务字段仍然应该保持稳定,必要时配合应用层校验或 MongoDB 的 JSON Schema 校验,避免同一字段一会儿存数字、一会儿存字符串。

二、启动 MongoDB

1. Docker 启动本地实例

学习和开发阶段,用 Docker 最省事:

docker run -d \
  --name mongodb \
  -p 27017:27017 \
  -v mongodb-data:/data/db \
  mongo:8.0

参数含义:

-d                 后台运行
--name mongodb     容器名称
-p 27017:27017    映射 MongoDB 默认端口
-v mongodb-data... 把数据保存到 Docker volume
mongo:8.0         使用 MongoDB 8.0 镜像

查看容器状态:

docker ps
docker logs mongodb

进入 MongoDB Shell:

docker <span>exec</span> -it mongodb mongosh

连接地址为:

mongodb://127.0.0.1:27017

停止和再次启动:

docker stop mongodb
docker start mongodb

这个本地 Demo 没有开启认证,只适合本机学习。生产环境不能把未认证的 MongoDB 直接暴露在公网。

2. 带账号密码的启动方式

需要演示认证时,可以重新创建一个带管理员账号的容器:

docker run -d \
  --name mongodb-auth \
  -p 27018:27017 \
  -v mongodb-auth-data:/data/db \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=<span>'change-me-please'</span> \
  mongo:8.0

连接:

mongosh <span>"mongodb://admin:change-me-please@127.0.0.1:27018/?authSource=admin"</span>

authSource=admin 表示账号在 admin 数据库中认证。密码中包含 @?# 等特殊字符时,需要先进行 URI 百分号编码。

3. 使用 MongoDB Atlas

Atlas 是 MongoDB 官方托管服务。创建集群后,需要完成两项配置才能连接:

  1. 在 Network Access 中加入客户端 IP。
  2. 创建 MongoDB Database User,并使用该用户生成连接字符串。

Atlas 通常提供这样的 SRV 连接字符串:

mongodb+srv://<username>:<password>@<cluster>.mongodb.net/shop

连接字符串不要硬编码到 Git 仓库,放进环境变量更安全:

<span>export</span> MONGODB_URI=<span>'mongodb+srv://user:password@cluster.example.mongodb.net/shop'</span>

三、先用 mongosh 跑通 CRUD

启动 mongosh 后,先切换到 Demo 数据库:

use shop
db

use shop 是切换数据库。只有真正写入数据后,数据库通常才会出现在 show dbs 的列表中。

1. 插入一条文档

db.<span>products</span>.<span>insertOne</span>({
  <span>name</span>: <span>"机械键盘"</span>,
  <span>price</span>: <span>399</span>,
  <span>stock</span>: <span>42</span>,
  <span>category</span>: <span>"键盘"</span>,
  <span>tags</span>: [<span>"办公"</span>, <span>"RGB"</span>, <span>"蓝牙"</span>],
  <span>specs</span>: {
    <span>layout</span>: <span>"87键"</span>,
    <span>connection</span>: [<span>"USB-C"</span>, <span>"Bluetooth"</span>]
  },
  <span>createdAt</span>: <span>new</span> <span>Date</span>()
})

执行结果中会出现 insertedId。没有手动提供 _id 时,MongoDB 会自动生成一个 ObjectId

{ <span>acknowledged</span>: <span>true</span>, <span>insertedId</span>: <span>ObjectId</span>(<span>'...'</span>) }

_id 在一个集合中必须唯一,它既可以使用自动生成的 ObjectId,也可以使用业务方提供的字符串或其他唯一值。

2. 批量插入

db.<span>products</span>.<span>insertMany</span>([
  {
    <span>name</span>: <span>"人体工学鼠标"</span>,
    <span>price</span>: <span>159</span>,
    <span>stock</span>: <span>100</span>,
    <span>category</span>: <span>"鼠标"</span>,
    <span>tags</span>: [<span>"办公"</span>, <span>"无线"</span>],
    <span>specs</span>: { <span>dpi</span>: <span>4000</span>, <span>connection</span>: [<span>"Bluetooth"</span>, <span>"2.4G"</span>] },
    <span>createdAt</span>: <span>new</span> <span>Date</span>()
  },
  {
    <span>name</span>: <span>"显示器支架"</span>,
    <span>price</span>: <span>299</span>,
    <span>stock</span>: <span>18</span>,
    <span>category</span>: <span>"支架"</span>,
    <span>tags</span>: [<span>"办公"</span>, <span>"桌搭"</span>],
    <span>specs</span>: { <span>material</span>: <span>"铝合金"</span>, <span>maxSize</span>: <span>32</span> },
    <span>createdAt</span>: <span>new</span> <span>Date</span>()
  },
  {
    <span>name</span>: <span>"USB-C 扩展坞"</span>,
    <span>price</span>: <span>229</span>,
    <span>stock</span>: <span>0</span>,
    <span>category</span>: <span>"扩展坞"</span>,
    <span>tags</span>: [<span>"办公"</span>, <span>"USB-C"</span>],
    <span>specs</span>: { <span>ports</span>: <span>8</span>, <span>connection</span>: [<span>"USB-C"</span>] },
    <span>createdAt</span>: <span>new</span> <span>Date</span>()
  }
])

3. 查询文档

查询全部文档:

db.<span>products</span>.<span>find</span>()

格式化输出:

db.<span>products</span>.<span>find</span>().<span>pretty</span>()

精确匹配:

db.<span>products</span>.<span>find</span>({ <span>category</span>: <span>"键盘"</span> })

比较操作符:

<span>// 价格大于等于 200</span>
db.<span>products</span>.<span>find</span>({ <span>price</span>: { <span>$gte</span>: <span>200</span> } })

<span>// 库存大于 0 且价格不超过 300</span>
db.<span>products</span>.<span>find</span>({
  <span>stock</span>: { <span>$gt</span>: <span>0</span> },
  <span>price</span>: { <span>$lte</span>: <span>300</span> }
})

常用比较操作符:

$eq   等于
$ne   不等于
$gt   大于
$gte  大于等于
$lt   小于
$lte  小于等于
$in   命中数组中的任意值
$nin  不命中数组中的值

OR 条件:

db.<span>products</span>.<span>find</span>({
  <span>$or</span>: [
    { <span>category</span>: <span>"键盘"</span> },
    { <span>price</span>: { <span>$lt</span>: <span>200</span> } }
  ]
})

只返回需要的字段,称为投影:

db.<span>products</span>.<span>find</span>(
  { <span>stock</span>: { <span>$gt</span>: <span>0</span> } },
  { <span>name</span>: <span>1</span>, <span>price</span>: <span>1</span>, <span>stock</span>: <span>1</span>, <span>_id</span>: <span>0</span> }
)

1 表示保留字段,0 表示排除字段。除 _id 外,通常不要在同一个投影中混用“保留”和“排除”两种写法。

查询一条文档:

db.<span>products</span>.<span>findOne</span>({ <span>name</span>: <span>"机械键盘"</span> })

_id 查询:

db.<span>products</span>.<span>findOne</span>({
  <span>_id</span>: <span>ObjectId</span>(<span>"66b000000000000000000001"</span>)
})

实际返回的 _id 需要替换成终端中插入结果里的值。

4. 数组和嵌套对象查询

数组字段可以直接匹配元素:

<span>// tags 数组包含“办公”</span>
db.<span>products</span>.<span>find</span>({ <span>tags</span>: <span>"办公"</span> })

<span>// tags 同时包含“办公”和“无线”</span>
db.<span>products</span>.<span>find</span>({
  <span>tags</span>: { <span>$all</span>: [<span>"办公"</span>, <span>"无线"</span>] }
})

嵌套对象使用点号路径:

<span>// specs.connection 数组包含 Bluetooth</span>
db.<span>products</span>.<span>find</span>({ <span>"specs.connection"</span>: <span>"Bluetooth"</span> })

<span>// specs.dpi 大于 1000</span>
db.<span>products</span>.<span>find</span>({ <span>"specs.dpi"</span>: { <span>$gt</span>: <span>1000</span> } })

查询字段是否存在:

db.<span>products</span>.<span>find</span>({ <span>"specs.ports"</span>: { <span>$exists</span>: <span>true</span> } })

5. 排序、分页和统计

<span>// 价格从高到低</span>
db.<span>products</span>.<span>find</span>().<span>sort</span>({ <span>price</span>: -<span>1</span> })

<span>// 价格从低到高</span>
db.<span>products</span>.<span>find</span>().<span>sort</span>({ <span>price</span>: <span>1</span> })

<span>// 第 2 页,每页 2 条</span>
db.<span>products</span>.<span>find</span>()
  .<span>sort</span>({ <span>createdAt</span>: -<span>1</span>, <span>_id</span>: -<span>1</span> })
  .<span>skip</span>(<span>2</span>)
  .<span>limit</span>(<span>2</span>)

排序方向 1 表示升序,-1 表示降序。分页排序时增加 _id 作为第二排序字段,可以让相同 createdAt 的文档保持稳定顺序。

统计数量:

db.<span>products</span>.<span>countDocuments</span>({ <span>stock</span>: { <span>$gt</span>: <span>0</span> } })

不建议在数据量很大时直接 find() 后再在应用层截取。过滤、排序、分页尽量交给数据库完成。

6. 更新文档

更新一个字段:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"机械键盘"</span> },
  { <span>$set</span>: { <span>price</span>: <span>369</span>, <span>stock</span>: <span>40</span> } }
)

增加或减少数字:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"机械键盘"</span> },
  { <span>$inc</span>: { <span>stock</span>: -<span>1</span> } }
)

向数组中追加一个值,重复值也会追加:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"机械键盘"</span> },
  { <span>$push</span>: { <span>tags</span>: <span>"热卖"</span> } }
)

只在数组中不存在时才追加:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"机械键盘"</span> },
  { <span>$addToSet</span>: { <span>tags</span>: <span>"办公"</span> } }
)

从数组中移除值:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"机械键盘"</span> },
  { <span>$pull</span>: { <span>tags</span>: <span>"RGB"</span> } }
)

更新嵌套字段:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"机械键盘"</span> },
  { <span>$set</span>: { <span>"specs.layout"</span>: <span>"75键"</span> } }
)

批量更新:

db.<span>products</span>.<span>updateMany</span>(
  { <span>category</span>: <span>"办公设备"</span>, <span>stock</span>: { <span>$gt</span>: <span>0</span> } },
  { <span>$set</span>: { <span>sale</span>: <span>true</span> } }
)

没有 办公设备 的 Demo 文档时,这条命令不会更新任何数据,这正好说明更新条件和更新内容是两部分。

使用 upsert 可以实现“有则更新,无则插入”:

db.<span>products</span>.<span>updateOne</span>(
  { <span>name</span>: <span>"桌面音箱"</span> },
  {
    <span>$set</span>: { <span>price</span>: <span>199</span>, <span>stock</span>: <span>20</span>, <span>category</span>: <span>"音箱"</span> },
    <span>$setOnInsert</span>: { <span>createdAt</span>: <span>new</span> <span>Date</span>() }
  },
  { <span>upsert</span>: <span>true</span> }
)

7. 删除文档

<span>// 删除一条</span>
db.<span>products</span>.<span>deleteOne</span>({ <span>name</span>: <span>"桌面音箱"</span> })

<span>// 删除所有库存为 0 的商品</span>
db.<span>products</span>.<span>deleteMany</span>({ <span>stock</span>: <span>0</span> })

删除前先执行同样的过滤条件查询,确认命中范围:

db.<span>products</span>.<span>find</span>({ <span>stock</span>: <span>0</span> }, { <span>name</span>: <span>1</span>, <span>stock</span>: <span>1</span> })

清空整个集合:

db.<span>products</span>.<span>deleteMany</span>({})

删除集合和删除数据库属于更大范围的操作:

db.<span>products</span>.<span>drop</span>()
db.<span>dropDatabase</span>()

四、用聚合管道做统计

普通 find() 适合取文档,统计和转换则使用聚合管道。管道由多个阶段组成,前一个阶段的结果交给下一个阶段:

$match → $group → $sort → $project

1. 按分类统计商品数量和平均价格

db.<span>products</span>.<span>aggregate</span>([
  {
    <span>$match</span>: { <span>stock</span>: { <span>$gt</span>: <span>0</span> } }
  },
  {
    <span>$group</span>: {
      <span>_id</span>: <span>"$category"</span>,
      <span>productCount</span>: { <span>$sum</span>: <span>1</span> },
      <span>averagePrice</span>: { <span>$avg</span>: <span>"$price"</span> },
      <span>totalStock</span>: { <span>$sum</span>: <span>"$stock"</span> }
    }
  },
  {
    <span>$sort</span>: { <span>productCount</span>: -<span>1</span>, <span>averagePrice</span>: <span>1</span> }
  },
  {
    <span>$project</span>: {
      <span>_id</span>: <span>0</span>,
      <span>category</span>: <span>"$_id"</span>,
      <span>productCount</span>: <span>1</span>,
      <span>averagePrice</span>: { <span>$round</span>: [<span>"$averagePrice"</span>, <span>2</span>] },
      <span>totalStock</span>: <span>1</span>
    }
  }
])

这里的含义是:先过滤有库存的商品,再按 category 分组,计算数量、平均价格和库存总数,最后调整返回字段。

2. 统计标签使用次数

$unwind 会把数组拆成多行:

db.<span>products</span>.<span>aggregate</span>([
  { <span>$unwind</span>: <span>"$tags"</span> },
  {
    <span>$group</span>: {
      <span>_id</span>: <span>"$tags"</span>,
      <span>count</span>: { <span>$sum</span>: <span>1</span> }
    }
  },
  { <span>$sort</span>: { <span>count</span>: -<span>1</span>, <span>_id</span>: <span>1</span> } }
])

一条商品有 3 个标签,经过 $unwind 后会变成 3 个中间结果,再由 $group 统计每个标签出现了多少次。

五、索引:让查询真正跑得快

没有索引时,MongoDB 可能需要扫描集合中的大量文档。高频过滤、排序和唯一性约束,通常都应该结合索引设计。

1. 创建和查看索引

<span>// category + price 复合索引</span>
db.<span>products</span>.<span>createIndex</span>({ <span>category</span>: <span>1</span>, <span>price</span>: <span>1</span> })

<span>// name 唯一索引</span>
db.<span>products</span>.<span>createIndex</span>({ <span>name</span>: <span>1</span> }, { <span>unique</span>: <span>true</span> })

<span>// 查看索引</span>
db.<span>products</span>.<span>getIndexes</span>()

复合索引的字段顺序很重要。{ category: 1, price: 1 } 适合先按分类过滤、再按价格排序的查询:

db.<span>products</span>.<span>find</span>({ <span>category</span>: <span>"键盘"</span> }).<span>sort</span>({ <span>price</span>: <span>1</span> })

不能把索引理解成“越多越好”。每个索引都会占用磁盘和内存,写入时也需要同步维护。应围绕真实查询建立索引。

2. 查看执行计划

db.<span>products</span>.<span>find</span>({ <span>category</span>: <span>"键盘"</span> })
  .<span>explain</span>(<span>"executionStats"</span>)

重点关注:

IXSCAN       使用索引扫描
COLLSCAN     全集合扫描
totalDocsExamined  检查过的文档数
nReturned          返回的文档数

数据量很小时,索引收益可能不明显;判断索引是否合适,应在接近真实数据量的环境中观察执行计划。

3. TTL 索引

临时数据可以使用 TTL 索引自动过期。TTL 字段必须是日期类型:

db.<span>loginTokens</span>.<span>createIndex</span>(
  { <span>expiresAt</span>: <span>1</span> },
  { <span>expireAfterSeconds</span>: <span>0</span> }
)

db.<span>loginTokens</span>.<span>insertOne</span>({
  <span>token</span>: <span>"demo-token"</span>,
  <span>expiresAt</span>: <span>new</span> <span>Date</span>(<span>Date</span>.<span>now</span>() + <span>10</span> * <span>60</span> * <span>1000</span>)
})

过期清理由后台任务执行,不保证在到达时间的瞬间删除,业务上不要把它当成精确的定时器。

六、Node.js 官方驱动实战

官方 Node.js Driver 提供了 MongoClient、数据库、集合和 CRUD API。先创建一个独立目录:

<span>mkdir</span> mongodb-demo
<span>cd</span> mongodb-demo
npm init -y
npm install mongodb

创建 index.js

<span>const</span> { <span>MongoClient</span> } = <span>require</span>(<span>"mongodb"</span>);

<span>const</span> uri = process.<span>env</span>.<span>MONGODB_URI</span> || <span>"mongodb://127.0.0.1:27017"</span>;
<span>const</span> client = <span>new</span> <span>MongoClient</span>(uri);

<span>async</span> <span>function</span> <span>main</span>(<span></span>) {
  <span>await</span> client.<span>connect</span>();

  <span>const</span> db = client.<span>db</span>(<span>"shop"</span>);
  <span>const</span> products = db.<span>collection</span>(<span>"products"</span>);

  <span>// 只创建一次即可;重复执行会得到同名索引已存在的结果</span>
  <span>await</span> products.<span>createIndex</span>({ <span>category</span>: <span>1</span>, <span>price</span>: <span>1</span> });

  <span>const</span> insertResult = <span>await</span> products.<span>insertOne</span>({
    <span>name</span>: <span>`USB-C 线材-<span>${<span>Date</span>.now()}</span>`</span>,
    <span>price</span>: <span>49</span>,
    <span>stock</span>: <span>200</span>,
    <span>category</span>: <span>"线材"</span>,
    <span>tags</span>: [<span>"办公"</span>, <span>"USB-C"</span>],
    <span>createdAt</span>: <span>new</span> <span>Date</span>()
  });

  <span>console</span>.<span>log</span>(<span>"新商品 ID:"</span>, insertResult.<span>insertedId</span>.<span>toString</span>());

  <span>const</span> list = <span>await</span> products.<span>find</span>({
    <span>stock</span>: { <span>$gt</span>: <span>0</span> },
    <span>price</span>: { <span>$lte</span>: <span>300</span> }
  })
    .<span>project</span>({ <span>name</span>: <span>1</span>, <span>price</span>: <span>1</span>, <span>stock</span>: <span>1</span>, <span>_id</span>: <span>0</span> })
    .<span>sort</span>({ <span>price</span>: <span>1</span> })
    .<span>limit</span>(<span>10</span>)
    .<span>toArray</span>();

  <span>console</span>.<span>table</span>(list);

  <span>const</span> updateResult = <span>await</span> products.<span>updateOne</span>(
    { <span>_id</span>: insertResult.<span>insertedId</span> },
    { <span>$inc</span>: { <span>stock</span>: -<span>1</span> }, <span>$addToSet</span>: { <span>tags</span>: <span>"热卖"</span> } }
  );

  <span>console</span>.<span>log</span>(<span>"更新数量:"</span>, updateResult.<span>modifiedCount</span>);

  <span>const</span> statistics = <span>await</span> products.<span>aggregate</span>([
    { <span>$match</span>: { <span>stock</span>: { <span>$gt</span>: <span>0</span> } } },
    {
      <span>$group</span>: {
        <span>_id</span>: <span>"$category"</span>,
        <span>count</span>: { <span>$sum</span>: <span>1</span> },
        <span>averagePrice</span>: { <span>$avg</span>: <span>"$price"</span> }
      }
    },
    { <span>$sort</span>: { <span>count</span>: -<span>1</span> } }
  ]).<span>toArray</span>();

  <span>console</span>.<span>log</span>(<span>"分类统计:"</span>, statistics);
}

<span>main</span>()
  .<span>catch</span>(<span>(<span>error</span>) =></span> {
    <span>console</span>.<span>error</span>(<span>"MongoDB 操作失败:"</span>, error);
    process.<span>exitCode</span> = <span>1</span>;
  })
  .<span>finally</span>(<span>async</span> () => {
    <span>await</span> client.<span>close</span>();
  });

运行:

node index.js

为什么要复用 MongoClient

MongoClient 内部会管理连接池。Web 服务启动时创建一个客户端,后续请求复用同一个客户端即可,不要每次接口请求都 new MongoClient()、连接、关闭。

一个简单的模块封装如下:

<span>// db.js</span>
<span>const</span> { <span>MongoClient</span> } = <span>require</span>(<span>"mongodb"</span>);

<span>const</span> client = <span>new</span> <span>MongoClient</span>(
  process.<span>env</span>.<span>MONGODB_URI</span> || <span>"mongodb://127.0.0.1:27017"</span>
);

<span>let</span> database;

<span>async</span> <span>function</span> <span>getDb</span>(<span></span>) {
  <span>if</span> (!database) {
    <span>await</span> client.<span>connect</span>();
    database = client.<span>db</span>(<span>"shop"</span>);
  }
  <span>return</span> database;
}

<span>module</span>.<span>exports</span> = { getDb, client };

业务代码中直接复用:

<span>const</span> { getDb } = <span>require</span>(<span>"./db"</span>);

<span>async</span> <span>function</span> <span>findAvailableProducts</span>(<span></span>) {
  <span>const</span> db = <span>await</span> <span>getDb</span>();
  <span>return</span> db.<span>collection</span>(<span>"products"</span>)
    .<span>find</span>({ <span>stock</span>: { <span>$gt</span>: <span>0</span> } })
    .<span>sort</span>({ <span>createdAt</span>: -<span>1</span> })
    .<span>limit</span>(<span>20</span>)
    .<span>toArray</span>();
}

七、MongoDB 的数据建模:嵌入还是拆开

MongoDB 的关键不只是“表换成集合”,而是根据读取方式设计文档。

适合嵌入的情况

商品规格放在商品文档内部就很自然:

{
  <span>name</span>: <span>"机械键盘"</span>,
  <span>specs</span>: {
    <span>layout</span>: <span>"87键"</span>,
    <span>switch</span>: <span>"茶轴"</span>
  }
}

适合嵌入的典型特点:

详情页经常一起读取
子数据数量有限
子数据不会被多个主体共享

适合拆成独立集合的情况

订单中的商品快照、商品主数据和库存记录,生命周期可能不同:

products       商品主数据
orders         订单
inventory      库存和仓库信息

适合拆分的典型特点:

子数据数量可能无限增长
子数据需要独立查询和更新
多个业务主体共享同一份数据

MongoDB 支持 $lookup 做集合关联,但它不是把所有关系型表直接搬过来的理由。优先根据业务读取模式设计文档,再决定嵌入、引用或两者结合。

八、常见错误和排查方法

1. ECONNREFUSED 127.0.0.1:27017

通常表示 MongoDB 没启动,或者端口映射不对:

docker ps
docker logs mongodb
mongosh <span>"mongodb://127.0.0.1:27017"</span>

2. Atlas 连接超时

检查三项:

客户端 IP 是否加入 IP Access List
Database User 的账号密码是否正确
连接字符串中的特殊字符是否做了 URL 编码

Atlas 用户账号和 MongoDB Database User 不是同一类账号,前者登录 Atlas 控制台,后者用于连接数据库。

3. 查询数字却查不到

MongoDB 会区分数字和字符串:

{ <span>price</span>: <span>399</span> }      <span>// 数字</span>
{ <span>price</span>: <span>"399"</span> }    <span>// 字符串</span>

这两个值不是同一种类型。字段类型应在写入入口统一处理。

4. 分页越来越慢

skip() 在页数很深时需要跳过大量结果。后台管理列表通常足够使用 skip + limit;大数据量的无限滚动场景,可以改成基于排序字段的游标分页,例如记录上一页最后一个 _id,下一页使用 $lt$gt 继续查询,并配套索引。

5. 把集合当成完全无规则的 JSON 仓库

字段灵活适合迭代,但字段命名、类型和必填规则仍应统一。至少在应用层保证:

price 始终是数字
stock 始终是非负整数
createdAt 始终是 Date
name 具备唯一性或明确允许重复

九、日常命令速查

show dbs                         <span>// 查看数据库</span>
use shop                         <span>// 切换数据库</span>
db                               <span>// 当前数据库</span>
show collections                 <span>// 查看集合</span>
db.<span>products</span>.<span>find</span>()               <span>// 查询集合</span>
db.<span>products</span>.<span>countDocuments</span>()     <span>// 统计文档数</span>
db.<span>products</span>.<span>getIndexes</span>()         <span>// 查看索引</span>
db.<span>products</span>.<span>drop</span>()               <span>// 删除集合</span>
db.<span>dropDatabase</span>()                <span>// 删除当前数据库</span>

数据备份与恢复使用 MongoDB Database Tools:

mongodump --uri=<span>"mongodb://127.0.0.1:27017/shop"</span> --out=./backup
mongorestore --uri=<span>"mongodb://127.0.0.1:27017"</span> ./backup

备份命令应根据实际部署、账号权限和存储策略调整,生产环境还需要验证恢复流程,而不是只验证备份命令能否执行。

十、学习路线

按照下面的顺序继续练习,基本可以覆盖日常开发:

1. mongosh:CRUD、数组、嵌套对象、投影、分页
2. 数据建模:嵌入、引用、文档大小和读取模式
3. 索引:单字段、复合、唯一、TTL、explain
4. 聚合:$match、$project、$unwind、$group、$lookup
5. 驱动:连接池、错误处理、事务、批量写入
6. 运维:认证、权限、备份、副本集、监控

MongoDB 的官方文档:

掌握文档结构、查询条件、更新操作、聚合管道和索引之后,MongoDB 就不再只是“会存 JSON”,而是一套可以支撑真实业务查询和统计的数据库工具。