务场景本地生活电商的运营团队急得跳脚——商品信息管理全靠手工 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 ps3.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” })// 只返回指定字段投影 projectiondb.products_crud.find({ status: “在售” },{ name: 1, price: 1, category: 1, _id: 0 } // 1包含0排除).limit(5)// ---- 分页查询offset 模式 ----const pageSize 10const 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 limitconst 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 的妙用目标实现库存扣减时同时返回最新库存。适合需要 CASCompare-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. 新增商品insertOneconst r1 db.products_crud.insertOne({name: “验收商品”, category: “测试”, price: NumberDecimal(“88.88”), stock: 10,status: “在售”, createdAt: new Date(), updatedAt: new Date()})const pid r1.insertedIdprint(“新增商品 _id:”, pid, PASS )// 2. 分页查询find sort skip limitconst 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. 更新库存$incconst 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 })// 先把库存置为 0db.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字段存储为字符串100inc 不能用于非数字字段 如果 stock 字段存储为字符串 100inc不能用于非数字字段如果stock字段存储为字符串100inc 会报错大文档使用 findOneAndUpdate 更新整个大文档时每次都会读写完整的 BSON考虑只用 updateOne 返回 matchedCount4.4 常见踩坑经验故障案例一运维误执行 deleteMany({})某次数据库迁移运维在测试环境执行 db.orders.deleteMany({}) 清空了测试数据但 IDE 中不小心切到了生产环境的连接标签再次按 CtrlEnter 执行。500 万条订单被清空幸好有延迟从库Delayed Secondary做数据恢复。根因缺少操作确认机制和权限控制。解决为运维账号设置 readWrite 角色而非 clusterManagerdeleteMany 前先 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 时实现 CASCompare-And-Swap语义——比如只有当前版本号为 3 时才更新为 4——该怎么写MongoDB 是否支持乐观锁findOneAndDelete 和 deleteOne 的返回值有什么区别什么场景下前者的返回值是必需的