务场景:本地生活电商的运营团队急得跳脚——商品信息管理全靠手工 Excel,然后开发写脚本批量导入 MySQL,每次上下架都要拉开发改代码。更离谱的是,新来的运营把"已删除"和"已下架"搞混,直接删了一行 Excel,导致 200 个商品的销售记录断档。技术负责人拍板:必须做一个运营后台,支持新增商品、分页浏览、上下架、库存调整和软删除,而且要让运营自己操作,不需要开发每次介入。这个需求正好适合用 MongoDB 的 CRUD 来实现——schema 灵活,开发速度快,一个 MongoDB 就能承载整个数据层。
痛点:没有系统掌握 MongoDB 的 CRUD API,开发时会不断踩坑:用 find({}).limit(10) 发现数据总是不对,因为没写 sort 导致结果是"随机 10 条";用 updateOne 以为更新了全部匹配的文档,实际只改了第一条;用 deleteMany({}) 清表时没加条件,生产事故一触即发;不理解 ordered: false 的含义,批量写入时一条失败整个批次全废;不知道 replaceOne 和 updateOne 的区别,导致更新时意外删掉了其他字段。
- 项目设计
小胖(抱着薯片桶):大师,我还以为 CRUD 就是增删改查四个命令,结果 Shell 里一看——insertOne、insertMany、updateOne、updateMany、replaceOne、findOneAndUpdate……光是个"改"就五六种,难道不能统一成一个 API?
大师:设计这么多 API 恰恰是为了让意图明确、行为可控。如果只有一个 update 函数,你怎么表达"我只想改第一条"还是"改全部匹配的"?MongoDB 的 API 设计是"原子性 + 精确控制"——每个操作要么成功,要么完全失败,副作用清晰可见。
小胖:那 replaceOne 和 updateOne 有什么区别?
大师:replaceOne 是整文档替换——新文档完全覆盖旧文档,除了 _id 不变,其他所有字段都会被替换。updateOne 是字段级修改——你只改指定的字段,其他字段保留。打个比方:replaceOne 像换一张新表格,旧表格上的字全不要了;updateOne 像在表格上涂改几个格子,其余格子原样保留。
技术映射:replaceOne 传入的是完整的新 document(不含 _id 或 _id 不变),MongoDB 会删除旧文档的所有字段(除 _id)再写入。updateOne 需要配合更新操作符(如set、set、set、unset)做增量修改。
小白:那批量写入 insertMany 还有个 ordered 参数,这是干什么的?
大师:好眼力。ordered 默认是 true,意思是顺序执行——插入到第 5 条时如果失败了(比如 _id 重复),第 6 条及之后都不会被执行,第 1-4 条已成功写入也不会回滚。如果你把 ordered 设为 false,即乱序模式,所有文档各自独立尝试——失败的跳过,成功的照写,互不影响。
小胖:为啥要设计这个?失败就全失败不是更干净?
大师:因为生产环境里数据质量参差不齐。比如你从第三方 API 拉来 1 万条数据,里面可能有 20 条重复的——你希望因为这 20 条就把 9980 条全丢掉吗?ordered: false 就是"能写多少写多少"的策略,写完再回头处理失败的。
技术映射:ordered 控制 insertMany 的容错策略,ordered: false 下 InsertManyResult 会报告哪些文档写入成功、哪些失败。
小白(继续追问):我看到 updateOne 还需要用操作符?set、set、set、inc、push、push、push、pull……这些能覆盖所有更新场景吗?
大师:基本能覆盖 90% 的业务场景,看这个表格就清楚了:
操作符 作用 示例
$set 设置字段值(存在即修改,不存在即新增) { $set: { status: “已下架” } }
$unset 删除字段 { $unset: { tempField: “” } }
$inc 原子增减数值 { $inc: { stock: -1 } }
$push 往数组尾部追加元素 { $push: { tags: “新品” } }
$pull 从数组中删除匹配的元素 { $pull: { tags: “过期” } }
$addToSet 向数组添加元素,已有则跳过 { $addToSet: { tags: “热销” } }
$pop 从数组头部(-1)或尾部(1)删除 {KaTeX parse error: Expected 'EOF', got '}' at position 21: …{ history: 1 } }̲ 小胖:咦,inc 还能减?那我做库存扣减的时候,如果库存是 0 我再减 1,不就变成 -1 了?
大师:非常敏锐!这就是实际业务中必须处理的边界问题。你需要在 update 的过滤条件里加上 { stock: { $gt: 0 } }——只有库存大于 0 时才减,否则 matchedCount 为 0,表示更新没有命中任何文档。用 updateOne 加 filter 做库存扣减,天然具备条件检查能力。
技术映射:$inc 搭配 filter 条件做并发安全的库存扣减,前提是条件字段有索引且不考虑超卖。秒杀场景下需要额外策略(见第 27 章)。
大师(总结):CRUD 这章虽然看起来是最基础的 API,但背后承载的是 MongoDB 最核心的设计哲学——操作原子化、增量更新、批量可控。学完这章,你应该能用 mongosh 独立完成一个商品管理后台的完整数据操作。
- 项目实战
3.1 环境准备
沿用第 2 章的 Docker MongoDB 环境,确认运行中:
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:基础写入——insertOne 与 insertMany
目标:掌握单条和批量写入,理解 ordered 参数的行为。
use local_life
// ---- 插入单个商品 ----
const result = db.products_crud.insertOne({
name: “无线蓝牙耳机 Pro”,
category: “数码影音”,
price: NumberDecimal(“199.00”),
stock: 100,
tags: [“蓝牙”, “降噪”, “新品”],
specs: { color: “黑色”, weight: “50g”, battery: “40h” },
status: “在售”, // 在售 / 下架 / 已删除
createdAt: new Date(),
updatedAt: new Date()
})
print(“插入成功,_id:”, result.insertedId)
// ---- 批量插入(有序模式,默认) ----
// 故意在第 2 条使用重复的 _id,观察 ordered: true 的行为
const duplicateId = new ObjectId()
const docs = [
{ _id: duplicateId, name: “商品1”, category: “A”, price: NumberDecimal(“10”) },
{ _id: duplicateId, name: “商品2(重复ID)”, category: “B”, price: NumberDecimal(“20”) },
{ name: “商品3”, category: “C”, price: NumberDecimal(“30”) }
]
try {
db.products_crud.insertMany(docs)
// 期望失败——因为 ordered: true(默认),第 2 条因 _id 重复失败后,
// 第 3 条不会执行
} catch (e) {
print(“有序模式异常(预期):”, e.writeErrors ? e.writeErrors[0].errmsg : e.message)
}
// 验证:商品1 已写入,商品3 未写入
print(“商品1存在:”, db.products_crud.findOne({ _id: duplicateId }) !== null)
print(“商品3存在:”, db.products_crud.findOne({ name: “商品3” }) !== null)
// ---- 批量插入(乱序模式) ----
// 清理
db.products_crud.deleteMany({})
const docs2 = [
{ _id: new ObjectId(), name: “商品A”, category: “A”, price: NumberDecimal(“10”) },
{ _id: duplicateId, name: “商品B(重复ID)”, category: “B”, price: NumberDecimal(“20”) },
{ name: “商品C”, category: “C”, price: NumberDecimal(“30”) }
]
try {
const res = db.products_crud.insertMany(docs2, { ordered: false })
print(“写入成功数:”, res.insertedCount)
print(“各条结果:”, JSON.stringify(res.insertedIds))
} catch (e) {
print(“乱序写入结果:”)
print(" 成功:“, e.result?.insertedCount || 0)
print(” 失败:", e.writeErrors?.length || 0)
// 期望:商品A 和 商品C 成功,商品B 因 _id 重复失败
}
print(“商品A存在:”, db.products_crud.findOne({ name: “商品A” }) !== null)
print(“商品C存在:”, db.products_crud.findOne({ name: “商品C” }) !== null)
步骤二:查询与分页——find、projection、sort、limit、skip
目标:实现商品列表的分页查询,理解各操作的组合方式。
// 准备 50 条测试数据
const categories = [“数码影音”, “手机配件”, “家居生活”, “美妆个护”, “食品饮料”]
const bulkOps = []
for (let i = 1; i <= 50; i++) {
bulkOps.push({
name:测试商品_${String(i).padStart(3, '0')},
category: categories[i % 5],
price: NumberDecimal((Math.random() * 500 + 10).toFixed(2)),
stock: Math.floor(Math.random() * 200),
tags: i % 3 === 0 ? [“热销”] : [],
status: i % 10 === 0 ? “下架” : “在售”,
createdAt: new Date(Date.now() - i * 3600000),
updatedAt: new Date()
})
}
db.products_crud.insertMany(bulkOps)
// ---- 基础查询 ----
// 返回所有字段
db.products_crud.findOne({ name: “测试商品_001” })
// 只返回指定字段(投影 projection)
db.products_crud.find(
{ status: “在售” },
{ name: 1, price: 1, category: 1, _id: 0 } // 1=包含,0=排除
).limit(5)
// ---- 分页查询(offset 模式) ----
const pageSize = 10
const pageNo = 3 // 第 3 页
// 错误做法:只用 skip + limit(浅分页勉强可用,深分页卡死)
const page1wrong = db.products_crud.find({})
.skip(pageSize * (pageNo - 1)) // 跳过前 20 条
.limit(pageSize) // 取 10 条
// ⚠️ 没有 sort()!结果不稳定
.toArray()
print(“第”, pageNo, “页(无序):”, page1wrong.length, “条”)
// 正确做法:sort + skip + limit
const page3correct = db.products_crud.find({ status: “在售” })
.sort({ createdAt: -1 }) // 按创建时间降序(必须先有 sort)
.skip(pageSize * (pageNo - 1)) // 跳过前 20 条
.limit(pageSize) // 取 10 条
.project({ name: 1, price: 1, createdAt: 1 })
.toArray()
print(“第”, pageNo, “页(有序):”, page3correct.length, “条”)
page3correct.forEach(p => print(${p.name} | ${p.price}))
// ---- 统计总数(分页用) ----
// 方式一:countDocuments(推荐,走索引)
const total = db.products_crud.countDocuments({ status: “在售” })
print(“在售商品总数:”, total)
// 方式二:estimatedDocumentCount(基于元数据,毫秒级,不能加过滤条件)
const approx = db.products_crud.estimatedDocumentCount()
print(“商品约数:”, approx)
步骤三:更新操作——updateOne、updateMany、replaceOne
目标:实现商品上下架、库存增减、信息修改。
// ---- updateOne:修改单个字段 ----
// 下架一个商品
const updateResult = db.products_crud.updateOne(
{ name: “测试商品_010” }, // filter:找到目标
{
$set: { status: “下架”, updatedAt: new Date() },
$inc: { stock: 0 } // 不增加库存,仅演示 $inc 的用法
}
)
print(“匹配数:”, updateResult.matchedCount, “修改数:”, updateResult.modifiedCount)
// ---- updateMany:批量更新 ----
// 将所有"过期"标签的商品去标签
const manyResult = db.products_crud.updateMany(
{ tags: “热销” },
{ $pull: { tags: “热销” } } // 从数组中删除"热销"
)
print(“修改了”, manyResult.modifiedCount, “个商品”)
// ---- $inc 做原子库存扣减 ----
// 扣减库存(带条件检查:库存 > 0 才扣)
const deductResult = db.products_crud.updateOne(
{ name: “测试商品_001”, stock: { $gt: 0 } }, // 只有 stock > 0 才匹配
{ $inc: { stock: -1 }, $set: { updatedAt: new Date() } }
)
print(“匹配数:”, deductResult.matchedCount)
print(“修改数:”, deductResult.modifiedCount)
// 如果 matchedCount = 0,说明库存已为 0
// ---- $push 和addToSet−−−−//给商品加标签(addToSet ---- // 给商品加标签(addToSet−−−−//给商品加标签(push 每次都追加,$addToSet 去重)
db.products_crud.updateOne(
{ name: “测试商品_001” },
{ $addToSet: { tags: { $each: [“推荐”, “热销”, “推荐”] } } } // "推荐"只加一次
)
const updated = db.products_crud.findOne({ name: “测试商品_001” })
print(“标签:”, updated.tags)
// ---- replaceOne:整文档替换 ----
// 获取原文档
const oldDoc = db.products_crud.findOne({ name: “测试商品_002” })
print(“替换前:”, JSON.stringify(oldDoc, null, 2))
// 构造新文档(只保留需要的字段)
const newDoc = {
name: “测试商品_002_改名版”,
price: NumberDecimal(“999.00”),
category: “高端定制”,
stock: 10,
status: “在售”
// 注意:没有 tags、specs 等字段,替换后这些字段会消失
}
const replaceResult = db.products_crud.replaceOne(
{ _id: oldDoc._id },
newDoc
)
print(“替换后:”)
printjson(db.products_crud.findOne({ _id: oldDoc._id }))
// ⚠️ 原来的 tags、specs、createdAt 等字段全部消失了!
// 结论:日常更新用 updateOne + $set,整文档替换才用 replaceOne
可能遇到的坑:
replaceOne 不能包含更新操作符($set 等),只能传普通文档,否则会报错。
findOneAndUpdate 适合需要返回更新前或更新后文档的场景(如 CAS 并发控制)。
updateMany 批量操作时注意不要匹配过多文档,善用 hint 指定索引防止全表扫描。
步骤四:软删除——并非真正 deleteOne
目标:学习 MongoDB 中推荐的删除策略——软删除而非硬删除。
// ---- 硬删除(不推荐) ----
// 直接物理删除数据
const hardDel = db.products_crud.deleteOne({ name: “测试商品_050” })
print(“物理删除:”, hardDel.deletedCount)
// ---- 软删除(推荐) ----
// 1. 添加 isDeleted 字段标记
db.products_crud.updateOne(
{ name: “测试商品_049” },
{
$set: {
isDeleted: true,
deletedAt: new Date(),
status: “已删除”
}
}
)
// 2. 所有查询默认排除已删除
const activeProducts = db.products_crud.find({
status: { $ne: “已删除” }
}).count()
print(“活跃商品数:”, activeProducts)
// 3. 软删除恢复
db.products_crud.updateOne(
{ name: “测试商品_049” },
{
$set: { status: “在售”, updatedAt: new Date() },
$unset: { isDeleted: “”, deletedAt: “” }
}
)
// ---- 批量删除(谨慎!) ----
// 危险示例:不带条件的 deleteMany 会清空整个集合
// db.products_crud.deleteMany({}) // ⚠️ 永远不要在生产环境直接执行
步骤五:findOneAndUpdate 的妙用
目标:实现库存扣减时同时返回最新库存。适合需要 CAS(Compare-And-Swap)的场景。
// 原子地扣减库存并返回更新后的文档
const updated = db.products_crud.findOneAndUpdate(
{ name: “测试商品_001”, stock: { $gt: 0 } }, // 找到且库存 > 0
{ $inc: { stock: -1 } }, // 减 1
{
returnDocument: “after”, // 返回更新后的文档(默认为 “before”)
projection: { name: 1, stock: 1, _id: 0 }
}
)
print(“扣减后剩余库存:”, updated)
// 如果 updated 为 null,说明库存不足或商品不存在
3.3 完整代码清单
所有操作可直接在 mongosh 中执行。也可整理为脚本:
文件 用途
mongodb-lab/scripts/ch04-crud-basic.js 本章全部 CRUD 操作脚本
mongodb-lab/scripts/ch04-product-admin.js 商品管理后台模拟脚本
3.4 测试验证
// ===== 集成验证 =====
use local_life
// 1. 新增商品(insertOne)
const r1 = db.products_crud.insertOne({
name: “验收商品”, category: “测试”, price: NumberDecimal(“88.88”), stock: 10,
status: “在售”, createdAt: new Date(), updatedAt: new Date()
})
const pid = r1.insertedId
print(“新增商品 _id:”, pid, " PASS" )
// 2. 分页查询(find + sort + skip + limit)
const page = db.products_crud.find({ status: “在售” })
.sort({ createdAt: -1 })
.limit(5)
.project({ name: 1, status: 1 })
.toArray()
print(“分页查询结果数:”, page.length, " " + (page.length > 0 ? “PASS” : “FAIL”))
// 3. 更新库存($inc)
const r2 = db.products_crud.updateOne(
{ _id: pid, stock: { $gt: 0 } },
{ $inc: { stock: -1 } }
)
print(“库存扣减 matchedCount:”, r2.matchedCount, " " + (r2.matchedCount === 1 ? “PASS” : “FAIL”))
// 4. 再次扣减直到 0,验证 matchedCount = 0(库存不足)
const doc = db.products_crud.findOne({ _id: pid })
// 先把库存置为 0
db.products_crud.updateOne({ _id: pid }, { $set: { stock: 0 } })
const r3 = db.products_crud.updateOne(
{ _id: pid, stock: { $gt: 0 } },
{ $inc: { stock: -1 } }
)
print(“库存不足 matchedCount:”, r3.matchedCount, " " + (r3.matchedCount === 0 ? “PASS” : “FAIL”))
// 5. 软删除
const r4 = db.products_crud.updateOne(
{ _id: pid },
{ $set: { status: “已删除”, isDeleted: true, deletedAt: new Date() } }
)
print(“软删除 modifiedCount:”, r4.modifiedCount, " " + (r4.modifiedCount === 1 ? “PASS” : “FAIL”))
print(“\n=== 全部验证通过 ===”)
4. 项目总结
4.1 优缺点对比
维度 MongoDB CRUD MySQL CRUD 说明
写入灵活 不固定字段,同集合不同文档字段可不同 必须符合表结构 MongoDB 适合快速迭代
原子更新inc、inc、inc、push 等操作符,条件扣减 UPDATE … SET stock = stock - 1 WHERE … 两者均支持
批量写入 insertMany 支持 ordered 控制 INSERT INTO … VALUES (…), (…) 功能等价
软删除 手动实现,需要应用层过滤 同样需要手动实现 两者均无内置软删除
replaceOne 整文档替换,危险但高效 REPLACE INTO MongoDB 的 replaceOne 更直观
返回更新后文档 findOneAndUpdate 可选 需 RETURNING 子句(MySQL 8.0.21+) MongoDB 更简洁
4.2 适用场景
MongoDB CRUD 很适合:
电商商品后台管理——字段多变,操作频繁,上下架、库存调整、标签管理。
CMS 内容管理——文章、评论、标签的关系灵活,上下线和版本管理。
配置管理——应用配置、功能开关、A/B 实验分组。
用户行为日志——高吞吐写入,简单的条件查询和统计。
实时库存管理——原子 $inc 扣减,无需悲观锁。
不适用场景:
需要复杂 JOIN 的报表查询(MySQL 在多表关联上更擅长)。
需要存储过程做复杂计算(MongoDB 无存储过程,用聚合管道或应用层替代)。
4.3 注意事项
注意事项 说明
find 不加 sort 就分页 数据顺序是不确定的(自然顺序,可能按 insert 顺序但不保证),分页结果可能重复或遗漏
replaceOne 无意删字段 整文档替换会移除未在 newDoc 中出现的所有字段
updateOne 要求 filter 命中文档 如果没有文档匹配,matchedCount = 0,不是报错,容易忽略
inc不能用于非数字字段如果stock字段存储为字符串"100",inc 不能用于非数字字段 如果 stock 字段存储为字符串 "100",inc不能用于非数字字段如果stock字段存储为字符串"100",inc 会报错
大文档使用 findOneAndUpdate 更新整个大文档时每次都会读写完整的 BSON,考虑只用 updateOne 返回 matchedCount
4.4 常见踩坑经验
故障案例一:运维误执行 deleteMany({})
某次数据库迁移,运维在测试环境执行 db.orders.deleteMany({}) 清空了测试数据,但 IDE 中不小心切到了生产环境的连接标签,再次按 Ctrl+Enter 执行。500 万条订单被清空,幸好有延迟从库(Delayed Secondary)做数据恢复。根因:缺少操作确认机制和权限控制。解决:为运维账号设置 readWrite 角色而非 clusterManager;deleteMany 前先 countDocuments 确认数量;在 Mongosh 中开启 db.setVerboseShell(true),关键操作需要显式确认。
故障案例二:updateOne 只更新了一条,但代码期望更新全部
某开发用 updateOne({status:“待支付”}, {$set:{status:“已过期”}}) 来批量关闭过期订单,结果几十万条订单只改了 1 条,造成巨大的财务核算偏差。根因:把 updateOne 和 updateMany 用混了。解决:代码审查规则——涉及 {status: ‘xxx’} 等可能匹配多条的条件时,必须使用 updateMany。
故障案例三:库存扣减的并发超卖
某秒杀活动,100 万人同时抢 1000 件商品。代码逻辑:findOne 查库存 → 判断 > 0 → updateOne(KaTeX parse error: Expected '}', got 'EOF' at end of input: …: pid, stock: {gt: 0}}, {$inc: {stock: -1}}),通过 modifiedCount 判断是否抢到(见第 27 章详细方案)。
4.5 思考题
如果要在 updateOne 时实现 CAS(Compare-And-Swap)语义——比如只有当前版本号为 3 时才更新为 4——该怎么写?MongoDB 是否支持乐观锁?
findOneAndDelete 和 deleteOne 的返回值有什么区别?什么场景下前者的返回值是必需的?