当前位置: 代码网 > it编程>编程语言>Javascript > MongoDB 从零到能用:文档、CRUD、聚合与 Node.js 实战指南

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

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

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

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

示例使用 mongodb 8.0、mongosh 和 node.js。mongodb 的命令可以在 mongodb shell、compass 的命令区域或应用驱动中使用。

一、mongodb 到底存什么

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

{
  _id: objectid("66b000000000000000000001"),
  name: "机械键盘",
  price: 399,
  stock: 42,
  category: "键盘",
  tags: ["办公", "rgb", "蓝牙"],
  specs: {
    layout: "87键",
    connection: ["usb-c", "bluetooth"]
  },
  createdat: isodate("2026-09-15t08:00:00z")
}

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

和 mysql 的概念对应关系

mysqlmongodb说明
databasedatabase数据库
tablecollection集合,类似数据表
rowdocument文档,类似一行记录
columnfield字段
primary key_id文档唯一标识
wherefilter查询条件
group by$group聚合分组
order bysort()排序
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 exec -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='change-me-please' \
  mongo:8.0

连接:

mongosh "mongodb://admin:change-me-please@127.0.0.1:27018/?authsource=admin"

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 仓库,放进环境变量更安全:

export mongodb_uri='mongodb+srv://user:password@cluster.example.mongodb.net/shop'

三、先用 mongosh 跑通 crud

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

use shop
db

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

1. 插入一条文档

db.products.insertone({
  name: "机械键盘",
  price: 399,
  stock: 42,
  category: "键盘",
  tags: ["办公", "rgb", "蓝牙"],
  specs: {
    layout: "87键",
    connection: ["usb-c", "bluetooth"]
  },
  createdat: new date()
})

执行结果中会出现 insertedid。没有手动提供 _id 时,mongodb 会自动生成一个 objectid

{ acknowledged: true, insertedid: objectid('...') }

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

2. 批量插入

db.products.insertmany([
  {
    name: "人体工学鼠标",
    price: 159,
    stock: 100,
    category: "鼠标",
    tags: ["办公", "无线"],
    specs: { dpi: 4000, connection: ["bluetooth", "2.4g"] },
    createdat: new date()
  },
  {
    name: "显示器支架",
    price: 299,
    stock: 18,
    category: "支架",
    tags: ["办公", "桌搭"],
    specs: { material: "铝合金", maxsize: 32 },
    createdat: new date()
  },
  {
    name: "usb-c 扩展坞",
    price: 229,
    stock: 0,
    category: "扩展坞",
    tags: ["办公", "usb-c"],
    specs: { ports: 8, connection: ["usb-c"] },
    createdat: new date()
  }
])

3. 查询文档

查询全部文档:

db.products.find()

格式化输出:

db.products.find().pretty()

精确匹配:

db.products.find({ category: "键盘" })

比较操作符:

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

常用比较操作符:

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

or 条件:

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

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

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

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

查询一条文档:

db.products.findone({ name: "机械键盘" })

_id 查询:

db.products.findone({
  _id: objectid("66b000000000000000000001")
})

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

4. 数组和嵌套对象查询

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

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

嵌套对象使用点号路径:

// specs.connection 数组包含 bluetooth
db.products.find({ "specs.connection": "bluetooth" })
// specs.dpi 大于 1000
db.products.find({ "specs.dpi": { $gt: 1000 } })

查询字段是否存在:

db.products.find({ "specs.ports": { $exists: true } })

5. 排序、分页和统计

// 价格从高到低
db.products.find().sort({ price: -1 })
// 价格从低到高
db.products.find().sort({ price: 1 })
// 第 2 页,每页 2 条
db.products.find()
  .sort({ createdat: -1, _id: -1 })
  .skip(2)
  .limit(2)

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

统计数量:

db.products.countdocuments({ stock: { $gt: 0 } })

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

6. 更新文档

更新一个字段:

db.products.updateone(
  { name: "机械键盘" },
  { $set: { price: 369, stock: 40 } }
)

增加或减少数字:

db.products.updateone(
  { name: "机械键盘" },
  { $inc: { stock: -1 } }
)

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

db.products.updateone(
  { name: "机械键盘" },
  { $push: { tags: "热卖" } }
)

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

db.products.updateone(
  { name: "机械键盘" },
  { $addtoset: { tags: "办公" } }
)

从数组中移除值:

db.products.updateone(
  { name: "机械键盘" },
  { $pull: { tags: "rgb" } }
)

更新嵌套字段:

db.products.updateone(
  { name: "机械键盘" },
  { $set: { "specs.layout": "75键" } }
)

批量更新:

db.products.updatemany(
  { category: "办公设备", stock: { $gt: 0 } },
  { $set: { sale: true } }
)

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

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

db.products.updateone(
  { name: "桌面音箱" },
  {
    $set: { price: 199, stock: 20, category: "音箱" },
    $setoninsert: { createdat: new date() }
  },
  { upsert: true }
)

7. 删除文档

// 删除一条
db.products.deleteone({ name: "桌面音箱" })
// 删除所有库存为 0 的商品
db.products.deletemany({ stock: 0 })

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

db.products.find({ stock: 0 }, { name: 1, stock: 1 })

清空整个集合:

db.products.deletemany({})

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

db.products.drop()
db.dropdatabase()

四、用聚合管道做统计

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

$match → $group → $sort → $project

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

db.products.aggregate([
  {
    $match: { stock: { $gt: 0 } }
  },
  {
    $group: {
      _id: "$category",
      productcount: { $sum: 1 },
      averageprice: { $avg: "$price" },
      totalstock: { $sum: "$stock" }
    }
  },
  {
    $sort: { productcount: -1, averageprice: 1 }
  },
  {
    $project: {
      _id: 0,
      category: "$_id",
      productcount: 1,
      averageprice: { $round: ["$averageprice", 2] },
      totalstock: 1
    }
  }
])

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

2. 统计标签使用次数

$unwind 会把数组拆成多行:

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

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

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

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

1. 创建和查看索引

// category + price 复合索引
db.products.createindex({ category: 1, price: 1 })

// name 唯一索引
db.products.createindex({ name: 1 }, { unique: true })

// 查看索引
db.products.getindexes()

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

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

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

2. 查看执行计划

db.products.find({ category: "键盘" })
  .explain("executionstats")

重点关注:

ixscan       使用索引扫描
collscan     全集合扫描
totaldocsexamined  检查过的文档数
nreturned          返回的文档数

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

3. ttl 索引

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

db.logintokens.createindex(
  { expiresat: 1 },
  { expireafterseconds: 0 }
)

db.logintokens.insertone({
  token: "demo-token",
  expiresat: new date(date.now() + 10 * 60 * 1000)
})

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

六、node.js 官方驱动实战

官方 node.js driver 提供了 mongoclient、数据库、集合和 crud api。先创建一个独立目录:

mkdir mongodb-demo
cd mongodb-demo
npm init -y
npm install mongodb

创建 index.js

const { mongoclient } = require("mongodb");
const uri = process.env.mongodb_uri || "mongodb://127.0.0.1:27017";
const client = new mongoclient(uri);
async function main() {
  await client.connect();
  const db = client.db("shop");
  const products = db.collection("products");
  // 只创建一次即可;重复执行会得到同名索引已存在的结果
  await products.createindex({ category: 1, price: 1 });
  const insertresult = await products.insertone({
    name: `usb-c 线材-${date.now()}`,
    price: 49,
    stock: 200,
    category: "线材",
    tags: ["办公", "usb-c"],
    createdat: new date()
  });
  console.log("新商品 id:", insertresult.insertedid.tostring());
  const list = await products.find({
    stock: { $gt: 0 },
    price: { $lte: 300 }
  })
    .project({ name: 1, price: 1, stock: 1, _id: 0 })
    .sort({ price: 1 })
    .limit(10)
    .toarray();
  console.table(list);
  const updateresult = await products.updateone(
    { _id: insertresult.insertedid },
    { $inc: { stock: -1 }, $addtoset: { tags: "热卖" } }
  );
  console.log("更新数量:", updateresult.modifiedcount);
  const statistics = await products.aggregate([
    { $match: { stock: { $gt: 0 } } },
    {
      $group: {
        _id: "$category",
        count: { $sum: 1 },
        averageprice: { $avg: "$price" }
      }
    },
    { $sort: { count: -1 } }
  ]).toarray();
  console.log("分类统计:", statistics);
}
main()
  .catch((error) => {
    console.error("mongodb 操作失败:", error);
    process.exitcode = 1;
  })
  .finally(async () => {
    await client.close();
  });

运行:

node index.js

为什么要复用 mongoclient

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

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

// db.js
const { mongoclient } = require("mongodb");

const client = new mongoclient(
  process.env.mongodb_uri || "mongodb://127.0.0.1:27017"
);

let database;

async function getdb() {
  if (!database) {
    await client.connect();
    database = client.db("shop");
  }
  return database;
}

module.exports = { getdb, client };

业务代码中直接复用:

const { getdb } = require("./db");
async function findavailableproducts() {
  const db = await getdb();
  return db.collection("products")
    .find({ stock: { $gt: 0 } })
    .sort({ createdat: -1 })
    .limit(20)
    .toarray();
}

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

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

适合嵌入的情况

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

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

适合嵌入的典型特点:

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

适合拆成独立集合的情况

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

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

适合拆分的典型特点:

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

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

八、常见错误和排查方法

1.econnrefused 127.0.0.1:27017

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

docker ps
docker logs mongodb
mongosh "mongodb://127.0.0.1:27017"

2. atlas 连接超时

检查三项:

客户端 ip 是否加入 ip access list
database user 的账号密码是否正确
连接字符串中的特殊字符是否做了 url 编码

atlas 用户账号和 mongodb database user 不是同一类账号,前者登录 atlas 控制台,后者用于连接数据库。

3. 查询数字却查不到

mongodb 会区分数字和字符串:

{ price: 399 }      // 数字
{ price: "399" }    // 字符串

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

4. 分页越来越慢

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

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

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

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

九、日常命令速查

show dbs                         // 查看数据库
use shop                         // 切换数据库
db                               // 当前数据库
show collections                 // 查看集合
db.products.find()               // 查询集合
db.products.countdocuments()     // 统计文档数
db.products.getindexes()         // 查看索引
db.products.drop()               // 删除集合
db.dropdatabase()                // 删除当前数据库

数据备份与恢复使用 mongodb database tools:

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

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

十、学习路线

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

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

mongodb 的官方文档:

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

到此这篇关于mongodb 从零到能用:文档、crud、聚合与 node.js 实战的文章就介绍到这了,更多相关mongodb 文档、crud、聚合与 node.js 内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com