1. 为什么MongoDB的CRUD值得单独拿出来讲
1.1 CRUD是MongoDB的基本功
CRUD说的是Create(创建)、Read(读取)、Update(更新)、Delete(删除)这四类最基础的数据库操作。在MongoDB里,几乎所有的业务逻辑最终都会落到这四类操作上——用户注册时要插入文档,查询个人信息时要find,改手机号时要update,注销账号时要delete。你在网上搜“MongoDB增删改查”,搜出来的东西看着挺多,但多数不是太零碎就是直接贴官方文档,新手看了反而一头雾水——尤其是从关系型数据库MySQL转过来的朋友,最容易踩“字段”“文档”“集合”这个概念差异的坑。
这篇我打算把MongoDB的CRUD完整过一遍,从环境安装到基础语法,再到数组查询、分页、更新操作符这些高频细节,尽量用实际可跑的示例和踩坑经验来讲,而不是单纯堆命令。如果你是刚接触MongoDB、或者用过但不熟、或者正在准备把项目后端存储切到MongoDB的开发者,这篇应该能帮你省不少折腾时间。
1.2 上手前需要先建立的三个概念
MongoDB和MySQL最大的区别,是你操作的数据结构不一样。MySQL里是数据库→表→行,MongoDB里则是数据库→集合(collection)→文档(document)。这个“文档”本质上是一段BSON格式的数据,BSON你可以理解成JSON的二进制版本,支持嵌套对象和数组。所以MongoDB里一条记录可以长下面这样:
javascript复制{
"_id": ObjectId("65f1b2d0c3a1a20001e5f001"),
"username": "zhangsan",
"email": "zhangsan@example.com",
"tags": ["developer", "mongodb"],
"address": {
"city": "Beijing",
"zip": "100000"
}
}
注意几个点:_id 是文档的主键,插入时不指定它会自动生成一个ObjectId;字段可以嵌套对象,也可以存数组,这比关系型数据库灵活非常多。也正是因为这种结构,MongoDB在处理“用户资料、订单记录、日志、内容管理”这类数据结构不太固定的场景时特别顺手——不需要提前设计表结构,存进去就是结构,改起来也不用写ALTER TABLE。
另外一个概念是“集合”,你可以把集合当成一张表,但它不强制字段一致。同一个集合里,两条文档可以长得完全不一样,这在开发早期迭代特别快的时候非常友好。不过我不建议真这么干——字段全不一致后期查询维护都是灾难。让集合保持一定的结构一致性,是负责任的开发者应该做的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:先装一个能用的MongoDB
2.1 Debian系安装MongoDB
很多服务器用的都是Debian或Ubuntu,安装MongoDB时最大的坑是直接用系统源装——Debian自带的源里MongoDB版本往往很老,而且从4.x之后MongoDB官方就不再直接往Debian的官方源里放包了。正确姿势是先添加MongoDB官方源。
以Debian 11为例,需要先导入GPG公钥并添加源,然后更新索引再安装。我用的是mongodb-org这个元包,因为它会把服务、shell、工具等核心组件一起带上,比单独装mongod干净:
bash复制# 安装gnupg等依赖
sudo apt-get install -y gnupg curl
# 导入MongoDB公共GPG密钥
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg \
--dearmor
# 添加7.0版本的软件源列表
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] http://repo.mongodb.org/apt/debian bullseye/mongodb-org/7.0 main" | \
sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
# 更新并安装
sudo apt-get update
sudo apt-get install -y mongodb-org
安装完成后启动服务,并设置开机自启:
bash复制sudo systemctl enable mongod
sudo systemctl start mongod
然后检查一下状态,看到 active (running) 基本就稳了:
bash复制sudo systemctl status mongod
如果你只是想快速试验,不想配官方源,Docker跑一个其实更快——拉个mongo镜像,一条命令挂载数据目录就行。但生产环境我还是建议走官方源安装,虽然配置步骤多一点,可靠性高得多。
2.2 Windows安装与升级到4.4.30的注意点
Windows下安装要简单不少,去官网下载MSI安装包双击安装即可。但很多人在Windows上升级MongoDB时会遇到问题——尤其是在升级到4.4.30这种小版本时。最典型的情况是升级后服务无法启动,日志里报各种“无法定位数据目录”或者版本不兼容的错。
实际操作经验是:升级前先备份数据,然后停掉服务,再运行新的MSI包。安装完成后如果发现服务启动不了,要先看看 C:\Program Files\MongoDB\Server\4.4\bin\mongod.cfg 这个配置文件里的 dbPath 和 logPath 是否存在、权限是否正确。Windows上很容易因为安装路径或者data目录权限不对导致mongod起不来。
还有一个很多人忽略的点:4.4版本的mongod默认不再支持旧版MMAPv1存储引擎,如果你的数据还是老版本用了MMAPv1,升级前必须用 mongod --repair 或者导出导入的方式把数据转换到WiredTiger。否则升级完一启动就崩。
2.3 接入mongosh:命令行操作的第一道门
装好服务后,如果还没装shell,需要自己安装 mongodb-mongosh。Debian下和mongod一起装 mongodb-org 会带上mongosh,但如果你只是单独升级server包,mongosh可能不存在,需要单独装。连接方式很简单:
bash复制mongosh --host 127.0.0.1 --port 27017
进入shell后可以先用 db.version() 确认版本,用 show dbs 看看现有数据库。日常开发中你也可以通过连接串来远程连接,格式是 mongodb://user:pass@host:port/dbname?authSource=admin,这个在C#、Java、Node.js等客户端里是通用的。
3. CRUD操作实战:从insertOne到deleteMany
3.1 创建操作:insertOne与insertMany
进入mongosh之后,先建一个库和集合,开始动手测。MongoDB有个很省事的设计:使用库或集合时如果它不存在,会自动创建。比如下面这条命令,如果test库和users集合不存在,会直接创建:
javascript复制use test
插入单条文档用 insertOne:
javascript复制db.users.insertOne({
username: "zhangsan",
email: "zhangsan@example.com",
age: 28,
tags: ["developer", "mongodb"],
address: { city: "Beijing", zip: "100000" }
})
插入成功后返回的 insertedId 很重要,如果插入时没指定 _id,MongoDB会生成一个ObjectId并放在这里返回。批量插入用 insertMany,注意传的是一个数组:
javascript复制db.users.insertMany([
{ username: "lisi", email: "lisi@example.com", age: 32 },
{ username: "wangwu", email: "wangwu@example.com", age: 24 }
])
这里有个值得说的细节:insertMany 默认按顺序插入,只要中间有一条文档插入失败,后面的都不会再执行,已经成功的不会回滚。如果你希望部分失败不影响后面的,可以传 { ordered: false } 选项:
javascript复制db.users.insertMany([...], { ordered: false })
我实际用的时候建议生产环境不要把太大的一批文档塞给insertMany,一般每次几百条以内比较稳妥,太多容易撑爆内存或导致主从复制延迟。
3.2 查询操作:find的完整用法
查询是CRUD里最常用也是内容最多的部分。find() 返回的是一个游标,你在mongosh里直接执行它会自动打印前20条。findOne() 则返回第一条匹配的文档。
最简单的全量查询:
javascript复制db.users.find()
带条件查询,直接传一个匹配对象:
javascript复制db.users.find({ username: "zhangsan" })
条件操作符是MongoDB查询的重头戏。比如查询年龄大于等于30的用户:
javascript复制db.users.find({ age: { $gte: 30 } })
常用操作符可以做一个速查对照:
| 操作符 | 含义 | 示例 |
|---|---|---|
$eq |
等于 | { age: { $eq: 30 } } |
$ne |
不等于 | { age: { $ne: 30 } } |
$gt / $gte |
大于 / 大于等于 | { age: { $gt: 30 } } |
$lt / $lte |
小于 / 小于等于 | { age: { $lt: 30 } } |
$in / $nin |
在某个数组内 / 不在 | { age: { $in: [24, 28] } } |
$regex |
正则匹配 | { username: { $regex: /^zhang/ } } |
逻辑操作符 $and、$or、$not 也经常用到。比如查年龄大于25且城市是北京的用户:
javascript复制db.users.find({
age: { $gt: 25 },
"address.city": "Beijing"
})
注意嵌套字段要用点号 "address.city" 来访问,这个语法新手很容易忽略。查满足多个条件中任意一个的:
javascript复制db.users.find({
$or: [
{ age: { $lt: 25 } },
{ email: "lisi@example.com" }
]
})
$and 一般不需要显式写,因为 find 的多个条件默认就是AND。但如果你需要同一个字段既匹配A又匹配B,就要显式用 $and 了——比如查年龄既大于25又小于30:
javascript复制db.users.find({
$and: [
{ age: { $gt: 25 } },
{ age: { $lt: 30 } }
]
})
另外,find 第二个参数是投影(projection),控制返回哪些字段。比如只要username和email,不要_id:
javascript复制db.users.find(
{ age: { $gte: 25 } },
{ username: 1, email: 1, _id: 0 }
)
1表示返回,0表示不返回。注意 _id 默认是返回的,不想返回就得显式写0。
3.3 更新操作:updateOne与updateMany
更新操作是我见过新手最容易踩坑的地方。MySQL的UPDATE是直接写新值,而MongoDB的更新操作符要花点时间适应,但用习惯以后真的方便。
先看最常用的 $set,它只更新指定字段,其他字段保持不变:
javascript复制db.users.updateOne(
{ username: "zhangsan" },
{ $set: { age: 29, email: "zhangsan_new@example.com" } }
)
updateOne 的返回结果里有个 matchedCount 表示匹配到了几条,modifiedCount 表示实际修改了几条。如果你发现 matchedCount 是1但 modifiedCount 是0,多半是更新的值和原来一样——这不算错误。
批量更新用 updateMany。比如给所有年龄小于25的用户加一个标记:
javascript复制db.users.updateMany(
{ age: { $lt: 25 } },
{ $set: { isYoung: true } }
)
$inc 是做数值增减的,比如给zhangsan的age加1:
javascript复制db.users.updateOne(
{ username: "zhangsan" },
{ $inc: { age: 1 } }
)
注意 $inc 操作的字段必须存在且是数值类型,如果字段不存在它会自动创建并设置为增量值。
$unset 用于删除字段:
javascript复制db.users.updateOne(
{ username: "zhangsan" },
{ $unset: { isYoung: "" } }
)
数组字段的更新操作要单独讲,因为特别常用。$push 往数组末尾追加一个元素:
javascript复制db.users.updateOne(
{ username: "zhangsan" },
{ $push: { tags: "database" } }
)
$addToSet 则保证不添加重复元素,如果数组里已经有该值就不会重复添加:
javascript复制db.users.updateOne(
{ username: "zhangsan" },
{ $addToSet: { tags: "database" } }
)
$pop 从数组删除一个元素,传1删尾部,传-1删头部:
javascript复制db.users.updateOne(
{ username: "zhangsan" },
{ $pop: { tags: 1 } }
)
这里要提醒一下:很多人会把 updateOne 和 updateMany 搞混,特别是只改了匹配条件忘了改方法名的情况。updateOne 只更新第一条匹配的文档,即使有100条匹配,也只更新第一条。如果你需要更新所有匹配的就一定要用 updateMany。有个更隐蔽的坑——当你只想给某一条文档加字段,但条件写得不够精确,结果 updateMany 全给改了,这个时候如果没有备份就非常麻烦。所以生产环境执行批量更新前,一定要先 find 查一下匹配范围,确认边界再动手。
replaceOne 是另一个用途——它会把整条文档替换成新文档,而不是只改某些字段。注意新文档里如果不带 _id,原来的 _id 会保留;如果带了,必须是原文档的 _id,否则会报错:
javascript复制db.users.replaceOne(
{ username: "zhangsan" },
{ username: "zhangsan", email: "zhangsan@example.com", age: 30 }
)
另外,findOneAndUpdate 可以在更新后直接返回文档,避免再查一次。它有个 upsert 选项,设置为true时如果匹配不到就插入一条新文档,这在“有则更新,无则创建”的场景里非常实用:
javascript复制db.users.findOneAndUpdate(
{ username: "zhaoliu" },
{ $set: { email: "zhaoliu@example.com" } },
{ upsert: true, returnDocument: "after" }
)
注意MongoDB 4.4及以上版本的 findOneAndUpdate 默认返回的是更新前的文档,想要返回更新后的文档需要传 { returnDocument: "after" }。
3.4 删除操作:deleteOne与deleteMany
删除相对简单,但误删的风险也最大。deleteOne 删除第一条匹配的文档:
javascript复制db.users.deleteOne({ username: "wangwu" })
deleteMany 删除所有匹配的文档:
javascript复制db.users.deleteMany({ age: { $lt: 20 } })
如果你想把整个集合清空但保留集合本身(保留索引),可以用:
javascript复制db.users.deleteMany({})
如果连集合一起删掉,用 drop():
javascript复制db.users.drop()
这两者的差异值得注意:deleteMany({}) 只删数据不删索引,drop() 则连索引一起删掉。如果你还需要继续用这个集合,别用drop,不然索引得重新建。
删除操作在MongoDB中会持续扫描匹配文档,数据量大时性能可能很差。如果集合很大但你需要清空全部数据,drop() 远比 deleteMany({}) 快。另外,生产环境的删除操作一定要先确认条件,比如 deleteMany 里条件写错了,把全表删了这种事故我是见过不止一次的。最稳妥的习惯是:动删除之前先跑 find().count() 看看到底会命中多少条。
4. 进阶技巧与几个高频需求
4.1 查询数组字段:判断包含、全部包含和元素匹配
“MongoDB查list包含”这个词的搜索量一直很高,大家想在数组字段上做查询。比如users集合里有个 tags 数组,你想找出所有包含 "mongodb" 这个标签的用户。
最简单的写法是直接传数组元素:
javascript复制db.users.find({ tags: "mongodb" })
MongoDB会自动遍历数组,只要数组里包含这个元素就能匹配到。这比SQL里用JSON字符串拼接查询要方便得多。
如果想查多个元素包含任意一个(OR语义),用 $in:
javascript复制db.users.find({ tags: { $in: ["mongodb", "database"] } })
如果要求同时包含多个元素(AND语义),可以用 $all:
javascript复制db.users.find({ tags: { $all: ["mongodb", "developer"] } })
注意 $in 和 $all 的区别:$in 是只要数组里有任意一个就返回,$all 是必须同时包含所有指定的元素才返回。这个区别在实际开发中很常见,比如筛选用户标签时,“有任意一个标签”和“同时具备两个标签”完全是两种业务逻辑。
如果你查的是文档数组字段——也就是数组里的每个元素都是对象——情况会复杂一些。比如每个用户有一个 skills 数组,里面是 { name: "java", level: 3 } 这样的对象。要查“技能名是java且等级大于2”的用户:
javascript复制db.users.find({
skills: {
$elemMatch: {
name: "java",
level: { $gt: 2 }
}
}
})
$elemMatch 的作用是确保数组里至少有一个元素同时满足所有条件。如果不用它直接写 { "skills.name": "java", "skills.level": { $gt: 2 } },那么可能犯的错误是:一个元素name是java但level是1,另一个元素name是python但level是3,它们组合起来也能匹配到,因为MongoDB默认会把这两个条件分别匹配到数组中的不同元素。$elemMatch 则要求必须有一个元素同时满足这两个条件。
数组位置查询也有不少应用,比如要查 tags 数组第一个元素是 "mongodb" 的用户,可以用数组下标:
javascript复制db.users.find({ "tags.0": "mongodb" })
还有用 $size 查数组长度为特定值的:
javascript复制db.users.find({ tags: { $size: 2 } })
注意 $size 不接受范围查询,如果你想查数组长度大于2的,得配合 $expr 写聚合表达式,或者干脆在文档里冗余存一个 tagCount 字段来维护。这也是MongoDB的一个经典设计思路——为了查询效率,冗余字段是完全可以接受的。
4.2 排序、分页与limit的常见坑
MongoDB排序用 sort,传一个字段和排序方向。1代表升序,-1代表降序:
javascript复制db.users.find().sort({ age: -1, username: 1 })
limit 限制返回条数,skip 跳过前N条,组合起来做分页:
javascript复制db.users.find().sort({ age: -1 }).skip(20).limit(10)
这是最基础的分页方式,但数据量大了以后你会发现 skip 翻到越深越慢。原因很简单——MongoDB需要从头扫描并丢弃前面所有文档才能到达跳过位置,这不是索引能解决的。比如100万条数据,skip 90万再取10条,性能会非常差。如果业务需要处理大数据集分页,我建议改用基于游标的分页:记录上一页最后一条文档的 _id 或排序字段值,用条件查询代替skip:
javascript复制db.users.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(10)
这种方式能走索引,翻页越深性能越稳定。不过在mongosh里直接跑 find 时,记得默认 find 返回的是一个游标,如果你要取很多条,需要主动迭代,或者用 toArray() 转换成数组。
4.3 C#中操作MongoDB
很多企业项目用C#和MongoDB搭配,这就要用到官方的 MongoDB.Driver 驱动。在NuGet里安装这个包后,基本使用流程是先创建客户端和数据库对象,再取集合。
csharp复制using MongoDB.Driver;
var client = new MongoClient("mongodb://127.0.0.1:27017");
var database = client.GetDatabase("test");
var collection = database.GetCollection<BsonDocument>("users");
// 插入一条文档
var document = new BsonDocument
{
{ "username", "zhangsan" },
{ "email", "zhangsan@example.com" },
{ "age", 28 }
};
await collection.InsertOneAsync(document);
// 查询年龄大于25的用户
var filter = Builders<BsonDocument>.Filter.Gt("age", 25);
var users = await collection.Find(filter).ToListAsync();
// 更新
var update = Builders<BsonDocument>.Update.Set("age", 29);
var result = await collection.UpdateOneAsync(
Builders<BsonDocument>.Filter.Eq("username", "zhangsan"),
update
);
// 删除
var deleteResult = await collection.DeleteOneAsync(
Builders<BsonDocument>.Filter.Eq("username", "zhangsan")
);
C#驱动有个很好的地方是它支持强类型模型,可以定义实体类然后直接用Lambda表达式构造过滤条件,写起来比BsonDocument舒服得多,编译期还能检查字段名拼写错误:
csharp复制public class User {
public ObjectId Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public int Age { get; set; }
}
var collection = database.GetCollection<User>("users");
var filter = Builders<User>.Filter.Gt(u => u.Age, 25);
var users = await collection.Find(filter).ToListAsync();
这里有个常见坑:C#类属性名默认是PascalCase(首字母大写),如果MongoDB里的文档字段是camelCase(首字母小写),需要加 [BsonElement("username")] 属性来映射,否则读出来属性全是空值:
csharp复制public class User {
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("username")]
public string Username { get; set; }
}
4.4 MongoDB语法与SQL的关键差异对照
从MySQL切过来的开发者,最需要一份快速对照表。我整理了一些最常用的对应关系:
| SQL / MySQL | MongoDB |
|---|---|
SELECT * FROM users |
db.users.find() |
SELECT username, email FROM users WHERE age > 25 |
db.users.find({ age: { $gt: 25 } }, { username: 1, email: 1 }) |
INSERT INTO users (...) VALUES (...) |
db.users.insertOne({...}) |
UPDATE users SET age = 29 WHERE username = 'zhangsan' |
db.users.updateOne({ username: "zhangsan" }, { $set: { age: 29 } }) |
DELETE FROM users WHERE age < 20 |
db.users.deleteMany({ age: { $lt: 20 } }) |
SELECT * FROM users ORDER BY age DESC LIMIT 10 |
db.users.find().sort({ age: -1 }).limit(10) |
SELECT COUNT(*) FROM users |
db.users.countDocuments() |
SELECT DISTINCT city FROM users |
db.users.distinct("address.city") |
注意MongoDB的 countDocuments() 在4.0以上版本才靠谱,更早版本建议用 estimatedDocumentCount() 或者聚合。实际上 countDocuments 和 estimatedDocumentCount 的实现逻辑不同,前者会带过滤条件真正数一遍文档,后者直接读元数据估算,速度更快但可能会有少量偏差。
5. 常见问题与排查经验
5.1 安装和连接相关的问题
先汇总一下安装和连接时最容易遇到的问题。
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
mongod 启动失败 |
data目录不存在或权限不对 | 检查 dbPath 目录,创建并用chown赋予mongod用户权限 |
connect ECONNREFUSED 127.0.0.1:27017 |
mongod服务没启动 | systemctl status mongod 查看服务状态 |
| 远程连接被拒绝 | bindIp 默认只绑127.0.0.1 |
修改配置文件 net.bindIp 为 0.0.0.0,并配置防火墙 |
Authentication failed |
用户权限未配置或authSource不对 | 确认连接串里的 authSource=admin 是否正确 |
| 升级到4.4.30后服务启动失败 | 存储引擎不兼容或数据目录权限改变 | 先备份数据,检查mongod.cfg配置,必要时用 mongod --repair |
关于远程连接,有一点必须提醒:不要为了省事把bindIp设置成0.0.0.0然后不配认证就线上裸奔。我见过有团队图方便在测试环境开了无认证远程访问,结果被挖矿程序写入了恶意数据,整个库被删还被勒索。MongoDB默认关闭远程访问就是为了防止这种情况,生产环境开启远程前至少要先创建管理员用户。
创建管理员用户的命令:
javascript复制use admin
db.createUser({
user: "admin",
pwd: "your_strong_password",
roles: [{ role: "root", db: "admin" }]
})
然后重启mongod时开启认证:
bash复制sudo systemctl edit mongod
# 在打开的配置中加入:
# [Service]
# Environment="MONGO_INITDB_ROOT_USERNAME=admin"
# Environment="MONGO_INITDB_ROOT_PASSWORD=your_strong_password"
更常见的做法是在mongod.conf里加 security.authorization: enabled,然后重启服务。
5.2 CRUD操作中的典型问题
第一个典型问题是字段类型不匹配。MongoDB不像MySQL那样有严格的列类型约束,但查询时类型不同会导致查不到数据。比如插入时age存的是字符串 "28",查询时用 { age: { $gt: 25 } } 就匹配不到,因为字符串和数字的BSON类型比较规则不同。解决的办法是统一写入规范,或者在查询时用 $type 操作符明确匹配类型:
javascript复制db.users.find({ age: { $type: "int", $gt: 25 } })
第二是ObjectId的坑。如果你要按 _id 查询,字符串形式的ObjectId不能直接匹配。正确写法是:
javascript复制db.users.find({ _id: ObjectId("65f1b2d0c3a1a20001e5f001") })
注意这里必须用 ObjectId() 转换,直接传字符串是不行的。
第三是字段不存在导致更新异常。比如用 $inc 操作一个不存在的字段,它会自动创建。但如果用 $set 更新时字段名写错了,MongoDB不会报错,而是默默创建一个新字段。我见过一个案例:某团队写更新代码时把 email 拼成了 emial,结果线上数据多了几千个 emial 字段,后期排查非常痛苦。事前预防的做法是,给集合建一个 $jsonSchema 校验规则,不符合结构的文档插入和更新会被拒绝。这个功能叫Schema Validation,简单配置示例:
javascript复制db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["username", "email"],
properties: {
username: { bsonType: "string" },
email: { bsonType: "string" }
}
}
}
})
不过这个校验规则也比较死板,适合在结构非常稳定的集合上开启,不适合快速迭代早期的集合。
5.3 性能排查:explain是终极武器
CRUD写得再顺手,性能上不去也是白搭。MongoDB查询性能的最大影响因素是索引。如果 find 条件里的字段没有索引,MongoDB会做全集合扫描(Collection Scan),数据量一大就非常慢。
我排查慢查询的第一步就是看 explain():
javascript复制db.users.find({ username: "zhangsan" }).explain("executionStats")
输出里重点看这几个字段:
queryPlanner.winningPlan.stage:如果是COLLSCAN说明全表扫描,如果是IXSCAN说明走了索引。executionStats.executionTimeMillis:总耗时。executionStats.totalDocsExamined:扫描了多少文档。executionStats.totalKeysExamined:扫描了多少索引项。
如果发现 totalDocsExamined 远大于返回的文档数,基本就是索引没建好。为高频查询创建索引是常规操作:
javascript复制db.users.createIndex({ username: 1 })
db.users.createIndex({ age: -1, username: 1 })
创建复合索引时要遵循“等值字段在前,排序字段在后,范围字段放最后”的原则,这个之前我也验证过,顺序不对索引利用率会差很多。
另外,给数组字段建索引也能让 $in、$all、$elemMatch 这些查询高效不少:
javascript复制db.users.createIndex({ tags: 1 })
MongoDB的数组索引会自动为每个数组元素建立索引项,所以能直接加速数组查询。
5.4 一个经典的更新删除误操作案例
说一个我实际遇到的教训。当时上线一个定时任务,逻辑是把所有状态为 expired 的订单更新成 closed。我图省事直接写了:
javascript复制db.orders.updateMany(
{ status: "expired" },
{ $set: { status: "closed" } }
)
结果跑完发现不对——很多新订单也被误关了。原因是有部分订单的状态字段压根不存在,而 update 的 $set 对条件判断是宽松的,{ status: "expired" } 这个条件对于“字段不存在”的文档其实不会匹配,问题出在另一个地方:有大批订单的status被我之前顺手设成了 "Expired",大小写不一样,MongoDB默认的字符串比较又是区分大小写的,按说这个也不会误匹配。真正的原因是我在更新前有另一个初始化脚本,把订单状态先写成了 "expired" 但业务逻辑还没跑到,我的定时任务把这一步的临时状态数据也一起处理了。
这次教训让我养成了一个习惯:任何批量更新或删除前,先跑 find().count() 统计匹配数量,再小心翼翼地加 limit(1) 试跑一次确认效果,最后才执行完整更新。这些看似多余的步骤,在关键时刻能帮你避免灾难。
6. 最后分享几个实用小技巧
在实际使用中,有几个小技巧让我省了很多心,这里一并分享。
第一,善用 mongodump 和 mongorestore。无论CRUD操作多熟练,定期备份永远是第一位的。生产环境至少每天全量备份一次,大库可以考虑oplog增量备份。恢复时也要先在测试环境演练一遍,等真的要用时才不会手忙脚乱。命令如下:
bash复制# 备份整个库
mongodump --host 127.0.0.1 --port 27017 --db test --out /backup/mongodb
# 恢复整个库
mongorestore --host 127.0.0.1 --port 27017 --db test /backup/mongodb/test
第二,用 updateOne 的 upsert 选项来防重复插入。比如用户第一次登录时创建资料,第二次登录时更新资料,用下面这种方式不需要手动判断记录是否存在:
javascript复制db.users.updateOne(
{ userId: 1001 },
{ $set: { lastLoginAt: new Date() }, $setOnInsert: { createdAt: new Date() } },
{ upsert: true }
)
$setOnInsert 只在插入时生效,非常适合维护“首次创建时间”这类字段。
第三,如果要在mongosh里做多层嵌套查询,别偷懒用点号,有条件尽量用引号把字段名包起来:
javascript复制db.users.find({ "address.city": "Beijing" })
虽然不加引号有时也能跑,但字段名以 $ 开头或包含特殊字符时就会出错。引号是安全的;另外字段名里不要带点号,就像不要给表字段起名叫 user.name 一样,否则MongoDB会把它当成嵌套路径处理,排查问题时非常头疼。
MongoDB的CRUD入门其实不难,难的是在实际业务里把每条命令用得恰到好处。你写第一遍的时候可以慢一点,多花点时间理解操作符的行为和返回结果的每个字段,后面写复杂查询时就能少踩很多坑。如果遇到问题,先看 explain(),再翻官方文档,大部分问题都能自己解决。
