
1. 动手写增删改之前先弄明白操作对象是啥很多刚学 MongoDB 的朋友会有一种错觉觉得把数据库装好、能连上服务就算入门了真正上手时却发现连“往哪个库里插一条数据”都要想半天。这一篇既然讲创建、更新和删除文档我就先把底层那张图给大家补上。MongoDB 里没有“表”这个叫法我们把数据集合叫 collection里面的每一条记录叫 document。documet 本质上是一份像 JSON 的 BSON 数据里面可以嵌套对象、放数组、存日期不像 MySQL 那样要求每行都严格遵守相同字段。也正是因为这种自由度很多人才会在初学阶段把文档结构设计得很随意后面更新和查询时才发现字段对不上。这篇教程我会默认你已经把 MongoDB 装好并且能在命令行里打开 mongosh。如果你之前只会在图形工具里点按钮建议从现在开始把命令行当成主战场。原因不是命令行有多高级而是 MongoDB 的增删改查语法最终都落在命令上你只有亲手敲一遍才知道哪一步会出错、哪一步会直接清空数据。我先说明本文演示用的库名。后面的所有例子都建议在测试库里执行别拿生产库练手。我在本地一般会新建一个名为 example 的测试库use exampleMongoDB 的一个特点是你不需要提前创建 database 和 collection。第一次往某个不存在的集合里插入文档时服务端会自动创建集合。这个机制很方便但也会带来一个容易误判的问题你以为自己写错了库名结果是 MongoDB 悄悄帮你建了一个新库。2. 创建文档insertOne 和 insertMany 的正确打开方式2.1 插入单条文档时_id 到底自己给还是让系统生成创建文档最基础的方法是 insertOne。看名字就知道它负责往集合里插入一条文档。我先举个例子db.user.insertOne({ name: 小王, age: 24, skills: [Java, MongoDB], createTime: new Date() })执行完mongosh 会返回类似这样的结果{ acknowledged: true, insertedId: ObjectId(64fc3f0c77f5f3f1b2c9e123) }这里有个新手最容易搞混的概念_id。如果你不主动给文档加上 _id 字段MongoDB 会自动生成一个 ObjectId 对象。这个 ObjectId 是一个 12 字节的唯一标识里面混合了时间戳、机器编号、进程号和自增序列基本可以保证在分布式环境里也不会重复。有的新手第一次看到返回结果里的 insertedId 会愣住以为是自己插入的字段丢了。其实不是系统只是把自动生成的 _id 回传给你了。反过来如果你的业务本来就需要自定义主键完全可以自己传db.user.insertOne({ _id: user_1001, name: 小李, age: 30 })自己传 _id 时要记住一个硬性规则同一个集合里 _id 不允许重复。如果你的代码在第二次插入时用了相同的 _idMongoDB 会直接报 duplicate key error。很多刚接触 MongoDB 的人会选择省事每次都让系统生成 _id。这样做在纯测试环境没问题但实际项目里如果你的业务数据本身有关联编号最好把关联编号单独存成一个字段而不要试图去改 _id 的值。_id 的本质是主键它就是用来做唯一区分和快速定位的别把它玩出太多业务含义。2.2 insertMany 批量插入时的顺序和错误处理策略当你要一次性创建多条文档时老实一条条 insertOne 不是不行只是会浪费大量网络往返时间。这时候用 insertMany 更合适db.user.insertMany([ { name: 小张, age: 20 }, { name: 小刘, age: 22 }, { name: 小陈, age: 25 } ])返回结果里会出现 insertedIds 数组对应你插入的每一条文档。如果插入过程没有报错这个集合里就有了三条数据。批量插入里有一个重要但容易被忽略的参数ordered。默认情况下insertMany 的 ordered 为 true意思是按顺序逐条插入一旦某一条因为重复键或其他原因失败后续的文档就不会再插入了。举个例子我要插入三条文档其中两条 _id 故意冲突db.user.insertMany([ { _id: 1, name: 甲 }, { _id: 1, name: 乙 }, { _id: 2, name: 丙 } ])因为集合里已经有 _id 为 1 的文档这条命令会在第二条处抛错第三条 _id 为 2 的文档也不会插入。如果你希望遇到错的跳过、继续插入后面的可以把 ordered 设为 falsedb.user.insertMany( [ { _id: 1, name: 甲 }, { _id: 1, name: 乙 }, { _id: 2, name: 丙 } ], { ordered: false } )这样服务端会尽量插入所有不冲突的文档_id 为 2 的那条可以成功写入最终报告里会注明有一条失败。这个技巧在导入大量数据时很实用因为你不希望因为几条脏数据就让整个导入任务中断。2.3 老命令 insert 已经过时别在教程里照单全收你在网上翻 MongoDB 教程时可能还会看到 db.collection.insert() 这种写法。这里我得提醒一句insert 是老版本的底层方法后来被拆成了 insertOne 和 insertMany官方已经不建议在新代码里直接用 insert。有的教程写 insert 是因为作者习惯老 API写出来的命令在 MongoDB 6.0 或 7.0 里也许仍然能跑但返回结构和错误处理逻辑都已经变化。如果你照着老教程写很容易遇到莫名其妙的兼容问题。比如你用 insertOne 可以得到 insertedId但 insert 的返回结果在不同版本里差异很大新手看了更晕。不管你是 Java、Python、Node.js 的开发者最后都会通过对应语言的 MongoDB Driver 做操作。Driver 里推荐的方法名其实都是 insertOne 和 insertMany 这一套风格。提前把命令行的写法统一成新 API后面写代码时切换成本会非常低。3. 更新文档updateOne、updateMany 与 replaceOne 三兄弟3.1 更新时不用 $set整条文档会被无情替换更新文档是新手翻车最频繁的操作。先看看规范做法。假如我要把刚刚小王的名字改成“小王二号”写法是db.user.updateOne( { name: 小王 }, { $set: { name: 小王二号 } } )这里 { name: 小王 } 是查询条件表示我要更新哪一条文档{ $set: { name: 小王二号 } } 是更新操作表示我要把 name 字段改成什么值。执行完你会看到返回结果{ acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }很多新手第一次会漏写 $set直接写成这样db.user.updateOne( { name: 小王 }, { name: 小王二号 } )这种写法在 MongoDB 里会被理解为“用新的文档替换旧的整条文档”。也就是说文档里其他字段如 age、skills、createTime 全部会消失只留下 _id 和新的 name。如果这条文档里有你不想丢的数据那这就是一起严重事故。所以我会给出一条最实用的铁律更新字段时更新表达式里尽量带上 $set除非你真的是想整体替换一条文档。就算要整体替换也应该改用专门的方法 replaceOne而不是用 updateOne 塞一个不带运算符的文档进去。replaceOne 的语义更明确看代码的人不容易产生误解。这里要额外解释一下$set 还有一个很贴心的特点如果目标文档里没有这个字段$set 会自动帮你加进去不会因为字段不存在就报错。你更新一条数据时想新增 profile 字段直接这样写就行db.user.updateOne( { name: 小王二号 }, { $set: { profile: { level: 1, vip: false } } } )3.2 updateMany想批量改文档时别再写循环了如果只有一条文档需要更新用 updateOne 没有问题。但如果你要更新所有符合条件的数据比如把一批年龄小于 18 的用户状态改成未成年再用循环就太低效了。这时应该用 updateManydb.user.updateMany( { age: { $lt: 18 } }, { $set: { status: minor } } )updateMany 会扫描所有匹配查询条件的文档然后统一执行更新操作。这里要特别留神一个隐患查询条件为空对象 {} 时updateMany 会更新当前集合里的每一条文档。db.user.updateMany( {}, { $set: { lastUpdate: new Date() } } )如果你确实想给全集加上一个字段这样写没问题。但如果你本意是想只更新某一条却因为查询条件写错了变成空对象那整个集合都会被改动。这个险我年轻时候也踩过后来养成一个习惯批量更新前先用 countDocuments 跑一下同一个查询条件看看会命中多少条文档确认数量符合预期后再执行更新。3.3 upsert 参数找不到匹配文档时直接插入默认情况下如果查询条件找不到任何文档updateOne 不会做任何事matchedCount 和 modifiedCount 都是 0。但在很多业务场景里我们希望“有就更新没有就插入”。比如统计用户学习时长的时候用户今天第一次产生学习记录数据库里根本没有这条文档。两种写法第一种是先查再判断再插入逻辑繁琐且存在并发问题。第二种是直接用 upsertdb.studyRecord.updateOne( { userId: user_1001, courseId: mongo101, date: 2026-01-01 }, { $inc: { studyMinutes: 30 } }, { upsert: true } )upsert: true 表示如果条件没匹配到文档就根据查询条件和更新字段自动创建一条新文档。上面这个例子中如果 user_1001 在 mongo101 课程里还没有今天的学习记录MongoDB 会自动创建一条包含 userId、courseId、date 和 studyMinutes: 30 的文档并且把 studyMinutes 设置成 30如果记录已存在则把 studyMinutes 加 30。我特别建议大家多理解 upsert 这个参数因为它在写数据同步脚本时非常好用。但注意不要盲目给所有 update 都加 upsert否则查询条件本身写错了它会静默插入一条你根本没想创建的文档到时候数据会变得很脏。3.4 更新嵌套字段和数组元素的方法实际开发里文档不会像测试数据那样只有一层。比如 user 文档里可能会有一个 address 对象里面有 city、street 这样的子字段。你想只更新 city就写成db.user.updateOne( { name: 小王二号 }, { $set: { address.city: 上海 } } )注意address.city 在双引号里因为对 MongoDB 来说这是一个字段路径。如果集合是多层嵌套路径也可以继续往下加a.b.c。这是 MongoDB 和 SQL 很不一样的地方新手很容易忘记给路径加引号。数组字段也有对应的更新操作。比如要给小王的 skills 数组加一项 Pythondb.user.updateOne( { name: 小王二号 }, { $push: { skills: Python } } )如果想同时塞多个值用 $eachdb.user.updateOne( { name: 小王二号 }, { $push: { skills: { $each: [Python, Go, Redis] } } } )如果要删除数组里的某个值用 $pulldb.user.updateOne( { name: 小王二号 }, { $pull: { skills: Java } } )$push 和 $addToSet 的区别是一个允许重复一个不允许重复。如果你要维护一个“不重复标签”列表就该用 $addToSet。别小看这几个数组操作符用好了能省掉大量应用层代码。3.5 更新操作里的单文档原子性再补充一个适用于所有更新操作的底层概念MongoDB 对单个文档的更新是原子的。所谓原子意思是同一时刻只有一次写入能真正修改这条文档不会出现 A 线程读到旧值、B 线程也读到旧值然后两个人互相覆盖的情况。比如你用 $inc 对同一个字段做自增无论并发请求有多少最终结果都不会丢增量。这比在应用层先 select 再 update 要安全得多。如果你需要在多条文档之间保证一致性那就超出单文档原子性的范畴了MongoDB 也支持多文档事务但那对初学者来说暂时用不上。先记住这条原则能用一条 update 操作搞定的事就不要拆成查询加更新两步。4. 删除文档deleteOne 和 deleteMany 的安全边界4.1 先查再删确认命中范围后再执行删除删除文档的操作看起来最简单实际上风险最大。MongoDB 删除文档的基本方法就两个deleteOne 和 deleteMany。deleteOne 会删除第一条匹配条件的文档db.user.deleteOne({ name: 小王二号 })deleteMany 会删除所有匹配条件的文档db.user.deleteMany({ status: minor })每条删除命令返回的结果里都会带 deletedCount告诉你实际删了多少条。我在实际工作中给团队定的规矩是写删除脚本前必须先执行一遍 find 或 countDocuments确认查询条件命中哪些数据。等看到返回数量确实符合预期再切换成删除命令执行。比如你先跑db.user.countDocuments({ status: minor })返回 128那说明待删除数据有 128 条。如果这个数字和你脑子里的预期差不多再执行db.user.deleteMany({ status: minor })这一步看起来多余但它能救命。很多生产事故不是命令写错而是查询条件里的字段写错了导致匹配到了完全不同的数据。还有一个常见操作误区想按 _id 删除时必须把字符串 _id 转换成 ObjectId 再查。比如说你在界面上复制了一个 _id 值它是形如 64fc3f0c77f5f3f1b2c9e123 的字符串直接这样写是删不到的db.user.deleteOne({ _id: 64fc3f0c77f5f3f1b2c9e123 })因为存储的 _id 不是字符串而是 ObjectId 对象。正确的写法是db.user.deleteOne({ _id: ObjectId(64fc3f0c77f5f3f1b2c9e123) })这类问题看起来低级却是新手删除数据时最常见的失手原因。4.2 deleteMany 的空条件会清空集合drop 会删除更多如果你写的删除条件是 {}deleteMany 会删除集合里的全部文档db.user.deleteMany({})执行后集合还在但数据一条不剩。这动作在清理测试数据时很有用但一旦在生产环境误执行后果会非常严重。更彻底的清除手段是直接 drop 整个集合db.user.drop()drop 和 deleteMany({}) 的区别在于deleteMany 只是删文档集合里的索引和元数据还在而 drop 是整个集合直接消失包括数据、索引和集合本身。下次再往这个集合插入数据时集合会被重新创建但之前建过的索引需要重新建立。如果你的查询很依赖某个索引没有及时重建性能会立刻回退到全集合扫描。如果你不仅想清空文档还想去掉这个集合对磁盘空间的占用drop 确实效果更直接。不过在生产环境我宁可多备份一次也不会轻易对一个业务集合执行 drop。4.3 生产环境里删除也分物理删除和逻辑删除这里想多说一点经验。有些场景下业务上说删除其实并不是真的要物理删除。比如用户注销账号很多系统会保留用户的基本记录只是把它标记成已注销。这种操作叫软删除或逻辑删除。比起 deleteMany 把数据抹掉软删除更安全也更方便未来做数据恢复和审计。软删除通常就是一次 updateMany给目标文档打上标记db.user.updateMany( { status: active }, { $set: { status: deleted, deletedAt: new Date() } } )之后查询时统一过滤掉被标记的数据。数据越多越要谨慎对待物理删除因为 MongoDB 的删除操作没有“回收站”这个概念删了就是删了除非你有备份否则再也捞不回来。另外MongoDB 还提供 TTL 索引可以自动删除超过指定时间的文档。比如你有一个日志集合想让日志只保留 30 天可以给时间字段建 TTL 索引让 MongoDB 在后台自动把过期文档清理掉。这个机制很适合做日志清理和会话过期清理是“删除文档”在实际项目中比较高级的玩法。5. 连数据库的命令行工具和实际项目中的接口差异问题5.1 mongosh 与老版本 mongo 的区别学 MongoDB 的过程中很多人会在网上看到两套命令入口mongo 和 mongosh。简单说mongo 是老一代 shell 的启动命令mongosh 是新一代官方 shell 启动命令。MongoDB 6.0 之后mongo 已经不再随默认安装提供你在新版 MongoDB 环境里打开终端应该输入的是 mongosh。如果看到类似“mongo: command not found”的报错不要怀疑自己安装有问题大概率是终端里用了旧命令。解决方案是在终端输入 mongosh。如果连 mongosh 也没找到那就回头检查一下 MongoDB 的安装路径和 PATH 环境变量。有的同学会用 DBeaver 这类图形化客户端连接 MongoDB。DBeaver 连接 MongoDB 时需要在连接配置里下载对应版本的 MongoDB 驱动包否则可能连接失败或看不到数据列表。驱动版本确实挺折腾人我的建议是图形工具适合做数据浏览和临时查询但学习阶段仍然要把 mongosh 用熟。因为命令行的输出最直接能让你看清每一步操作到底改了什么。5.2 Shell 里的操作如何对应到各种开发语言你在 mongosh 里写的 db.user.updateOne 和 Java、Python、Node.js 里的代码逻辑很接近但不是完全一样。比如 Node.js 里会写成db.collection(user).updateOne( { name: 小王 }, { $set: { age: 25 } } )Java 里则使用 Document 和 Filters 这样的辅助类来拼查询条件。底层 Driver 会把你代码里的对象转换成 MongoDB 的 BSON 结构再发给服务端。所以我的建议是先用 mongosh 把一个操作的命令正确性和返回结构搞清楚再用你熟悉的编程语言去重写。这样可以避免一上来就在语言驱动里查 API。很多 API 错误其实来源于开发者对 MongoDB 本身操作逻辑的理解偏差比如没搞清楚 update 需要 $set、删除和更新会默认匹配多少条这些问题在命令行里最容易暴露。6. 我在实际学习中最常踩的五个坑6.1 错误地以为 updateOne 会更新所有匹配文档updateOne 的含义是在匹配到的文档里只更新第一条。如果查询条件匹配了 100 条文档updateOne 只会更新其中一条。很多人想更新一批数据时误用了 updateOne导致只改了一条还以为功能有 bug。而 updateMany 才会更新所有匹配文档。写代码前先看一眼需求到底是一次性更新单条还是批量的状态变更这两个方法不能混用。6.2 把 _id 和普通字符串主键搞混这个问题真的很常见。比如在数据导入时你从 CSV 里读出了一个主键 1001在关系型数据库里直接 update where id 1001 就够了。但在 MongoDB 里如果集合文档的 _id 是 ObjectId 类型你拿字符串 1001 去查永远匹配不到。我在实际处理数据迁移时一般会先用 find 查一条样例文档看清楚 _id 到底是什么类型是字符串还是 ObjectId然后再决定查询条件怎么写。尤其在写自动脚本时不要在脚本里臆想字段类型先跑一条 find 看看真实数据这是最高效的避坑方式。6.3 更新文档时把小写字段名写错导致多出脏字段MongoDB 不强制要求字段必须存在$set 遇到新字段也会自动创建。这意味着如果你把字段名写错了比如本意是 updateTime写成了 updatetimeMongoDB 不会报错而是会给你默默创建一个新字段。这个行为比 SQL 严格校验更灵活也更容易出问题。我当时排查过一个数据不一致问题查了半天发现是有个历史脚本把字段名拼错了满库都多出一个错误字段。现在我在写更新脚本时都会先查看现有文档字段再写 $set 内容。6.4 删除前不备份删完才知道没后悔药即使你操作前用了 countDocuments 确认数量也仍然存在误判的可能。尤其是在生产环境最稳妥的做法是先把目标数据导成文件备份再执行删除。一个最简单的备份方法是用 mongodump 工具按集合备份比如mongodump --db example --collection user --out /backup/user_backup如果只是少量数据也可以先查询出来保存成 JSON日常开发完全够用。很多老手不是多聪明只是他们在删数据前多花了一分钟做备份。6.5 空集合上的 upsert 可能会创建一条奇怪文档upsert 虽好用但也需要小心。当查询条件本身是 {} 时如果集合里没有匹配文档upsert 会把更新表达式里的字段组合成一条新文档插进去。比如db.user.updateOne( {}, { $set: { hello: world } }, { upsert: true } )如果集合已经空了这条命令会插入一条只有 _id 和 hello 字段的文档而不是把所有文档都更新。这种写法很容易让人困惑所以我在看到空查询条件加 upsert 的组合时几乎都会重新审视一遍逻辑。7. 学习完 CUD 之后下一步建议做点什么如果你能照着前面例子手动敲完创建、更新和删除的语法MongoDB 的文档操作基本已经入门了。下一步可以把目标放在查询和聚合上因为这些操作会反过来检验你设计的文档结构是否合理。举个很简单的例子当初你把用户的多个手机号存成了数组那么后续查询里就可以用 $in 或 $elemMatch 去和数组数据打交道。如果你当初把多个手机号硬拼接成了一个字符串那么查询和统计就会非常痛苦。创建文档时对结构多思考一分后面的查询就能省下十分力。在学习阶段我建议可以准备一个自己感兴趣的小项目比如写一个简单的博客系统把用户、文章、评论分别用三个集合存储反复练习创建用户、更新文章、删除评论。只有把这些操作放到连贯的业务场景里你才能真正理解 MongoDB 的文档型数据库优势和边界。