POST · 数据库 · 文章
MongoDB 从入门到进阶
2026/11/13 · by ZHANG XING YUN · 阅读 3 次 · 3667 字 · 预计阅读 10 分钟
本文覆盖 MongoDB 的文档模型、CRUD、查询与更新操作符、索引、聚合管道、事务、副本集与读写关注、Schema 设计与运维命令。
- 环境:MongoDB 7、mongosh
- 定位:文档型数据库,适合结构灵活、以整体读写为主的数据
一、文档模型与基本概念
| 关系型 | MongoDB | 说明 |
|---|---|---|
| database | database | 数据库 |
| table | collection | 集合,无固定结构 |
| row | document | 文档,JSON 形式(底层 BSON) |
| column | field | 字段,可嵌套 |
| primary key | _id |
默认唯一索引,未指定时自动生成 ObjectId |
| join | $lookup |
聚合阶段,能力弱于 SQL JOIN |
| transaction | transaction | 4.0+ 副本集、4.2+ 分片支持 |
{
"_id": { "$oid": "65f0c3a1e2b4c9d8f7a6b5c4" },
"title": "第一条笔记",
"tags": ["demo", "mongodb"],
"author": { "id": 1001, "name": "张三" },
"createdAt": { "$date": "2026-01-13T10:00:00.000Z" }
}
文档的特点:一个文档就是一个完整的对象,读取时一次取回,不需要 JOIN;代价是跨文档的关联与事务能力弱于关系库。
二、安装与连接
2.1 Docker
docker run -d --name mongo \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=root \
-e MONGO_INITDB_ROOT_PASSWORD=secret \
-v mongodata:/data/db \
mongo:7
带认证的连接串必须指定 authSource=admin:
mongodb://root:secret@localhost:27017/appdb?authSource=admin
2.2 连接
mongosh "mongodb://root:secret@localhost:27017/appdb?authSource=admin"
# 或分步
mongosh
> use admin
> db.auth("root", "secret")
> use appdb
2.3 单机启用认证
mongod --auth --dbpath /data/db
首次需要先用 localhost 例外创建管理员:
use admin
db.createUser({ user: "root", pwd: "secret", roles: [{ role: "root", db: "admin" }] })
三、mongosh 常用命令
| 命令 | 作用 |
|---|---|
show dbs |
列出数据库 |
use appdb |
切换数据库(不存在则延迟创建) |
show collections |
列出集合 |
db.stats() |
数据库统计 |
db.users.countDocuments() |
文档数 |
db.users.find().limit(5) |
查询 |
db.users.getIndexes() |
索引 |
db.users.explain().find(...) |
执行计划 |
db.currentOp() |
当前操作 |
db.killOp(id) |
终止操作 |
db.setProfilingLevel(2) |
开启慢查询分析 |
exit |
退出 |
集合不存在时,首次写入会自动创建;也可以显式创建并指定选项:
db.createCollection("notes", {
validator: { $jsonSchema: { bsonType: "object", required: ["title"] } }
})
四、BSON 数据类型
| 类型 | 示例 | 说明 |
|---|---|---|
| String | "abc" |
|
| Int32 / Int64 | 1、NumberLong("1") |
整数有范围区分 |
| Double | 1.5 |
浮点 |
| Decimal128 | NumberDecimal("19.99") |
金额必须用它 |
| Boolean | true |
|
| Date | ISODate("2026-01-13T10:00:00Z") |
毫秒精度,UTC |
| ObjectId | ObjectId("...") |
12 字节,含时间戳 |
| Array | [1, 2] |
可混合类型 |
| Object | { a: 1 } |
嵌套文档 |
| Null | null |
|
| Binary | BinData(...) |
二进制 |
| Regex | /abc/i |
正则 |
两个容易踩的点:
- 金额用
Decimal128,不要用Number(double 精度问题)。 - 同一个字段在不同文档里可能是不同 BSON 类型(如有的存字符串、有的存数字),查询时不会互相匹配,需要统一写入路径。
五、插入文档
db.users.insertOne({ name: "张三", email: "a@example.com", points: 10 })
db.users.insertMany([
{ name: "李四", email: "b@example.com" },
{ name: "王五", email: "c@example.com" }
])
// 指定 _id 与时间
db.notes.insertOne({
_id: ObjectId(),
title: "第一条",
createdAt: new Date()
})
返回结果包含 insertedId / insertedIds。插入时未定义的字段不会占位(与关系表不同),因此同一集合的文档可以结构不同。
六、查询
6.1 基本查询
db.users.find({ name: "张三" })
db.users.findOne({ email: "a@example.com" })
db.users.find({}, { name: 1, email: 1, _id: 0 }) // 投影:1 保留,0 排除
投影规则:_id 默认返回,排除需显式 _id: 0;除 _id 外不能混用 1 与 0。
6.2 查询操作符
| 类别 | 操作符 | 示例 |
|---|---|---|
| 比较 | $eq $ne $gt $gte $lt $lte $in $nin |
{ age: { $gte: 18 } } |
| 逻辑 | $and $or $nor $not |
{ $or: [{ a: 1 }, { b: 2 }] } |
| 存在 | $exists |
{ email: { $exists: true } } |
| 类型 | $type |
{ age: { $type: "int" } } |
| 数组 | $all $size $elemMatch |
{ tags: { $all: ["a", "b"] } } |
| 正则 | $regex |
{ name: { $regex: "^张" } } |
| 空值 | null |
{ email: null } 同时匹配 null 与缺失字段 |
// 数组包含某个值
db.notes.find({ tags: "mongodb" })
// 数组元素同时满足多个条件
db.notes.find({ tags: { $elemMatch: { $regex: "^demo" } } })
// 嵌套字段
db.users.find({ "author.id": 1001 })
// 组合条件
db.users.find({
$and: [
{ points: { $gte: 10 } },
{ $or: [{ status: "active" }, { status: { $exists: false } }] }
]
})
null 查询的陷阱:{ email: null } 会匹配"值为 null"和"字段不存在"两种情况;只想匹配显式 null 时用 { email: { $type: "null" } }。
6.3 排序与分页
db.users.find().sort({ createdAt: -1 }).skip(20).limit(10)
skip 越大越慢,深分页改用游标:
db.users.find({ createdAt: { $lt: lastCreatedAt } })
.sort({ createdAt: -1 }).limit(20)
6.4 计数与去重
db.users.countDocuments({ status: "active" })
db.users.distinct("status")
countDocuments 是准确计数(会扫描);estimatedDocumentCount 用元数据,快但只是近似。
七、更新
// 更新一条
db.users.updateOne({ email: "a@example.com" }, { $set: { points: 20 } })
// 更新多条
db.users.updateMany({ status: "active" }, { $inc: { points: 1 } })
// 替换整份文档(只保留 _id 与新内容)
db.users.replaceOne({ email: "a@example.com" }, { name: "张三", email: "a@example.com" })
// upsert:不存在则插入
db.users.updateOne(
{ email: "d@example.com" },
{ $set: { name: "赵六" }, $setOnInsert: { createdAt: new Date() } },
{ upsert: true }
)
7.1 更新操作符
| 操作符 | 作用 |
|---|---|
$set |
设置字段 |
$unset |
删除字段 |
$inc |
数值增减 |
$mul |
数值相乘 |
$min / $max |
取较小/较大值 |
$push |
数组追加 |
$addToSet |
数组追加(去重) |
$pull |
数组移除匹配元素 |
$pop |
移除首/尾元素 |
$rename |
重命名字段 |
$currentDate |
设为当前时间 |
// 数组操作
db.notes.updateOne(
{ _id: noteId },
{
$addToSet: { tags: "new" },
$push: { comments: { user: "u1", text: "好" } }
}
)
// 删除数组中的元素
db.notes.updateOne({ _id: noteId }, { $pull: { tags: "new" } })
7.2 原子性与返回
- 单文档更新是原子的;多文档更新不是。
findOneAndUpdate可以在同一次操作中返回更新前后文档:
db.users.findOneAndUpdate(
{ email: "a@example.com" },
{ $inc: { points: 1 } },
{ returnDocument: "after" }
)
7.3 管道更新(4.2+)
db.users.updateMany(
{ points: { $lt: 0 } },
[{ $set: { points: 0, updatedAt: "$$NOW" } }]
)
八、删除
db.users.deleteOne({ email: "a@example.com" })
db.users.deleteMany({ status: "disabled" })
db.users.drop() // 删除集合
db.dropDatabase() // 删除数据库
deleteMany({}) 会清空集合但保留索引,drop() 会连同索引一起删除。
九、索引
db.users.createIndex({ email: 1 }, { unique: true })
db.orders.createIndex({ userId: 1, createdAt: -1 })
// 部分索引:只为满足条件的文档建索引
db.orders.createIndex({ status: 1 }, { partialFilterExpression: { status: "pending" } })
// TTL 索引:到期自动删除(如会话、验证码)
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
// 文本索引
db.notes.createIndex({ title: "text", body: "text" })
// 唯一 + 稀疏:只对存在的字段生效
db.users.createIndex({ phone: 1 }, { unique: true, sparse: true })
db.users.getIndexes()
db.users.dropIndex("email_1")
索引类型:
| 类型 | 用途 |
|---|---|
| 单键 | 常规查询 |
| 复合 | 多字段条件,遵循最左前缀 |
| 多键(数组) | 数组元素查询 |
| 文本 | 全文检索(一个集合只能一个) |
| 哈希 | 分片键,等值查询 |
| TTL | 过期自动清理 |
| 部分 | 仅对部分文档建索引,节省空间 |
复合索引的最左前缀原则:{ a: 1, b: 1 } 能支持 a、a + b 的查询,不能单独支持 b。
查看索引使用情况:
db.users.aggregate([{ $indexStats: {} }]).forEach(i => print(i.name, i.accesses.ops))
长期 ops 为 0 的索引可以考虑删除。
十、聚合管道
聚合是把文档依次经过多个阶段处理,类似 Unix 管道。
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$userId", total: { $sum: "$amount" }, count: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 10 }
])
常用阶段:
| 阶段 | 作用 |
|---|---|
$match |
过滤,尽量放在最前并命中索引 |
$project |
选择/计算字段 |
$group |
分组聚合 |
$sort |
排序 |
$limit / $skip |
分页 |
$unwind |
展开数组 |
$lookup |
关联其他集合 |
$facet |
同一批数据多路聚合 |
$bucket |
按区间分桶 |
$addFields |
增加计算字段 |
$replaceRoot |
替换文档根 |
10.1 分组与累计
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
{
$group: {
_id: { day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } } },
total: { $sum: "$amount" },
avg: { $avg: "$amount" }
}
},
{ $sort: { "_id.day": 1 } }
])
10.2 关联($lookup)
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: { path: "$user", preserveNullAndEmptyArrays: true } },
{ $project: { orderId: "$_id", amount: 1, "user.name": 1 } }
])
$lookup 不会自动走索引,foreignField 必须有索引,否则大集合会非常慢。另一个常见问题是两边字段类型必须一致:如果 orders.userId 存的是字符串而 users._id 是 ObjectId,关联结果永远是空数组。
10.3 分面查询
db.products.aggregate([
{ $match: { category: "book" } },
{
$facet: {
byPrice: [{ $group: { _id: null, min: { $min: "$price" }, max: { $max: "$price" } } }],
topTags: [{ $unwind: "$tags" }, { $group: { _id: "$tags", cnt: { $sum: 1 } } }, { $sort: { cnt: -1 } }, { $limit: 5 }]
}
}
])
十一、事务与一致性
MongoDB 4.0+(副本集)/ 4.2+(分片)支持多文档事务。
const session = db.getMongo().startSession()
session.startTransaction()
try {
const users = session.getDatabase("appdb").users
const orders = session.getDatabase("appdb").orders
users.updateOne({ _id: userId }, { $inc: { points: -100 } }, { session })
orders.insertOne({ userId, amount: 100, createdAt: new Date() }, { session })
session.commitTransaction()
} catch (error) {
session.abortTransaction()
throw error
} finally {
session.endSession()
}
约束与代价:
- 事务默认 60 秒超时,操作数有上限。
- 跨分片事务性能明显下降。
- 事务内不要做远程调用或长时间计算。
MongoDB 的设计哲学是"单文档原子",能用一个文档表达的数据尽量放一个文档,减少事务需求。
十二、副本集与写关注、读关注
副本集:一主多从,主节点接收写,从节点异步复制。
mongod --replSet rs0 --dbpath /data/db1 --port 27017
mongosh --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
rs.status()
写关注(writeConcern):
| 值 | 含义 |
|---|---|
w: 1 |
主节点写入成功即返回(默认) |
w: "majority" |
多数节点确认,抗主节点宕机 |
w: 0 |
不等待确认,可能丢数据 |
j: true |
写入 journal 后才确认 |
读关注(readConcern):
| 值 | 含义 |
|---|---|
local |
默认,读取本地最新数据 |
majority |
读取多数节点确认的数据,避免回滚 |
snapshot |
事务内一致快照 |
读偏好(readPreference):primary(默认)、primaryPreferred、secondary、nearest。
关键数据(账务、订单)建议 writeConcern: { w: "majority", j: true } + readConcern: "majority",代价是延迟增加。
十三、Schema 设计:嵌入还是引用
| 方式 | 写法 | 适用 |
|---|---|---|
| 嵌入 | 子文档放在父文档内 | 一对一、一对少、总是整体读取 |
| 引用 | 存对方 _id |
一对多、数据量大、独立更新 |
// 嵌入:订单与其明细,总是整体读取
{ _id: 1, items: [{ sku: "A", qty: 2 }, { sku: "B", qty: 1 }], total: 100 }
// 引用:文章与评论,评论量大且独立查询
{ _id: 1, title: "..." }
{ _id: 2, postId: 1, text: "..." }
判断标准:
- 子数据是否总是与父数据一起读?是 → 嵌入。
- 子数据是否会无限增长?是 → 引用(文档有 16MB 上限)。
- 子数据是否需要独立查询与更新?是 → 引用。
16MB 文档上限是硬约束,日志、消息、评论这类会不断追加的数据不要嵌在同一个文档里。
十四、数据校验
db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["email", "name"],
properties: {
email: { bsonType: "string", pattern: "^.+@.+$" },
name: { bsonType: "string", maxLength: 64 },
points: { bsonType: "int", minimum: 0 }
},
additionalProperties: true
}
},
validationLevel: "strict", // strict | moderate
validationAction: "error" // error | warn
})
已有集合可以用 collMod 添加校验:
db.runCommand({
collMod: "users",
validator: { $jsonSchema: { bsonType: "object", required: ["email"] } }
})
校验只能保证"写入符合规则",历史数据不会被自动修正。
十五、用户与权限
use appdb
db.createUser({
user: "app",
pwd: "secret",
roles: [
{ role: "readWrite", db: "appdb" },
{ role: "read", db: "reporting" }
]
})
db.getUsers()
db.updateUser("app", { roles: [{ role: "read", db: "appdb" }] })
db.dropUser("app")
角色粒度:read、readWrite、dbAdmin、userAdmin、clusterAdmin、root。应用账号不要给 root 或 clusterAdmin。
十六、备份与恢复
# 逻辑备份
mongodump --uri="mongodb://root:secret@localhost:27017/appdb?authSource=admin" --out=/backup/20260113
# 恢复
mongorestore --uri="mongodb://root:secret@localhost:27017/appdb?authSource=admin" /backup/20260113
# 单集合导出为 JSON / CSV
mongoexport --db=appdb --collection=users --out=users.json
mongoimport --db=appdb --collection=users --file=users.json
生产环境备份策略:
- 副本集可直接用
mongodump(对主节点压力较大时从从节点备份)。 - 大库用文件系统快照或云厂商的持续备份。
- 定期验证恢复流程。
十七、性能与排查
// 执行计划
db.users.find({ email: "a@example.com" }).explain("executionStats")
// 关注字段
// executionStats.totalDocsExamined 与 nReturned 的比值:接近 1 说明索引有效
// executionStats.executionTimeMillis
// winningPlan.stage:COLLSCAN 表示全表扫描
慢查询分析:
db.setProfilingLevel(1, { slowms: 100 })
db.system.profile.find().sort({ ts: -1 }).limit(5)
db.setProfilingLevel(0)
常用诊断命令:
db.currentOp({ "secs_running": { $gte: 5 } })
db.serverStatus().connections
db.stats()
db.users.stats()
优化方向:
- 为查询条件与排序字段建复合索引,注意最左前缀。
- 避免全表扫描(
COLLSCAN)。 - 只投影需要的字段,减少网络与内存开销。
- 控制文档大小,避免文档膨胀导致迁移。
- 大结果集用分页或游标,不要一次
toArray()。
十八、常见错误
| 错误 | 原因 | 处理 |
|---|---|---|
Authentication failed |
连接串缺少 authSource=admin |
补上参数 |
E11000 duplicate key error |
唯一索引冲突 | 先查询再更新,或用 upsert |
BSONObj size ... is invalid |
单文档超过 16MB | 拆分文档或改引用结构 |
not authorized on appdb to execute command |
权限不足 | 授予对应 role |
MongoServerSelectionError |
连不上副本集/网络不通 | 检查连接串、DNS、防火墙、副本集状态 |
查询慢、COLLSCAN |
缺索引或索引不匹配 | explain 后补复合索引 |
Transaction numbers are only allowed on a replica set member or mongos |
单机实例开了事务 | 使用副本集 |
Date 显示差 8 小时 |
存储为 UTC,展示未转换 | 前端/服务端按本地时区格式化 |
十九、FAQ
Q1:MongoDB 适合什么场景?
结构不固定或嵌套明显的数据:内容、表单、日志、会话、商品属性。需要强事务与多表关联的核心业务建议用关系库。
Q2:_id 可以用业务字段吗?
可以,但要求全局唯一且不变。用业务键做主键后,一旦业务键变化就要迁移文档;默认 ObjectId 更省心。
Q3:skip 分页为什么慢?
skip(n) 需要扫描并丢弃前 n 条。深分页用"上次最后一条的排序值"作为游标。
Q4:$lookup 性能差怎么办?
确认 foreignField 有索引;能嵌入就不要关联;高频关联场景考虑在写入时冗余必要字段。
Q5:数组字段怎么建索引?
直接对数组字段建普通索引即为多键索引,MongoDB 会自动为每个元素建条目。注意一个复合索引里最多一个数组字段。
Q6:事务能跨集合吗?
可以(同一副本集内)。跨分片事务也支持,但性能代价大,应尽量避免。
Q7:如何保证不重复插入?
用唯一索引 + updateOne(..., { upsert: true }),或插入时捕获 E11000 并转为幂等成功。
Q8:金额用什么类型?
Decimal128(NumberDecimal("19.99"))。用 double 会出现精度误差。
Q9:如何清理过期数据?
TTL 索引:createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })。后台任务每分钟扫描一次,不保证精确到秒。
Q10:单机能不能用于生产?
不建议。单机没有冗余,主节点故障即不可用,且无法使用事务。生产至少三节点副本集。
附录:命令速查
// 库与集合
show dbs
use appdb
show collections
db.createCollection("c")
db.c.drop()
db.dropDatabase()
// 增
db.c.insertOne({...})
db.c.insertMany([{...}, {...}])
// 查
db.c.find({ field: value })
db.c.findOne({ _id: ObjectId("...") })
db.c.find({ age: { $gte: 18 } }).sort({ age: -1 }).limit(10)
db.c.countDocuments({})
db.c.distinct("field")
// 改
db.c.updateOne({ _id }, { $set: { a: 1 } })
db.c.updateMany({}, { $inc: { n: 1 } })
db.c.replaceOne({ _id }, {...})
db.c.findOneAndUpdate({ _id }, { $set: {...} }, { returnDocument: "after" })
// 删
db.c.deleteOne({ _id })
db.c.deleteMany({ status: "x" })
// 索引
db.c.createIndex({ a: 1, b: -1 }, { unique: true })
db.c.getIndexes()
db.c.dropIndex("a_1")
// 聚合
db.c.aggregate([{ $match: {} }, { $group: { _id: "$f", n: { $sum: 1 } } }])
// 管理
db.c.explain("executionStats").find({...})
db.setProfilingLevel(1, { slowms: 100 })
db.stats()
db.serverStatus().connections