1. 项目背景
业务场景:本地生活电商的运营团队急得跳脚------商品信息管理全靠手工 Excel,然后开发写脚本批量导入 MySQL,每次上下架都要拉开发改代码。更离谱的是,新来的运营把"已删除"和"已下架"搞混,直接删了一行 Excel,导致 200 个商品的销售记录断档。技术负责人拍板:必须做一个运营后台,支持新增商品、分页浏览、上下架、库存调整和软删除,而且要让运营自己操作,不需要开发每次介入。这个需求正好适合用 MongoDB 的 CRUD 来实现------schema 灵活,开发速度快,一个 MongoDB 就能承载整个数据层。
痛点 :没有系统掌握 MongoDB 的 CRUD API,开发时会不断踩坑:用 find({}).limit(10) 发现数据总是不对,因为没写 sort 导致结果是"随机 10 条";用 updateOne 以为更新了全部匹配的文档,实际只改了第一条;用 deleteMany({}) 清表时没加条件,生产事故一触即发;不理解 ordered: false 的含义,批量写入时一条失败整个批次全废;不知道 replaceOne 和 updateOne 的区别,导致更新时意外删掉了其他字段。
2. 项目设计
小胖 (抱着薯片桶):大师,我还以为 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、$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、$inc、$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)删除 | { $pop: { history: 1 } } |
小胖 :咦,$inc 还能减?那我做库存扣减的时候,如果库存是 0 我再减 1,不就变成 -1 了?
大师 :非常敏锐!这就是实际业务中必须处理的边界问题。你需要在 update 的过滤条件里加上 { stock: { $gt: 0 } }------只有库存大于 0 时才减,否则 matchedCount 为 0,表示更新没有命中任何文档。用 updateOne 加 filter 做库存扣减,天然具备条件检查能力。
技术映射 :$inc 搭配 filter 条件做并发安全的库存扣减,前提是条件字段有索引且不考虑超卖。秒杀场景下需要额外策略(见第 27 章)。
大师(总结):CRUD 这章虽然看起来是最基础的 API,但背后承载的是 MongoDB 最核心的设计哲学------操作原子化、增量更新、批量可控。学完这章,你应该能用 mongosh 独立完成一个商品管理后台的完整数据操作。
3. 项目实战
3.1 环境准备
沿用第 2 章的 Docker MongoDB 环境,确认运行中:
bash
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:基础写入------insertOne 与 insertMany
目标:掌握单条和批量写入,理解 ordered 参数的行为。
javascript
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
目标:实现商品列表的分页查询,理解各操作的组合方式。
javascript
// 准备 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
目标:实现商品上下架、库存增减、信息修改。
javascript
// ---- 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 ----
// 给商品加标签($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 中推荐的删除策略------软删除而非硬删除。
javascript
// ---- 硬删除(不推荐) ----
// 直接物理删除数据
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)的场景。
javascript
// 原子地扣减库存并返回更新后的文档
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 测试验证
javascript
// ===== 集成验证 =====
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、$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 会报错 |
大文档使用 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($inc: -1)。最终卖出 1583 件。根因:查库存和扣库存两步不是原子的,中间有时间窗口。解决 :一步到位------updateOne({_id: pid, stock: {$gt: 0}}, {$inc: {stock: -1}}),通过 modifiedCount 判断是否抢到(见第 27 章详细方案)。
4.5 思考题
- 如果要在
updateOne时实现 CAS(Compare-And-Swap)语义------比如只有当前版本号为 3 时才更新为 4------该怎么写?MongoDB 是否支持乐观锁? findOneAndDelete和deleteOne的返回值有什么区别?什么场景下前者的返回值是必需的?
(答案将在第 5 章末尾揭晓)
上一章思考题答案:
防范脏数据的三层策略:① 应用层校验(DTO 层) :在进入数据库之前用 JSR-380(Bean Validation)或 JSON Schema 做字段类型、格式校验,是第一道防线。② 数据库层 Schema Validator :在集合上设置
$jsonSchema校验器,即使应用层绕过也能拦截,是最后一道防线。③ 数据治理脚本 :定期运行collStats和 Schema 分析工具检测异常字段,发现脏数据后通过数据迁移脚本修复。ObjectId vs UUID v7:两者前 48 位都是时间戳(秒 vs 毫秒),都具备时间排序能力。ObjectId 优势:12 字节(比 UUID v7 的 16 字节少 25%),在 MongoDB 生态中天然支持(
getTimestamp()、mongosh 内置方法)。UUID v7 优势:与通用生态兼容(PostgreSQL、Javajava.util.UUID),精度到毫秒(ObjectId 只到秒)。选择:如果数据只存在 MongoDB 且排序精度要求秒级,用 ObjectId;如果需要跨系统共享 ID 且需要毫秒精度,用 UUID v7。
延伸阅读与资源
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地