第4章:Mongo CRUD 基础实战——用户与商品管理

1. 项目背景

业务场景:本地生活电商的运营团队急得跳脚------商品信息管理全靠手工 Excel,然后开发写脚本批量导入 MySQL,每次上下架都要拉开发改代码。更离谱的是,新来的运营把"已删除"和"已下架"搞混,直接删了一行 Excel,导致 200 个商品的销售记录断档。技术负责人拍板:必须做一个运营后台,支持新增商品、分页浏览、上下架、库存调整和软删除,而且要让运营自己操作,不需要开发每次介入。这个需求正好适合用 MongoDB 的 CRUD 来实现------schema 灵活,开发速度快,一个 MongoDB 就能承载整个数据层。

痛点 :没有系统掌握 MongoDB 的 CRUD API,开发时会不断踩坑:用 find({}).limit(10) 发现数据总是不对,因为没写 sort 导致结果是"随机 10 条";用 updateOne 以为更新了全部匹配的文档,实际只改了第一条;用 deleteMany({}) 清表时没加条件,生产事故一触即发;不理解 ordered: false 的含义,批量写入时一条失败整个批次全废;不知道 replaceOneupdateOne 的区别,导致更新时意外删掉了其他字段。

2. 项目设计

小胖 (抱着薯片桶):大师,我还以为 CRUD 就是增删改查四个命令,结果 Shell 里一看------insertOneinsertManyupdateOneupdateManyreplaceOnefindOneAndUpdate......光是个"改"就五六种,难道不能统一成一个 API?

大师 :设计这么多 API 恰恰是为了让意图明确、行为可控。如果只有一个 update 函数,你怎么表达"我只想改第一条"还是"改全部匹配的"?MongoDB 的 API 设计是"原子性 + 精确控制"------每个操作要么成功,要么完全失败,副作用清晰可见。

小胖 :那 replaceOneupdateOne 有什么区别?

大师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: falseInsertManyResult 会报告哪些文档写入成功、哪些失败。

小白 (继续追问):我看到 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 很适合

  1. 电商商品后台管理------字段多变,操作频繁,上下架、库存调整、标签管理。
  2. CMS 内容管理------文章、评论、标签的关系灵活,上下线和版本管理。
  3. 配置管理------应用配置、功能开关、A/B 实验分组。
  4. 用户行为日志------高吞吐写入,简单的条件查询和统计。
  5. 实时库存管理------原子 $inc 扣减,无需悲观锁。

不适用场景

  1. 需要复杂 JOIN 的报表查询(MySQL 在多表关联上更擅长)。
  2. 需要存储过程做复杂计算(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 条,造成巨大的财务核算偏差。根因:把 updateOneupdateMany 用混了。解决 :代码审查规则------涉及 {status: 'xxx'} 等可能匹配多条的条件时,必须使用 updateMany

故障案例三:库存扣减的并发超卖

某秒杀活动,100 万人同时抢 1000 件商品。代码逻辑:findOne 查库存 → 判断 > 0updateOne($inc: -1)。最终卖出 1583 件。根因:查库存和扣库存两步不是原子的,中间有时间窗口。解决 :一步到位------updateOne({_id: pid, stock: {$gt: 0}}, {$inc: {stock: -1}}),通过 modifiedCount 判断是否抢到(见第 27 章详细方案)。

4.5 思考题

  1. 如果要在 updateOne 时实现 CAS(Compare-And-Swap)语义------比如只有当前版本号为 3 时才更新为 4------该怎么写?MongoDB 是否支持乐观锁?
  2. findOneAndDeletedeleteOne 的返回值有什么区别?什么场景下前者的返回值是必需的?

(答案将在第 5 章末尾揭晓)


上一章思考题答案

  1. 防范脏数据的三层策略:① 应用层校验(DTO 层) :在进入数据库之前用 JSR-380(Bean Validation)或 JSON Schema 做字段类型、格式校验,是第一道防线。② 数据库层 Schema Validator :在集合上设置 $jsonSchema 校验器,即使应用层绕过也能拦截,是最后一道防线。③ 数据治理脚本 :定期运行 collStats 和 Schema 分析工具检测异常字段,发现脏数据后通过数据迁移脚本修复。

  2. ObjectId vs UUID v7:两者前 48 位都是时间戳(秒 vs 毫秒),都具备时间排序能力。ObjectId 优势:12 字节(比 UUID v7 的 16 字节少 25%),在 MongoDB 生态中天然支持(getTimestamp()、mongosh 内置方法)。UUID v7 优势:与通用生态兼容(PostgreSQL、Java java.util.UUID),精度到毫秒(ObjectId 只到秒)。选择:如果数据只存在 MongoDB 且排序精度要求秒级,用 ObjectId;如果需要跨系统共享 ID 且需要毫秒精度,用 UUID v7。

延伸阅读与资源

python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地

后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用

后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析