CRUD 是 Create、Read、Update、Delete 的缩写,对应创建、读取、更新和删除。名字很基础,真正重要的是每个操作的三个问题:过滤条件会命中谁、修改内容是什么、返回结果能证明什么。
这一节继续使用上一节的种子状态。开始前应有 5 本书、3 位顾客和 5 张订单。如果状态不同,先重新执行上一节的集合创建与种子导入脚本。

数据库操作具有状态。上一条命令是否执行过,会改变下一条命令的结果。与其在结果不一致时猜原因,不如先建立一个小而明确的前置检查。
如果已经退出 mongosh,重新进入并切换数据库:
docker exec -it paperboat-mongo mongosh --quietuse bookstore
print(EJSON.stringify({
books: db.books.countDocuments({}),
customers: db.customers.countDocuments({}),
orders: db.orders.countDocuments({})
}));{"books":5,"customers":3,"orders":5}三个数字说明我们站在同一个起点。若结果不同,优先重置种子数据,而不是为了迁就错误状态去改下面的过滤条件。
insertOne() 接收一个文档。集合存在时,它把文档写入集合;如果没有 _id,mongosh 会生成 ObjectId。课程仍使用固定字符串 _id,这样新增顾客可以被后续命令准确找到。
插入结果中的 acknowledged 说明服务端是否确认写入,insertedId 则告诉调用方最终使用了哪个 _id。它不会把整个新文档原样返回。
const insertResult = db.customers.insertOne({
_id: "customer-chen",
name: "陈墨",
email: "chen@example.com",
addresses: [
{
label: "默认",
city: "苏州",
detail: "姑苏区墨香路 6 号"
}
]
});
print(EJSON.stringify({
acknowledged: insertResult.acknowledged,
insertedId: insertResult.insertedId
{"acknowledged":true,"insertedId":"customer-chen"}这两个字段证明写入被确认,并且新顾客的标识是 customer-chen。再读取一次文档,才能验证字段内容:
写入确认回答“数据库接受了吗”,读取回答“保存后的事实是什么”。对关键流程做读后验证,比只看到命令没有抛错更可靠。
const newCustomer = db.customers.findOne(
{ _id: "customer-chen" },
{ _id: 1, name: 1, email: 1, "addresses.city": 1 }
);
print(EJSON.stringify(newCustomer));{"_id":"customer-chen","name":"陈墨","email":"chen@example.com","addresses":[{"city":"苏州"}]}投影只返回了需要核对的字段。addresses 仍然是数组,但每个元素只保留 city。
固定 _id 让课程结果稳定,也会让重复执行 insertOne() 触发重复键错误。若你已经成功插入 customer-chen,不要再次执行创建命令;需要从头重做时,回到种子初始化步骤统一重置。
find() 返回游标,而不是立即把无限结果全部放进内存。过滤器 { published: true } 决定哪些文档进入结果;第二个参数是投影,决定返回哪些字段。这里显式排除 _id,让输出只关注标题、价格和库存。
sort({ title: 1 }) 中的 1 表示升序,-1 表示降序。即使数据量很小,也应明确结果顺序,否则数据库没有义务每次按同一顺序返回。
const publishedBooks = db.books
.find(
{ published: true },
{ _id: 0, title: 1, price: 1, stock: 1 }
)
.sort({ title: 1 })
.toArray();
print(EJSON.stringify(publishedBooks));[{"title":"MongoDB 从入门到实践","price":89,"stock":20},{"title":"Node.js 项目开发","price":69,"stock":15},{"title":"数据建模的艺术","price":79,"stock":8},{"title":"现代 Web 基础","price":59,"stock"结果只有 4 本,因为 book-draft 的 published 为 false。toArray() 适合当前这组很小的课程数据;面对大量文档时,应让应用逐批消费游标,不能无条件把全部结果载入内存。
findOne() 返回第一个匹配文档;没有匹配时返回 null。当过滤条件使用唯一 _id 时,结果最多只有一条,语义比 find(...).limit(1) 更直接。
嵌套字段可以用点号路径查询。addresses.city 会检查地址数组中嵌套文档的 city 字段,只要至少一个元素匹配就能命中顾客。
const hangzhouCustomer = db.customers.findOne(
{ "addresses.city": "杭州" },
{ _id: 1, name: 1, addresses: 1 }
);
print(EJSON.stringify(hangzhouCustomer));{"_id":"customer-zhou","name":"周雨","addresses":[{"label":"默认","city":"杭州","detail":"西湖区云栖街 12 号"}]}点号路径用于过滤,并不会自动裁剪数组内容,所以投影中的 addresses: 1 返回完整地址数组。若顾客有多个地址且只想返回匹配元素,后面可以使用 $elemMatch 投影或聚合管道。
售出一本书需要减少库存。我们没有先读取库存再决定更新,而是把“库存至少为 1”写进更新过滤器:
{ _id: "book-mongodb", stock: { $gte: 1 } }MongoDB 会在执行写入时检查这个条件。$inc 对库存做增量修改,$set 把书标记为精选。两个操作符作用于同一个文档,因此这次更新是原子的,其他客户端不会看到库存已减但 featured 还没设置的中间状态。
const updateResult = db.books.updateOne(
{
_id: "book-mongodb",
stock: { $gte: 1 }
},
{
$inc: { stock: -1 },
$set: { featured: true }
}
);
print(EJSON.stringify({
acknowledged: updateResult.acknowledged,
matchedCount: updateResult.matchedCount,
modifiedCount: updateResult.modifiedCount
}));{"acknowledged":true,"matchedCount":1,"modifiedCount":1}matchedCount: 1 表示过滤器找到一本库存充足的目标书;modifiedCount: 1 表示文档内容确实发生变化。现在读取更新后的字段:
更新结果只给计数,不返回修改后的完整文档。读后验证能区分“命中了文档”与“字段最终是什么”。
const updatedBook = db.books.findOne(
{ _id: "book-mongodb" },
{ _id: 0, title: 1, stock: 1, featured: 1 }
);
print(EJSON.stringify(updatedBook));{"title":"MongoDB 从入门到实践","stock":19,"featured":true}库存从 20 变成 19,featured 为 true。这两个值会成为下一节组合查询的输入状态。
命中文档不代表一定发生修改。如果把已经是 true 的 featured 再次设置为 true,过滤器仍然命中,但最终值没有变化。应用不能把 modifiedCount: 0 一律解释成“记录不存在”。
const noChangeResult = db.books.updateOne(
{ _id: "book-mongodb" },
{ $set: { featured: true } }
);
print(EJSON.stringify({
matchedCount: noChangeResult.matchedCount,
modifiedCount: noChangeResult.modifiedCount
}));{"matchedCount":1,"modifiedCount":0}结果准确表达了两件事:书存在,但目标值已经满足要求。若 matchedCount 也是 0,才说明过滤器没有找到文档。
删除最危险的部分不是方法名,而是过滤条件。deleteOne({}) 会删除任意一条文档;deleteMany({}) 会清空集合。更稳妥的流程是先用完全相同的过滤器预览,再执行删除,并检查 deletedCount。
我们的删除条件同时约束 _id、发布状态和库存。即使 _id 写对了,只要草稿已经上架或有库存,操作也不会删除它。
先预览目标:
const draftFilter = {
_id: "book-draft",
published: false,
stock: 0
};
print(EJSON.stringify(db.books.findOne(
draftFilter,
{ _id: 1, title: 1, published: 1, stock: 1 }
)));{"_id":"book-draft","title":"尚未发布的草稿","stock":0,"published":false}预览确认目标后,再执行删除:
复用同一个 draftFilter 能避免预览条件和删除条件手工抄写不一致。deleteOne() 最多删除一个文档,返回的 deletedCount 是最终判断依据。
const deleteResult = db.books.deleteOne(draftFilter);
print(EJSON.stringify({
acknowledged: deleteResult.acknowledged,
deletedCount: deleteResult.deletedCount
}));{"acknowledged":true,"deletedCount":1}deletedCount: 1 表示草稿确实被删除。最后验证集合状态:
删除后的目标查询应返回 null,集合总数应从 5 变为 4。两个检查一起做,可以同时确认目标消失和其余书籍仍在。
print(EJSON.stringify({
deletedDocument: db.books.findOne({ _id: "book-draft" }),
remainingBooks: db.books.countDocuments({})
}));{"deletedDocument":null,"remainingBooks":4}纸舟书店现在有 4 本正式书籍。顾客数量因为新增陈墨变成 4,订单仍是 5 张。
这一节结束时,CRUD 四类动作都已经落到纸舟书店的真实状态上:新增一位顾客、读取四本已发布书、售出一本 MongoDB 书、删除一份未发布草稿。下一节会组合比较、逻辑、数组与嵌套条件,并实现不会因同价书籍而抖动的稳定分页。