MongoDB 从零到能用:文档、CRUD、聚合与 Node.js 实战
很多数据库教程停在"插入一条数据、查询一条数据"。真正开始写业务后,马上会遇到更多问题:字段怎么设计?数组怎么查?分页怎么写?索引到底有没有生效?应用代码如何安全地复用数据库连接?
这篇文章用一个商品目录 Demo 串起 MongoDB 的核心能力:从启动数据库开始,依次完成文档建模、增删改查、数组和嵌套对象查询、分页、聚合、索引,再用 Node.js 官方驱动写一个可以直接运行的示例。
示例使用 MongoDB 8.0、mongosh 和 Node.js。MongoDB 的命令可以在 MongoDB Shell、Compass 的命令区域或应用驱动中使用。
一、MongoDB 到底存什么
MongoDB 是文档数据库。数据以 BSON 文档保存,BSON 可以理解成"比 JSON 多了一些类型支持的二进制 JSON"。一条商品数据大致长这样:
javascript
{
_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 的概念对应关系
| MySQL | MongoDB | 说明 |
|---|---|---|
| Database | Database | 数据库 |
| Table | Collection | 集合,类似数据表 |
| Row | Document | 文档,类似一行记录 |
| Column | Field | 字段 |
| Primary Key | _id |
文档唯一标识 |
| WHERE | Filter | 查询条件 |
| GROUP BY | $group |
聚合分组 |
| ORDER BY | sort() |
排序 |
| INDEX | Index | 索引 |
MongoDB 不要求先执行 CREATE TABLE。第一次向集合写入文档时,数据库可以自动创建数据库和集合:
text
shop(数据库)
└── products(集合)
├── document
├── document
└── document
"无固定 Schema"不等于"字段可以随便写"。它表示数据库不会强迫同一个集合中的每条文档完全相同。业务字段仍然应该保持稳定,必要时配合应用层校验或 MongoDB 的 JSON Schema 校验,避免同一字段一会儿存数字、一会儿存字符串。
二、启动 MongoDB
1. Docker 启动本地实例
学习和开发阶段,用 Docker 最省事:
bash
docker run -d \
--name mongodb \
-p 27017:27017 \
-v mongodb-data:/data/db \
mongo:8.0
参数含义:
text
-d 后台运行
--name mongodb 容器名称
-p 27017:27017 映射 MongoDB 默认端口
-v mongodb-data... 把数据保存到 Docker volume
mongo:8.0 使用 MongoDB 8.0 镜像
查看容器状态:
bash
docker ps
docker logs mongodb
进入 MongoDB Shell:
bash
docker exec -it mongodb mongosh
连接地址为:
text
mongodb://127.0.0.1:27017
停止和再次启动:
bash
docker stop mongodb
docker start mongodb
这个本地 Demo 没有开启认证,只适合本机学习。生产环境不能把未认证的 MongoDB 直接暴露在公网。
2. 带账号密码的启动方式
需要演示认证时,可以重新创建一个带管理员账号的容器:
bash
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
连接:
bash
mongosh "mongodb://admin:change-me-please@127.0.0.1:27018/?authSource=admin"
authSource=admin 表示账号在 admin 数据库中认证。密码中包含 @、?、# 等特殊字符时,需要先进行 URI 百分号编码。
3. 使用 MongoDB Atlas
Atlas 是 MongoDB 官方托管服务。创建集群后,需要完成两项配置才能连接:
- 在 Network Access 中加入客户端 IP。
- 创建 MongoDB Database User,并使用该用户生成连接字符串。
Atlas 通常提供这样的 SRV 连接字符串:
text
mongodb+srv://<username>:<password>@<cluster>.mongodb.net/shop
连接字符串不要硬编码到 Git 仓库,放进环境变量更安全:
bash
export MONGODB_URI='mongodb+srv://user:password@cluster.example.mongodb.net/shop'
三、先用 mongosh 跑通 CRUD
启动 mongosh 后,先切换到 Demo 数据库:
javascript
use shop
db
use shop 是切换数据库。只有真正写入数据后,数据库通常才会出现在 show dbs 的列表中。
1. 插入一条文档
javascript
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:
javascript
{ acknowledged: true, insertedId: ObjectId('...') }
_id 在一个集合中必须唯一,它既可以使用自动生成的 ObjectId,也可以使用业务方提供的字符串或其他唯一值。
2. 批量插入
javascript
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. 查询文档
查询全部文档:
javascript
db.products.find()
格式化输出:
javascript
db.products.find().pretty()
精确匹配:
javascript
db.products.find({ category: "键盘" })
比较操作符:
javascript
// 价格大于等于 200
db.products.find({ price: { $gte: 200 } })
// 库存大于 0 且价格不超过 300
db.products.find({
stock: { $gt: 0 },
price: { $lte: 300 }
})
常用比较操作符:
text
$eq 等于
$ne 不等于
$gt 大于
$gte 大于等于
$lt 小于
$lte 小于等于
$in 命中数组中的任意值
$nin 不命中数组中的值
OR 条件:
javascript
db.products.find({
$or: [
{ category: "键盘" },
{ price: { $lt: 200 } }
]
})
只返回需要的字段,称为投影:
javascript
db.products.find(
{ stock: { $gt: 0 } },
{ name: 1, price: 1, stock: 1, _id: 0 }
)
1 表示保留字段,0 表示排除字段。除 _id 外,通常不要在同一个投影中混用"保留"和"排除"两种写法。
查询一条文档:
javascript
db.products.findOne({ name: "机械键盘" })
按 _id 查询:
javascript
db.products.findOne({
_id: ObjectId("66b000000000000000000001")
})
实际返回的 _id 需要替换成终端中插入结果里的值。
4. 数组和嵌套对象查询
数组字段可以直接匹配元素:
javascript
// tags 数组包含"办公"
db.products.find({ tags: "办公" })
// tags 同时包含"办公"和"无线"
db.products.find({
tags: { $all: ["办公", "无线"] }
})
嵌套对象使用点号路径:
javascript
// specs.connection 数组包含 Bluetooth
db.products.find({ "specs.connection": "Bluetooth" })
// specs.dpi 大于 1000
db.products.find({ "specs.dpi": { $gt: 1000 } })
查询字段是否存在:
javascript
db.products.find({ "specs.ports": { $exists: true } })
5. 排序、分页和统计
javascript
// 价格从高到低
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 的文档保持稳定顺序。
统计数量:
javascript
db.products.countDocuments({ stock: { $gt: 0 } })
不建议在数据量很大时直接 find() 后再在应用层截取。过滤、排序、分页尽量交给数据库完成。
6. 更新文档
更新一个字段:
javascript
db.products.updateOne(
{ name: "机械键盘" },
{ $set: { price: 369, stock: 40 } }
)
增加或减少数字:
javascript
db.products.updateOne(
{ name: "机械键盘" },
{ $inc: { stock: -1 } }
)
向数组中追加一个值,重复值也会追加:
javascript
db.products.updateOne(
{ name: "机械键盘" },
{ $push: { tags: "热卖" } }
)
只在数组中不存在时才追加:
javascript
db.products.updateOne(
{ name: "机械键盘" },
{ $addToSet: { tags: "办公" } }
)
从数组中移除值:
javascript
db.products.updateOne(
{ name: "机械键盘" },
{ $pull: { tags: "RGB" } }
)
更新嵌套字段:
javascript
db.products.updateOne(
{ name: "机械键盘" },
{ $set: { "specs.layout": "75键" } }
)
批量更新:
javascript
db.products.updateMany(
{ category: "办公设备", stock: { $gt: 0 } },
{ $set: { sale: true } }
)
没有 办公设备 的 Demo 文档时,这条命令不会更新任何数据,这正好说明更新条件和更新内容是两部分。
使用 upsert 可以实现"有则更新,无则插入":
javascript
db.products.updateOne(
{ name: "桌面音箱" },
{
$set: { price: 199, stock: 20, category: "音箱" },
$setOnInsert: { createdAt: new Date() }
},
{ upsert: true }
)
7. 删除文档
javascript
// 删除一条
db.products.deleteOne({ name: "桌面音箱" })
// 删除所有库存为 0 的商品
db.products.deleteMany({ stock: 0 })
删除前先执行同样的过滤条件查询,确认命中范围:
javascript
db.products.find({ stock: 0 }, { name: 1, stock: 1 })
清空整个集合:
javascript
db.products.deleteMany({})
删除集合和删除数据库属于更大范围的操作:
javascript
db.products.drop()
db.dropDatabase()
四、用聚合管道做统计
普通 find() 适合取文档,统计和转换则使用聚合管道。管道由多个阶段组成,前一个阶段的结果交给下一个阶段:
text
$match → $group → $sort → $project
1. 按分类统计商品数量和平均价格
javascript
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 会把数组拆成多行:
javascript
db.products.aggregate([
{ $unwind: "$tags" },
{
$group: {
_id: "$tags",
count: { $sum: 1 }
}
},
{ $sort: { count: -1, _id: 1 } }
])
一条商品有 3 个标签,经过 $unwind 后会变成 3 个中间结果,再由 $group 统计每个标签出现了多少次。
五、索引:让查询真正跑得快
没有索引时,MongoDB 可能需要扫描集合中的大量文档。高频过滤、排序和唯一性约束,通常都应该结合索引设计。
1. 创建和查看索引
javascript
// category + price 复合索引
db.products.createIndex({ category: 1, price: 1 })
// name 唯一索引
db.products.createIndex({ name: 1 }, { unique: true })
// 查看索引
db.products.getIndexes()
复合索引的字段顺序很重要。{ category: 1, price: 1 } 适合先按分类过滤、再按价格排序的查询:
javascript
db.products.find({ category: "键盘" }).sort({ price: 1 })
不能把索引理解成"越多越好"。每个索引都会占用磁盘和内存,写入时也需要同步维护。应围绕真实查询建立索引。
2. 查看执行计划
javascript
db.products.find({ category: "键盘" })
.explain("executionStats")
重点关注:
text
IXSCAN 使用索引扫描
COLLSCAN 全集合扫描
totalDocsExamined 检查过的文档数
nReturned 返回的文档数
数据量很小时,索引收益可能不明显;判断索引是否合适,应在接近真实数据量的环境中观察执行计划。
3. TTL 索引
临时数据可以使用 TTL 索引自动过期。TTL 字段必须是日期类型:
javascript
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。先创建一个独立目录:
bash
mkdir mongodb-demo
cd mongodb-demo
npm init -y
npm install mongodb
创建 index.js:
javascript
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();
});
运行:
bash
node index.js
为什么要复用 MongoClient
MongoClient 内部会管理连接池。Web 服务启动时创建一个客户端,后续请求复用同一个客户端即可,不要每次接口请求都 new MongoClient()、连接、关闭。
一个简单的模块封装如下:
javascript
// 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 };
业务代码中直接复用:
javascript
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 的关键不只是"表换成集合",而是根据读取方式设计文档。
适合嵌入的情况
商品规格放在商品文档内部就很自然:
javascript
{
name: "机械键盘",
specs: {
layout: "87键",
switch: "茶轴"
}
}
适合嵌入的典型特点:
text
详情页经常一起读取
子数据数量有限
子数据不会被多个主体共享
适合拆成独立集合的情况
订单中的商品快照、商品主数据和库存记录,生命周期可能不同:
text
products 商品主数据
orders 订单
inventory 库存和仓库信息
适合拆分的典型特点:
text
子数据数量可能无限增长
子数据需要独立查询和更新
多个业务主体共享同一份数据
MongoDB 支持 $lookup 做集合关联,但它不是把所有关系型表直接搬过来的理由。优先根据业务读取模式设计文档,再决定嵌入、引用或两者结合。
八、常见错误和排查方法
1. ECONNREFUSED 127.0.0.1:27017
通常表示 MongoDB 没启动,或者端口映射不对:
bash
docker ps
docker logs mongodb
mongosh "mongodb://127.0.0.1:27017"
2. Atlas 连接超时
检查三项:
text
客户端 IP 是否加入 IP Access List
Database User 的账号密码是否正确
连接字符串中的特殊字符是否做了 URL 编码
Atlas 用户账号和 MongoDB Database User 不是同一类账号,前者登录 Atlas 控制台,后者用于连接数据库。
3. 查询数字却查不到
MongoDB 会区分数字和字符串:
javascript
{ price: 399 } // 数字
{ price: "399" } // 字符串
这两个值不是同一种类型。字段类型应在写入入口统一处理。
4. 分页越来越慢
skip() 在页数很深时需要跳过大量结果。后台管理列表通常足够使用 skip + limit;大数据量的无限滚动场景,可以改成基于排序字段的游标分页,例如记录上一页最后一个 _id,下一页使用 $lt 或 $gt 继续查询,并配套索引。
5. 把集合当成完全无规则的 JSON 仓库
字段灵活适合迭代,但字段命名、类型和必填规则仍应统一。至少在应用层保证:
text
price 始终是数字
stock 始终是非负整数
createdAt 始终是 Date
name 具备唯一性或明确允许重复
九、日常命令速查
javascript
show dbs // 查看数据库
use shop // 切换数据库
db // 当前数据库
show collections // 查看集合
db.products.find() // 查询集合
db.products.countDocuments() // 统计文档数
db.products.getIndexes() // 查看索引
db.products.drop() // 删除集合
db.dropDatabase() // 删除当前数据库
数据备份与恢复使用 MongoDB Database Tools:
bash
mongodump --uri="mongodb://127.0.0.1:27017/shop" --out=./backup
mongorestore --uri="mongodb://127.0.0.1:27017" ./backup
备份命令应根据实际部署、账号权限和存储策略调整,生产环境还需要验证恢复流程,而不是只验证备份命令能否执行。
十、学习路线
按照下面的顺序继续练习,基本可以覆盖日常开发:
text
1. mongosh:CRUD、数组、嵌套对象、投影、分页
2. 数据建模:嵌入、引用、文档大小和读取模式
3. 索引:单字段、复合、唯一、TTL、explain
4. 聚合:$match、$project、$unwind、$group、$lookup
5. 驱动:连接池、错误处理、事务、批量写入
6. 运维:认证、权限、备份、副本集、监控
MongoDB 的官方文档:
掌握文档结构、查询条件、更新操作、聚合管道和索引之后,MongoDB 就不再只是"会存 JSON",而是一套可以支撑真实业务查询和统计的数据库工具。