MongoDB 这玩意儿,第一次上手的人十有八九会在两个地方卡住:一是装完了不知道该怎么连,二是连上了对着空荡荡的 shell 不知道敲什么。我带过不少做实验课的同学,很多人对着文档抄命令能跑通,但一旦让他自己设计一个集合结构、写个查询,立刻就露馅了。问题不在于命令记不住,而在于没搞明白 MongoDB 这套文档模型到底是怎么想的。
这篇内容围绕 MongoDB 数据库基本操作展开,把增删改查、集合管理、索引、聚合这些核心操作拆开揉碎讲一遍。适合两类人看:正在做数据库实验、需要把操作跑通并理解原理的同学,以及刚接触文档型数据库、想快速建立一套可复用的操作习惯的开发者。我会尽量把每条命令背后的意图讲清楚,而不是甩一堆语法让你背。文中所有示例都基于通用的 MongoDB 环境,不依赖任何特定平台,你本地装一个或者用容器起一个都能跟着敲。
1. 先搞清楚 MongoDB 的库、集合、文档三层结构
很多人上来就敲use test,然后db.xxx.insert(),跑是跑通了,但你问他这个test是什么、xxx又是什么,答不上来。这一层概念不清,后面设计数据结构必然乱。
1.1 库、集合、文档与关系型数据库的对应关系
MongoDB 是文档型数据库,它的层级是数据库(database)→ 集合(collection)→ 文档(document)。你可以拿它跟关系型数据库做个类比,但千万别当成一一对应来理解,否则会带偏。
| 关系型数据库 | MongoDB | 说明 |
|---|---|---|
| 数据库 database | 数据库 database | 概念基本一致,一个实例下可以有多个库 |
| 表 table | 集合 collection | 集合不强制结构,文档之间字段可以不同 |
| 行 row | 文档 document | 文档是 BSON 格式,本质是键值对 |
| 列 column | 字段 field | 字段不需要预先定义,随文档走 |
| 主键 primary key | _id字段 | 插入时自动生成,也可手动指定 |
关键差异在于:关系型数据库的表结构是"先定义后使用",你得先CREATE TABLE声明每一列的类型;而 MongoDB 的集合是"用了才存在",你第一次往某个集合插数据,它自动就建好了,字段类型也随文档自由。这个特性叫schema-less(无模式),灵活是灵活,但也意味着约束得靠你自己在应用层保证。
提示:无模式不等于可以乱来。实际项目里,同一个集合内的文档最好保持字段结构大体一致,否则查询和索引会变得很难维护。灵活是给你应对变化用的,不是给你偷懒用的。
1.2 库和集合的隐式创建与显式创建
新手最容易困惑的一点是:我use mydb之后,show dbs里怎么没有mydb?原因是use只是切换上下文,并不会真正创建数据库。数据库和集合都是"惰性创建"的——只有你往里面写了第一条数据,它们才真正落盘。
// 切换到一个不存在的库,此时不会创建 use shop // 查看当前所在库 db // 此时 show dbs 看不到 shop show dbs // 插入一条数据,shop 库和 users 集合同时被创建 db.users.insertOne({ name: "张三", age: 28 }) // 现在能看到 shop 了 show dbs如果你确实想显式建集合(比如要预设一些选项,像固定集合、带校验规则的集合),可以用createCollection:
db.createCollection("orders", { capped: false, validator: { $jsonSchema: { bsonType: "object", required: ["orderNo", "amount"], properties: { orderNo: { bsonType: "string" }, amount: { bsonType: "double", minimum: 0 } } } } })上面这段给orders集合加了一个 JSON Schema 校验器,插入的文档必须带orderNo和amount,且金额不能为负。这就是在无模式的基础上补回一部分约束能力,实际项目里很常用。我个人的习惯是:核心业务集合尽量加上校验器,边缘的、结构多变的集合就放开。
1.3 命名规范与常见踩坑点
集合名和库名有一些硬性限制,踩过一次就记住了:
- 库名不能包含
/ \ . " * < > : | ? $这些字符,也不能为空,长度有上限。 - 集合名不能以
system.开头(这是系统集合的保留前缀),不能包含$(除非是特定系统场景),不能为空。 - 集合名区分大小写,
Users和users是两个不同的集合,这点和某些数据库不一样,命名时最好统一风格。
还有一个隐蔽的坑:库名和集合名里带点号.会有歧义。虽然某些情况下允许,但在跨库操作、备份恢复时容易出问题,我的建议是干脆别用点号,用下划线代替。
2. 插入操作:insertOne 与 insertMany 的取舍
插入是写操作的起点,看起来简单,但里面有几个细节决定了你后面查询顺不顺畅。
2.1 单条插入与批量插入的写法差异
单条插入用insertOne,返回一个包含acknowledged和insertedId的对象:
const result = db.users.insertOne({ name: "李四", age: 32, tags: ["vip", "early-bird"], createdAt: new Date() }) print(result.insertedId)批量插入用insertMany,参数是文档数组,返回insertedIds映射:
db.users.insertMany([ { name: "王五", age: 25, tags: ["new"] }, { name: "赵六", age: 41, tags: ["vip"] }, { name: "钱七", age: 19, tags: [] } ])这里有个性能上的关键点:批量插入远比循环单条插入快。因为每次insertOne都是一次网络往返加一次写确认,循环一千次就是一千次往返;而insertMany默认是"有序批量",一次请求带多条文档,服务端按顺序处理。实测下来,同样一千条数据,批量插入的耗时往往只有循环单插的几分之一。
2.2 有序插入与无序插入对失败处理的影响
insertMany有个ordered选项,默认是true。这意味着一旦中间某条文档插入失败(比如违反唯一索引),后面的文档全部停止插入。如果你希望"能插的尽量插进去,失败的单独报告",就设成false:
try { db.users.insertMany([ { _id: 1, name: "A" }, { _id: 1, name: "B" }, // _id 重复,会失败 { _id: 2, name: "C" } ], { ordered: false }) } catch (e) { print(e.writeErrors) // 能看到具体哪几条失败 }在ordered: false下,_id: 2那条依然会插入成功,只有重复的那条报错。这个选项在做数据迁移、批量导入时特别有用——你总不希望一条脏数据把后面几千条都堵死。
注意:无序插入虽然容错好,但失败文档的定位要靠解析
writeErrors,写导入脚本时记得把失败列表落盘,方便事后补录。
2.3 手动指定 _id 的时机与风险
默认情况下 MongoDB 会为每条文档生成一个ObjectId类型的_id,它包含时间戳、机器标识、进程号和计数器,全局唯一且大致有序。大多数场景直接用默认的就行。
但有些时候你会想手动指定_id,比如从旧系统迁移数据、用业务主键去重。这时候要注意:_id一旦插入就不能修改,想改只能删了重插。而且如果你手动指定的_id是自增整数,在高并发写入下容易成为瓶颈,因为所有插入都要抢同一个序列。所以除非有明确理由,我一般建议保留默认ObjectId,业务唯一性用单独的字段加唯一索引去保证。
3. 查询操作:从等值匹配到聚合管道
查询是 MongoDB 用得最多、也最能体现功力的部分。同样是查数据,有人只会find({name: "x"}),有人能用一个聚合管道把报表直接算出来,差距就在这儿。
3.1 find 的条件写法与投影控制
最基本的查询是find(条件, 投影):
// 查所有 age 等于 28 的 db.users.find({ age: 28 }) // 只返回 name 和 age 字段,不返回 _id db.users.find({ age: 28 }, { name: 1, age: 1, _id: 0 })投影里字段给1表示包含,给0表示排除。规则是:除了_id之外,包含和排除不能混用。也就是说你不能写{ name: 1, age: 0 },这在 MongoDB 里是非法投影。_id是唯一例外,它可以单独用0排除掉。
条件里除了等值,还支持一堆操作符,常用的整理成表:
| 操作符 | 含义 | 示例 |
|---|---|---|
$gt/$gte | 大于 / 大于等于 | { age: { $gt: 30 } } |
$lt/$lte | 小于 / 小于等于 | { age: { $lt: 20 } } |
$ne | 不等于 | { name: { $ne: "张三" } } |
$in/$nin | 在 / 不在集合内 | { tags: { $in: ["vip"] } } |
$exists | 字段是否存在 | { email: { $exists: true } } |
$regex | 正则匹配 | { name: { $regex: "^张" } } |
数组字段的匹配有个容易忽略的点:{ tags: "vip" }这种写法,只要数组里包含vip就能匹配上,不需要写$in。而{ tags: ["vip", "new"] }则是精确匹配整个数组(顺序也要一致)。这两个语义差别很大,写查询时要想清楚你要的是"包含某元素"还是"数组完全相等"。
3.2 排序、分页与游标的使用
排序用.sort(),分页用.skip()和.limit():
db.users.find({ age: { $gte: 18 } }) .sort({ age: -1, name: 1 }) // age 降序,age 相同则 name 升序 .skip(20) // 跳过前 20 条 .limit(10) // 取 10 条sort里1是升序、-1是降序,可以多字段组合。但这里有个性能陷阱:skip越大越慢。因为 MongoDB 要先扫描并丢弃前面skip条文档,才能拿到你要的那一页。翻到第 1000 页时,skip(10000)的开销非常可观。
正确的分页姿势是基于游标(cursor-based)分页,用上一页最后一条记录的排序字段值作为下一页的起点:
// 假设按 age 升序,上一页最后一条 age 是 35,_id 是 xxx db.users.find({ $or: [ { age: { $gt: 35 } }, { age: 35, _id: { $gt: ObjectId("xxx") } } ] }).sort({ age: 1, _id: 1 }).limit(10)这样每页查询都能走索引,不会随页码增大而变慢。代价是不能直接跳到第 N 页,但对于信息流、列表这类"往下翻"的场景完全够用。
3.3 聚合管道:把计算下推到数据库
当你需要分组统计、多阶段变换时,find就不够用了,得上aggregate。聚合管道是一串阶段(stage),文档依次流过每个阶段被处理。最常用的几个阶段:
$match:过滤,尽量放最前面,能提前减少数据量。$group:分组聚合,类似 SQL 的GROUP BY。$sort:排序。$project:重塑文档结构,选字段、算新字段。$lookup:跨集合关联,类似JOIN。
举个实际例子,统计每个标签下的用户数和平均年龄:
db.users.aggregate([ { $unwind: "$tags" }, // 把 tags 数组拆成多条 { $group: { _id: "$tags", userCount: { $sum: 1 }, avgAge: { $avg: "$age" } }}, { $sort: { userCount: -1 } } ])$unwind是个关键阶段,它把数组字段"炸开",一条带["vip","new"]的文档会变成两条,分别带vip和new。没有它,$group就没法按数组元素分组。
提示:聚合管道的阶段顺序直接影响性能。
$match和$limit能往前放就往前放,让数据尽早瘦身;$lookup尽量放在过滤之后,避免对大结果集做关联。
4. 更新与删除:别让一条命令改崩整个集合
更新和删除是危险操作,写错一个条件,可能整张表的数据就没了。这一节重点讲清楚操作符的语义和防呆手段。
4.1 updateOne、updateMany 与 replaceOne 的区别
三个更新方法,语义完全不同:
updateOne(filter, update):只更新匹配到的第一条。updateMany(filter, update):更新所有匹配的文档。replaceOne(filter, replacement):用新文档整体替换匹配到的第一条,除了_id之外旧字段全部丢弃。
// 只给第一个匹配的用户加 1 岁 db.users.updateOne({ name: "张三" }, { $inc: { age: 1 } }) // 给所有 vip 用户打上 active 标记 db.users.updateMany({ tags: "vip" }, { $set: { active: true } }) // 整体替换,注意这会丢掉原文档的其他字段 db.users.replaceOne({ name: "李四" }, { name: "李四", age: 33 })最容易出事的是把updateMany的条件写漏。比如本想改一个用户,结果filter写成了{},那就是全表更新。我的习惯是:执行更新前先用同样的filter跑一次find或countDocuments,确认命中数量符合预期,再执行更新。多花三秒钟,能避免一场事故。
4.2 $set、$unset、$inc 等更新操作符的语义
更新文档时,如果直接传一个普通对象,MongoDB 会把它当成"替换"而不是"修改字段"。想只改某几个字段,必须用更新操作符:
| 操作符 | 作用 | 示例 |
|---|---|---|
$set | 设置字段值,不存在则创建 | { $set: { city: "北京" } } |
$unset | 删除字段 | { $unset: { city: "" } } |
$inc | 数值增减 | { $inc: { age: 1 } } |
$push | 向数组追加元素 | { $push: { tags: "new" } } |
$pull | 从数组移除匹配元素 | { $pull: { tags: "new" } } |
$addToSet | 追加元素,已存在则不操作 | { $addToSet: { tags: "vip" } } |
$addToSet和$push的区别值得单独说:$push无脑追加,可能产生重复元素;$addToSet会先检查,已存在就跳过。做标签、去重列表这类场景,用$addToSet更省心。
还有一个组合技巧:$set配合点号路径可以更新嵌套字段或数组内某个元素:
// 更新嵌套对象里的字段 db.users.updateOne( { name: "张三" }, { $set: { "address.city": "上海" } } ) // 更新数组中第一个匹配元素的字段 db.users.updateOne( { name: "张三", "orders.id": 1001 }, { $set: { "orders.$.status": "shipped" } } )那个$是位置操作符,代表"匹配到的数组元素的下标",不用你手动算索引,很实用。
4.3 删除操作的安全边界与软删除思路
删除用deleteOne和deleteMany,语义和更新对应:
db.users.deleteOne({ name: "钱七" }) db.users.deleteMany({ age: { $lt: 18 } })deleteMany({})会清空整个集合,但集合本身和索引还在;db.users.drop()则是连集合带索引一起删掉。这两个操作都不可逆,生产环境一定要谨慎。
实际项目里,我很少做物理删除,更常用软删除:加一个deletedAt字段,删除时写时间戳,查询时统一带上{ deletedAt: null }条件。好处是数据可恢复、可审计,坏处是所有查询都得记得加过滤条件,容易漏。折中方案是给软删除的集合建一个视图(view),把过滤条件封装进去,应用层查视图就行。
5. 索引与聚合的性能意识
操作能跑通只是第一步,数据量上来之后,慢查询会让你怀疑人生。索引是绕不开的一环。
5.1 单字段索引与复合索引的建立
建索引用createIndex:
// 单字段索引,1 升序,-1 降序 db.users.createIndex({ age: 1 }) // 复合索引 db.users.createIndex({ tags: 1, age: -1 }) // 唯一索引 db.users.createIndex({ email: 1 }, { unique: true }) // 查看集合上的索引 db.users.getIndexes()复合索引有个最左前缀原则:索引{ tags: 1, age: -1 }能支持按tags查、按tags + age查,但单独按age查用不上这个索引。所以建复合索引时,把最常单独查询的字段放最左边。
5.2 用 explain 判断查询是否走索引
写完查询,别急着上线,先用explain看一眼执行计划:
db.users.find({ age: { $gt: 30 } }).explain("executionStats")重点看几个字段:winningPlan里如果是COLLSCAN说明全集合扫描,没走索引;是IXSCAN说明走了索引。executionStats里的totalDocsExamined和nReturned越接近越好,如果扫描了几万条只返回几条,那就是索引没建对。
注意:索引不是越多越好。每个索引都会增加写入时的维护成本,还会占内存。一般一个集合的索引控制在几个以内,定期用
$indexStats看哪些索引从没被用过,该删就删。
5.3 聚合管道的执行顺序优化
回到聚合,性能优化的核心就是"让数据尽早变小"。对比下面两种写法:
// 不推荐:先分组再过滤,分组时处理了全量数据 db.orders.aggregate([ { $group: { _id: "$userId", total: { $sum: "$amount" } } }, { $match: { total: { $gt: 1000 } } } ]) // 推荐:先过滤再分组,分组数据量小得多 db.orders.aggregate([ { $match: { status: "paid" } }, { $group: { _id: "$userId", total: { $sum: "$amount" } } }, { $match: { total: { $gt: 1000 } } } ])MongoDB 的查询优化器会做一些阶段重排,但不是万能的,尤其是$match里带聚合计算结果的(比如上面按total过滤),它没法提前。所以手写管道时自己就要有"先瘦身再计算"的意识。
6. 实验环境里最容易翻车的几个操作细节
最后聊几个我在带实验、看别人操作时反复见到的翻车点,都是些文档里不显眼、但一踩就卡半天的细节。
6.1 数据类型不匹配导致的"查不到"
MongoDB 是强类型的,28(数字)和"28"(字符串)是两个完全不同的值。如果你插入时age是数字,查询时手滑写成{ age: "28" },结果就是空。更隐蔽的是从外部导入的 JSON,数字可能被当成字符串存进去,之后所有数值比较($gt、$lt)都会失效。
排查办法是用$type查一下字段的实际类型:
db.users.find({ age: { $type: "string" } })如果查出一堆本该是数字的字符串,就得用updateMany配合$convert或应用层脚本做类型清洗。这个坑在数据迁移场景里几乎必现,提前留个心眼。
6.2 shell 里的多行输入与括号闭合
在mongosh里敲多行命令时,如果括号没闭合,shell 会一直显示...等待续行,新手容易以为卡死了。这时候按Ctrl+C中断重来即可,不用重启整个 shell。另外,聚合管道那种多层嵌套的写法,建议先在编辑器里写好、确认括号配对,再粘进 shell,比在命令行里一行行敲靠谱得多。
6.3 权限不足时的报错识别
如果你连的是一个受限账号,执行某些操作会报not authorized on xxx to execute command。这类报错不是语法问题,是权限问题,改命令没用,得找管理员要对应库的读写权限。区分清楚"语法错"和"权限错"能省下大量瞎调试的时间——前者报SyntaxError或操作符不认识,后者明确带not authorized字样。
6.4 实验数据清理的正确姿势
做完实验想清场,别上来就dropDatabase。先确认当前在哪个库(db命令看一眼),再决定删集合还是删库。我见过有人在admin库下误操作,虽然普通账号通常没这个权限,但习惯要养好:删之前先show collections和db.xxx.countDocuments()确认目标,删完再确认一次。数据这东西,删错了可没有回收站。
把上面这些操作串起来练一遍,基本就能覆盖日常开发里八九成的 MongoDB 使用场景了。真正拉开差距的不是记住了多少命令,而是每次写查询、建索引之前,脑子里会先过一遍"数据长什么样、量有多大、走不走索引"。这个意识建立起来,剩下的就是查文档的事。