MongoDB 一直是我日常排查数据时最顺手的一套数据库,早年做日志采集和用户画像那类项目,很多临时查询我都是直接对着 mongosh 敲的。最近带几个新同事上手,发现大家对 MongoDB 基本操作的认知往往停在“装个库然后 insert/find”的层面,一旦聊到集合是何时被创建的、更新文档时到底该用 $set 还是直接整个替换、skip 深分页为什么慢,很多人就说不清了。这篇文章我就按实际使用顺序,把库和集合、增删改查、更新语法、索引设计、常见报错完整过一遍。适合刚接触 NoSQL 的开发者,也适合用过但没来得及系统整理细节的人,跟着敲一遍基本就能覆盖大部分日常工作。
1. 先从选型说起:MongoDB 的文档模型解决了什么问题
1.1 和 MySQL 建表思维最不一样的地方
如果之前只写过 MySQL,第一次碰 MongoDB 最容易卡在“要不要建表”这个环节上。关系型数据库要先定义表结构,每一行必须符合列约束,字段变动基本离不开 ALTER TABLE;MongoDB 里一个集合(collection)更像是容器,往里面放的是 BSON 格式的文档,字段可以随业务增减。比如一个用户集合,早期数据可能就是:
{ "_id": ObjectId("65b0a1c2e4b0f1a2b3c4d5e6"), "name": "张三", "age": 30, "tags": ["技术", "摄影"] }后面业务要加地址,直接写入address字段就行,不需要先改表。这是文档模型最舒服的地方,也是最大的隐患:字段太自由,脏数据和格式漂移一样会悄悄出现。所以我的习惯是,文档模型可以不用严格建表,但应用层校验一定要有,比如维护一份 JSON Schema,或者在做复杂查询前先统一字段命名。
对比着看会更清楚:MySQL 关心的是“表、行、列、主外键、关系”,MongoDB 关心的是“库、集合、文档、嵌套结构、索引”。这两种思维方式没有绝对好坏,关键看你手头的数据是强结构、强一致,还是变化快、嵌套深。
1.2 适合直接上 MongoDB 的场景和该避开的场景
很多人问我“能不能用 MongoDB 替代 MySQL”,我一般不会直接回答能或不能,而是列一张场景对照表:
| 更适合 MongoDB | 不建议硬上 MongoDB |
|---|---|
| 用户画像、标签系统,字段经常变 | 强事务、金额账务系统 |
| 日志、埋点、物联网时序数据 | 复杂多表 JOIN 报表 |
| 内容管理、商品属性不固定 | 需要窗口函数、复杂聚合分析 |
| 快速原型、早期 MVP 迭代 | 已有成熟 SQL 生态的团队数据仓库 |
注意一点:MongoDB 从 4.0 开始支持多文档事务,但我不建议把它当成 MySQL 来用。日常写个跨集合事务还行,高并发下事务的代价和锁等待一定要做压测。选型这件事,我的判断标准是“这个数据是不是天生带文档结构,以及改动频率高不高”。如果答案都是肯定,直接上 MongoDB;如果核心诉求是账务一致性和复杂关联,老老实实留在关系型数据库。
2. 环境准备:装库、起服务、连客户端的那些坑
2.1 安装、启动服务与基础配置检查
本地体验 MongoDB 其实比想象中简单。多数 Linux 发行版都有官方源提供 mongodb-org 相关包,安装后立刻把它当成服务跑起来:
systemctl start mongod systemctl status mongod有些容器里没有 systemd,我会直接手动启动mongod:
mongod --dbpath /data/mongo --logpath /var/log/mongodb/mongod.log --fork手动启动有两点容易踩坑:一是dbpath指向的目录必须先创建好并且有读写权限,否则会报“Cannot create directory”之类的错;二是--fork需要配合--logpath使用,不然进程无法后台化。生产环境我建议用配置文件mongod.conf统一管理,重点检查net.port、storage.dbPath、net.bindIp三项。
bindIp这个参数很关键。默认很多发行版的配置只允许本机连接127.0.0.1,如果你想让其他机器通过内网访问,得在这里加上实际的服务器内网 IP。一旦开了外网端口,必须配合认证和防火墙,不要裸奔在公网上。
2.2 连接串、认证参数与常用客户端工具
连数据库首选官方提供的 mongosh,旧版的 mongo shell 在新版里已经逐渐被替代。最简单的本地连接:
mongosh "mongodb://127.0.0.1:27017/myblog"如果开启了认证,连接串会长一些:
mongosh "mongodb://myuser:secret@127.0.0.1:27017/myblog?authSource=admin"这里最容易忽略的是authSource。它表示“验证用户身份的库”,默认情况下如果连接串里没写,就用当前连接的库来做认证。你创建一个用户时如果指定的是admin库,但登录时直接用业务库路径,通常就会报认证失败。养成习惯:凡是单机自建实例,连接串都显式写上authSource=admin。
除了交互式 shell,我还会用到两条基础命令:mongodump用于逻辑备份,mongorestore用于恢复。它们不同于文件系统快照,但日常“遛一把”足够救急。图形客户端方面,官方提供的 GUI 工具适合不熟悉命令行的同学看数据,但我个人还是更推荐 shell,因为脚本批量改数据时命令行效率要高一截。
3. 数据库与集合层级的增删改查
3.1 库和集合的创建、切换与删除
MongoDB 里没有CREATE DATABASE这种显式建库命令,库的“诞生”往往是被某个写操作触发的。进入 mongosh 后执行:
use myblog此时只是切换上下文,什么都没创建。真正的动作发生在你往里面写第一条数据时:
db.users.insertOne({ name: "李四", age: 25 });这条命令同时触发了myblog库和users集合的创建。如果想让集合一开始就带参数,比如使用固定大小集合,可以用db.createCollection,但日常业务里不常见。
查看当前有哪些库和集合:
show dbs show collections删除动作一定要谨慎。删除集合用db.users.drop(),整库删除用db.dropDatabase()。这里我不太赞成新手在测试库上随便敲db.dropDatabase(),因为真实环境里误删之后恢复成本往往不小。我的经验是:除非确认要清空全部数据,否则先db.collection.find()看一眼集合里的数据量,再决定是不是真要 drop。
3.2 插入文档的三种方式与 write concern 概念
插入操作主要有insertOne、insertMany,以及旧版遗留的insert。从 MongoDB 5.0 起,建议明确区分单条和多条:
db.users.insertOne({ name: "王五", age: 30 }); db.users.insertMany([ { name: "赵六", age: 22 }, { name: "孙七", age: 28 } ], { ordered: false });ordered: false是很多人没注意到的参数。默认ordered: true时,如果批量数据中间某一条触发重复键错误,后面的数据就不会再写入;改成false,MongoDB 会跳过错误项继续写剩下的数据,非常适合日志灌库这类场景。
每条文档都有一个_id字段,不指定时系统会自动生成ObjectId。这个字段全局唯一,可以用于排序、分页和去重,但要注意:一旦你手动指定了重复的_id,插入就会立刻报 E11000 duplicate key error。
这里再提一下writeConcern。它控制写入请求要等到什么程度才返回成功:
db.users.insertOne( { name: "周八", age: 19 }, { writeConcern: { w: "majority" } } );单机模式下w: 1表示主节点确认即可;在副本集里w: "majority"表示要等大多数节点写入后才返回结果。对一般业务来说,默认配置足够;但金融类或者数据不能丢的场景,建议调高 writeConcern。不过每调高一级,写入延迟也会相应增加,这个平衡点要靠压测找。
4. 查询操作:find 的过滤、投影与操作符
4.1 比较、逻辑、元素查询操作符
查询是 MongoDB 使用频率最高的部分,核心就是find()。一个最简单的过滤条件:
db.users.find({ age: { $gte: 18, $lt: 35 } });表示查年龄在 18 到 34 之间的用户。如果把$gte和$lt换成更具体的操作符,日常覆盖范围会更广:
| 操作符 | 作用 | 示例 |
|---|---|---|
$ne | 不等于 | { status: { $ne: "deleted" } } |
$in | 在指定列表内 | { status: { $in: ["active", "pending"] } } |
$nin | 不在列表内 | { status: { $nin: ["banned"] } } |
$exists | 判断字段是否存在 | { address: { $exists: true } } |
$type | 按 BSON 类型过滤 | { age: { $type: "int" } } |
逻辑条件也很直观。多个字段同时满足时,MongoDB 隐式按 AND 处理:
db.users.find({ status: "active", age: { $gte: 18 } });如果是显式的 AND 或 OR,可以这样写:
db.users.find({ $and: [ { age: { $gte: 18 } }, { tags: "技术" } ] }); db.users.find({ $or: [ { status: "active" }, { vipLevel: { $gte: 3 } } ] });我的建议是,不要一上来就堆复杂的$or,MongoDB 对这种查询走索引并不像 MySQL 那么直观。如果条件里有多个字段,先考虑建复合索引,再写查询。
4.2 排序、分页、limit/skip 与游标注意事项
分页是基本操作里最常见的需求。按创建时间倒序取第一页:
db.users.find() .sort({ createdAt: -1 }) .skip(0) .limit(20);第一页很轻松,但当你翻到第 100 页时,skip(1980)可能已经把前面所有文档都扫描了一遍。这个问题的本质是 skip 是“跳过”,而不是“跳到”,每次都要从头数过去。数据量小无所谓,数据量一大就是灾难。
更推荐的做法是用_id或某个排序字段做游标分页:
db.users.find({ _id: { $gt: lastId } }) .sort({ _id: 1 }) .limit(20);这种方式不需要扫描被跳过的记录,性能稳定得多。代价是没法像传统分页那样随意跳页,但对无限滚动、信息流场景非常合适。
查询结果默认返回所有字段,如果只需要部分字段,可以用投影:
db.users.find({ status: "active" }, { name: 1, age: 1, _id: 0 });投影里1表示要该字段,0表示排除,_id默认返回,明确设置_id: 0才能去掉。还有一个容易踩的坑:shell 中 find 返回的是游标,数据很多时不会一次性全部加载到内存。交互模式下可以用it继续迭代,脚本里如果确定数据量不大,可以用toArray()或forEach()取出结果。
5. 更新与删除:从整行替换到局部修改
5.1 updateOne/updateMany 与常见更新操作符
更新文档是基本操作里最容易写错的一块,核心在于弄清楚“替换”和“修改”的区别。下面这条是替换:
db.users.replaceOne( { _id: ObjectId("65b0a1c2e4b0f1a2b3c4d5e6") }, { name: "新名字", age: 99 } );替换后文档里只剩_id、name、age,之前的tags、address都没了。如果只想改某个字段,用更新操作符:
db.users.updateOne( { _id: ObjectId("65b0a1c2e4b0f1a2b3c4d5e6") }, { $set: { nickname: "阿新" }, $inc: { loginCount: 1 } } );我把日常高频操作符列一下:
| 操作符 | 作用 | 常见用法 |
|---|---|---|
$set | 设置或更新字段 | { $set: { status: "active" } } |
$unset | 删除字段 | { $unset: { oldField: "" } } |
$inc | 数值增减 | { $inc: { score: 10 } } |
$push | 数组追加元素 | { $push: { tags: "生活" } } |
$pull | 从数组移除匹配元素 | { $pull: { tags: "广告" } } |
$addToSet | 数组不存在才追加 | { $addToSet: { tags: "技术" } } |
upsert是一个容易被忽略但很实用的选项。比如登录更新用户信息,希望记录不存在时自动创建:
db.users.updateOne( { openid: "u12345" }, { $set: { lastLoginAt: new Date() }, $inc: { loginCount: 1 } }, { upsert: true } );注意 MongoDB 4.0 之前的 shell 里,update函数默认只更新一条,要更新多条得带{ multi: true },很多老项目踩过这个坑。现在更明确的方式是直接分updateOne和updateMany,语义不会产生歧义,这也是我强烈推荐新项目直接用这两个方法的原因。
5.2 删除的两种思路与误删恢复心态
删除比更新更需要谨慎。最简单的方式是按条件删:
db.users.deleteOne({ _id: ObjectId("65b0a1c2e4b0f1a2b3c4d5e6") }); db.users.deleteMany({ status: "inactive" });我看到不少人清理无关数据时直接写db.users.deleteMany({}),一旦条件没限制好,整张表就直接清空了。所以我的习惯是:批量删除前,先跑一条等价的find().countDocuments()看看影响范围;如果目标是清空整个集合,用db.users.drop()比deleteMany({})快很多,但集合上的索引也会一起被丢掉,需要重新创建。
线上业务数据我一般不建议物理删除,更稳妥的方案是软删除,打个标记:
db.users.updateOne( { _id: ObjectId("...") }, { $set: { deletedAt: new Date(), status: "deleted" } } );这样查询时多一个deletedAt: { $exists: false }条件,数据还在,心里不慌。MongoDB 删除后不像 MySQL 还有闪回这种机制,所以执行任何 drop、deleteMany 之前,最可靠的动作就是先备份。低成本的方式是mongodump --gzip导出一次,再继续操作。这个习惯我坚持了很多年,救过不止一次场。
6. 索引设计对基本操作性能的影响
6.1 单字段索引与复合索引的基本姿势
基本操作做到后面,性能瓶颈大部分出在索引上。MongoDB 不建索引也能查,但数据一多 COLLSCAN 就会拖垮一切。先建一个最常用的单字段索引:
db.users.createIndex({ email: 1 });{ email: 1 }表示按 email 升序建索引,-1 则是降序。对单字段查询来说,方向不敏感,但复合索引就要考虑顺序了:
db.users.createIndex({ status: 1, createdAt: -1 });这条复合索引对find({ status: "active" }).sort({ createdAt: -1 })很有用。设计复合索引最简单的心法是“等值字段放前面,排序字段放后面,范围查询尽量放最后”。如果查询条件里同时有等值和范围,顺序放反了,索引效果会大打折扣。
索引还包括唯一索引,用来保证某个字段不重复:
db.users.createIndex({ openid: 1 }, { unique: true });唯一索引能兜底很多业务幂等问题,但要注意它也会让插入变慢。还有一个很实用的是 TTL 索引,专门用于“过期自动清理”,比如会话数据:
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });这条命令会让 createdAt 超过 1 小时的数据被后台自动删除,比定时任务去 delete 省事得多。
6.2 用 explain 看查询走了什么索引
我判断一条查询到底快不快,很少靠猜,都是用 explain 看执行计划:
db.users.find({ status: "active", age: { $gte: 18 } }) .sort({ createdAt: -1 }) .explain("executionStats");重点看两个地方:winningPlan.stage和executionStats里的totalDocsExamined。winningPlan.stage如果显示COLLSCAN,表示在扫描整个集合,数据量大就会慢;如果显示IXSCAN和FETCH,说明走了索引再回表查文档,这是比较健康的路径。
totalDocsExamined和nReturned这两个数字也很关键。理想的查询是扫描的文档数尽量等于返回的文档数,如果前者远大于后者,说明索引选择性不够好。比如索引建在status上,而大部分文档的status都是active,查询结果是全表一半的数据,优化器可能也懒得走索引。这时就要考虑把选择性更高的字段也放进复合索引。
做 explain 是一劳永逸的技能。调索引前先看执行计划,改完再看一遍对比,比拍脑袋加十个索引有用得多。索引不是越多越好,一个表上几十个索引会让写入明显变慢,得不偿失。
7. 常见问题与排查技巧实录
7.1 几个必看的报错和排查思路
我自己平时接到同学最多的求助,基本都集中在这几个报错上,整理成一个排查表:
| 报错或现象 | 常见原因 | 处理方式 |
|---|---|---|
| not authorized on db to execute command | 认证库不对或用户权限不足 | 确认连接串的 authSource,grant 对应角色 |
| ECONNREFUSED | mongod 没启动或 bindIp 不允许该地址访问 | 查看服务状态,检查配置文件 bindIp |
| E11000 duplicate key | 唯一索引冲突或 _id 重复 | 查一下冲突字段,去除重复数据 |
| MongoNetworkTimeout | 网络延迟或防火墙拦截 | telnet 测试端口,检查安全组 |
| Failed to parse | 查询条件类型错误,比如字符串写成数字 | 检查 BSON 类型,确认字段真实类型 |
其中“not authorized”最常见,多半不是权限真的不够,而是连接的库不对。例如创建用户时用admin库,登录时却写成业务库路径,等同于拿错了通行证。另一个高频问题就是 E11000,尤其用insertMany导入历史数据时,容易因为 _id 重复中断。处理批量导入我一般会先跑脚本筛掉重复项,再用ordered: false兜底。
7.2 一些文档里不会写但实测有用的习惯
除了按部就班的命令,最后分享几个我实际摸出来的操作习惯。
第一,交互式 mongosh 里 cursor 分批返回,数据多时不需要一次全部打印,多按it就能继续取下一批。但写脚本时要注意,取完结果最好先判断数量,避免无意识产生超大内存加载。第二,统计文档数量用countDocuments(),不要用已经被废弃的count(),否则新版客户端会提示 deprecated。第三,批量修改集合字段前,先用find().limit(5)抽样看数据形态,确认过滤条件不会误伤,再跑updateMany。
还有一点是关于导入导出。mongorestore恢复时如果目标库里已有相同 _id 的数据,默认会报 duplicate key,这时如果确实想覆盖旧数据,可以加--drop参数,先删集合再恢复。这个参数我是吃过亏才记住的,不加它会卡在重复键上,加了又确认好几次才敢执行。
最后说一个我坚持了很久的动作:凡是线上集合要做结构变更,或者要跑大批量删除、更新,我都会先mongodump --gzip打个备份,再分批次小范围验证。MongoDB 基本操作确实不算难,难的是每次操作前都多想一步数据是怎么存的、查询会走哪条索引、误操作怎么收场。把这些细节养成习惯,MongoDB 用起来会顺手很多。