大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
CRUD 操作
这一课你将能做到
- 熟练使用 insertOne/insertMany、find/findOne、updateOne/updateMany/replaceOne、deleteOne/deleteMany 完成全部增删改查
- 用
$set/$inc/$push等更新运算符做「原地修改」,并说出它和整文档替换的区别 - 用 bulkWrite 做批量写,并解释 upsert 与 findOneAndUpdate 的实际用途
为什么要学
CRUD 是 MongoDB 的日常:用户提交一次代码,就是一次 insert;做完一节课,进度集合就是一次 upsert + $inc;后台清洗脏数据,就是 updateMany/deleteMany。写错一个更新运算符,比如把 $set 写成整文档替换,可能瞬间把用户文档里其它字段全部抹掉——这是真实发生过的生产事故。
人话:查询是「读空气」,更新是「动手术」。读错了再读一次就行,写错了数据就真没了,所以这一课的重点在写。
先建立图像
更新运算符 vs 整文档替换的区别:
原文档: { userId: 1024, doneCount: 3, nickname: "小明", city: "上海" }
$set 更新(手术刀,只动指定字段):
db.users.updateOne({userId:1024}, {$set: {doneCount: 4}})
结果: { userId: 1024, doneCount: 4, nickname: "小明", city: "上海" } ✅ 其它字段还在
replaceOne(推土机,整个文档换掉):
db.users.replaceOne({userId:1024}, {doneCount: 4})
结果: { _id: ..., doneCount: 4 } ❌ nickname/city/userId 全没了!
跟着做
准备演示数据:
use codeforge
db.submissions.drop()
db.submissions.insertMany([
{ userId: 1024, lessonSlug: "mdbrm-intro", status: "accepted", score: 100, attempts: 1, submittedAt: new Date("2026-10-01") },
{ userId: 1024, lessonSlug: "mdbrm-types", status: "failed", score: 40, attempts: 2, submittedAt: new Date("2026-10-02") },
{ userId: 2048, lessonSlug: "mdbrm-intro", status: "accepted", score: 90, attempts: 3, submittedAt: new Date("2026-10-03") }
])
步骤 1:Create —— 插入
// 单条
db.submissions.insertOne({ userId: 2048, lessonSlug: "mdbrm-types", status: "pending", score: 0, attempts: 1 })
// 多条:ordered 默认 true,遇错即停
db.submissions.insertMany([
{ userId: 4096, lessonSlug: "mdbrm-intro", status: "accepted", score: 85 },
{ userId: 4096, lessonSlug: "mdbrm-types", status: "accepted", score: 92 }
], { ordered: false }) // false:跳过错误继续插
步骤 2:Read —— 查询基础
// 等值
db.submissions.find({ userId: 1024 })
// 多条件(隐式 AND)
db.submissions.find({ userId: 1024, status: "accepted" })
// 只取一条
db.submissions.findOne({ lessonSlug: "mdbrm-types" }, { score: 1, status: 1 })
find 返回的是游标,mongosh 自动迭代前 20 条,输入 it 继续翻。
步骤 3:Update —— 原地修改
用户重做一题,通过:分数更新、attempts +1、记录最后提交时间:
db.submissions.updateOne(
{ userId: 1024, lessonSlug: "mdbrm-types" },
{
$set: { status: "accepted", score: 100, submittedAt: new Date() },
$inc: { attempts: 1 }
}
)
数组操作:给进度集合的 doneLessons 追加一节课(去重):
db.progress.updateOne(
{ userId: 1024, trackSlug: "mongodb-roadmap-essentials" },
{
$addToSet: { doneLessons: "mdbrm-crud" }, // 已存在则不重复加
$inc: { doneCount: 1 },
$set: { updatedAt: new Date() }
},
{ upsert: true } // 不存在就插入(首次学习时建档)
)
步骤 4:upsert 的返回与 _id
const r = db.progress.updateOne(
{ userId: 2048, trackSlug: "mongodb-roadmap-essentials" },
{ $setOnInsert: { createdAt: new Date() }, $set: { updatedAt: new Date() } },
{ upsert: true }
)
printjson(r) // upsertedCount=1 时 r.upsertedId 是新文档 _id
$setOnInsert 只在「真正发生插入」时生效,适合写字段创建时间。
步骤 5:批量更新与批量写
把所有 pending 超过 7 天的提交标记为 abandoned:
db.submissions.updateMany(
{ status: "pending", submittedAt: { $lt: new Date(Date.now() - 7*24*3600*1000) } },
{ $set: { status: "abandoned" } }
)
混合批量操作用 bulkWrite(一次网络往返执行多种写):
db.submissions.bulkWrite([
{ insertOne: { document: { userId: 8192, lessonSlug: "mdbrm-intro", status: "accepted", score: 100 } } },
{ updateOne: { filter: { userId: 2048, lessonSlug: "mdbrm-types" }, update: { $set: { status: "accepted" } } } },
{ deleteOne: { filter: { userId: 4096, lessonSlug: "mdbrm-types" } } }
])
步骤 6:Delete —— 删除
db.submissions.deleteOne({ userId: 8192 }) // 删一条
db.submissions.deleteMany({ status: "abandoned" }) // 清空废数据
步骤 7:findOneAndUpdate —— 原子「读改写」
判题系统抢占一条待判任务(防多 worker 重复处理):
db.judgeQueue.findOneAndUpdate(
{ status: "queued" },
{ $set: { status: "running", worker: "w-01", startedAt: new Date() } },
{ sort: { _id: 1 }, returnDocument: "after" } // 返回更新后的文档
)
单文档原子性保证:两个 worker 并发执行,只有一个能抢到同一条。
讲清楚要点
- 更新运算符是「原地修改」:
$set(设值)、$unset(删字段)、$inc(加减)、$push(数组追加)、$addToSet(数组去重追加)、$pull(数组删除元素)、$rename(改字段名)、$currentDate(写当前时间)。 - 不带运算符的 update 文档是危险的:MongoDB 7 中
updateOne的更新参数必须是运算符文档或聚合管道;想整文档替换请显式用replaceOne,让意图可见。 - updateMany 默认只改匹配的,不做 upsert 之外的事;
updateOne只改第一条匹配(匹配多条时按自然顺序的第一条,不可靠,条件要能唯一定位)。 - 单文档写是原子的:
$inc不需要「先读再加」,并发安全。计数器、库存、点赞数都该用$inc而不是读出来加完写回去(读-改-写有竞态)。 - insertMany 的 ordered 选项:true(默认)遇错停止,已插入的不回滚;false 并发执行、跳过错误。批量导入用 false + 事后查
writeErrors。 - writeConcern 决定「写到什么程度算成功」:
{w: 1}主节点确认(默认);{w: "majority"}多数派确认,防主节点宕机丢数据;关键数据(如支付)用 majority。
你可能会懵的地方
$push和$addToSet选哪个? 允许重复、关心顺序用$push;语义是「集合」(如已学课时、标签)用$addToSet,天然去重。- update 的 filter 匹配 0 条会怎样?
什么都不做,
matchedCount: 0,不报错。配upsert: true才会插入新文档。 - 数组元素更新怎么定位?
用位置运算符
$:db.submissions.updateOne({_id: x, "cases.name": "case1"}, {$set: {"cases.$.passed": true}}),$代表 filter 匹配到的那个数组下标。 - deleteMany({}) 和 drop() 一样吗? 不一样。deleteMany({}) 逐条删除、保留集合和索引;drop() 直接删整个集合(含索引),快得多。清数据用 drop,删部分用 deleteMany。
- 能更新
_id吗? 不能。_id一旦设定不可变,要换主键只能插入新文档再删旧的。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
读出来在应用层 +1 再 $set 写回 | 并发下计数丢失 | $inc: {count: 1} 原子加 |
更新参数忘写 $set 意图整文档替换却用了 update | 7.x 直接报错;老版本会清空其它字段 | 显式 replaceOne 或 $set |
| 批量导入用循环 insertOne | N 次网络往返,慢一个数量级 | insertMany 或 bulkWrite |
| 用 deleteMany 清全集合 | 慢且产生大量 oplog | drop() |
| 关键写操作用默认 w:1 还觉得「绝对安全」 | 主节点宕机可能丢最近写 | 关键路径 {writeConcern: {w: "majority"}} |
卡点急救
- updateMany 改了 0 条,以为没生效:先
db.col.countDocuments(filter)确认 filter 是否匹配;常见原因是类型不一致(回顾上一课)或点号字段没加引号。 - upsert 报 duplicate key 错误:filter 里的字段和唯一索引冲突——upsert 插入时会带上 filter 中的等值条件字段,若这些字段已存在于别的文档且被唯一索引约束就冲突。让 filter 与唯一键对齐。
- bulkWrite 部分失败:查返回的
writeErrors数组,每项含index(第几个操作)和errmsg;ordered:false 时其余操作已成功,别整体重试(会重复插入),只补偿失败项。
面试常问
- MongoDB 的原子性边界在哪? 单文档。单文档内所有字段的更新要么全成要么全败,这也是「内嵌设计」的隐藏福利——把需要原子修改的数据放进同一文档。跨文档要用多文档事务(4.0+)。
- $inc 为什么并发安全? 它在存储引擎层对文档做读-改-写并加文档级锁,应用层不需要任何分布式锁。
- insertMany 失败会回滚吗? 不会。它不是事务,ordered:true 时停在错误处,之前插入的保留。要么接受部分成功,要么自己补偿,要么放进多文档事务。
自测三问
updateOne的更新参数忘记写$set会发生什么?怎么写才是对的?- 进度系统「完成一节课」的写入,为什么用 upsert +
$addToSet+$inc这个组合? - findOneAndUpdate 在判题队列里解决了什么问题?普通 find + update 为什么不行?
小结一句话
读用 find 游标、写用运算符原地修改、批量用 bulkWrite、并发计数靠 $inc——写操作永远多想一步「并发下会发生什么」。
延伸
- 官方文档:CRUD Operations
- 更新运算符完整列表
- 下一课:查询运算符——
$in、$elemMatch、$regex,把查询条件写到面试级水平。