上一节让纸舟书店的状态发生了变化:book-draft 已删除,book-mongodb 的库存是 19,并新增了 featured: true。这一节会从这个状态出发,把查询拆成四个独立问题:哪些文档能进入结果、返回哪些字段、按什么顺序排列、一次取多少条。
MongoDB 查询过滤器本身也是文档。等值、范围、逻辑、数组和嵌套条件可以组合,但组合得越复杂,越需要先用小结果验证语义,再讨论索引与性能。
这一节的固定结果依赖上一节的更新与删除。先核对关键文档,可以快速判断是否需要回到种子导入和 CRUD 步骤。
如果需要,重新进入 shell 并切换数据库:
docker exec -it paperboat-mongo mongosh --quietuse bookstore
const mongoBook = db.books.findOne(
{ _id: "book-mongodb" },
{ _id: 1, stock: 1, featured: 1 }
);
print(EJSON.stringify({
bookCount: db.books.countDocuments({}),
mongoBook
}));{"bookCount":4,"mongoBook":{"_id":"book-mongodb","stock":19,"featured":true}}如果书籍总数不是 4,或库存仍是 20,说明前一节没有完整执行。先修正起点,再继续下面的组合查询。
同一字段的多个比较条件应写在一个对象里。下面的价格条件表示闭区间 60 到 90:
{ price: { $gte: 60, $lte: 90 } }$in 表示字段值属于给定集合。把 category 与 price 放在同一个过滤器中,默认是逻辑 AND:两部分都满足的文档才能进入结果。
查找价格在 60 到 90 元之间、类别属于“数据库”的书,并按价格升序返回:
const databaseBooks = db.books
.find(
{
price: { $gte: 60, $lte: 90 },
category: { $in: ["数据库"] }
},
{ _id: 0, title: 1, price: 1 }
)
.sort({ price: 1, _id: 1 })
.toArray();
print(EJSON.stringify[{"title":"数据建模的艺术","price":79},{"title":"MongoDB 从入门到实践","price":89}]Node.js 项目开发 的价格也在区间内,但类别是“编程”,因此没有进入结果。现代 Web 基础 同时不满足类别和最低价格。
$or 接收一个过滤器数组,只要其中一个分支成立,文档就会匹配。纸舟书店准备一个运营列表:库存少于 10 本,或者被标记为精选的书,都进入候选列表。
字段不存在时不会自动等于 false。book-design 没有 featured 字段,但它凭 stock < 10 命中;book-mongodb 凭 featured: true 命中。
const operationCandidates = db.books
.find(
{
$or: [
{ stock: { $lt: 10 } },
{ featured: true }
]
},
{ _id: 1, title: 1, stock: 1, featured: 1 }
)
.sort({ _id: 1 })
.toArray();
print(EJSON.[{"_id":"book-design","title":"数据建模的艺术","stock":8},{"_id":"book-mongodb","title":"MongoDB 从入门到实践","stock":19,"featured":true}]结果清楚展示了两个不同命中原因。投影不会为不存在的 featured 补值,因此第一本文档中没有这个字段。
当 $or 分支越来越多时,先问它是否真的表达了同一个业务集合。把互不相关的条件堆进一个查询,通常比拆成两个清楚的操作更难维护,也更难设计索引。
对数组字段使用一个标量条件时,只要数组中至少一个元素相等就能命中。例如 { tags: "后端" } 会匹配包含“后端”的标签数组。
$all 更严格:数组必须包含列出的每个值,但不要求顺序相同,也允许还有其他元素。它适合“同时拥有这些标签”的条件。
查找同时拥有 “MongoDB” 和 “后端” 标签的书:
const taggedBooks = db.books
.find(
{ tags: { $all: ["MongoDB", "后端"] } },
{ _id: 1, title: 1, tags: 1 }
)
.toArray();
print(EJSON.stringify(taggedBooks));[{"_id":"book-mongodb","title":"MongoDB 从入门到实践","tags":["MongoDB","后端"]}]只有一本书同时包含两个标签。book-node 虽然包含“后端”,但没有 “MongoDB”,因此不会命中 $all。
点号路径可以进入嵌套文档和文档数组。addresses.city 表示检查 addresses 数组里每个元素的 city。MongoDB 会在任意元素命中时返回整个顾客文档。
const hangzhouCustomers = db.customers
.find(
{ "addresses.city": "杭州" },
{ _id: 1, name: 1, "addresses.city": 1 }
)
.toArray();
print(EJSON.stringify(hangzhouCustomers));[{"_id":"customer-zhou","name":"周雨","addresses":[{"city":"杭州"}]}]顾客文档被命中,投影只保留地址中的 city 字段。点号路径没有改变数据库中的原文档,只影响这次查询的匹配与返回形状。
订单的 items 是文档数组。如果我们要求“同一个明细既是 MongoDB 书,数量又至少为 2”,应使用 $elemMatch:
{
items: {
$elemMatch: {
bookId: "book-mongodb",
quantity: { $gte: 2 }
}
}
}如果只写 items.bookId 和 items.quantity 两个独立条件,在包含多个明细的订单中,它们可能分别由不同数组元素满足,从而产生并非业务本意的匹配。
const bulkMongoOrders = db.orders
.find(
{
items: {
$elemMatch: {
bookId: "book-mongodb",
quantity: { $gte: 2 }
}
}
},
{ _id: 1, customerId: 1, status: 1, items: 1 }
)
.toArray();
print(EJSON.stringify(bulkMongoOrders));[{"_id":"order-1003","customerId":"customer-lin","status":"shipped","items":[{"bookId":"book-mongodb","title":"MongoDB 从入门到实践","unitPrice":89,"quantity":2}]}]只有 order-1003 的同一条明细同时满足书籍标识与数量条件。
投影的主要价值是减少无关数据传输,并把调用方真正依赖的字段写清楚。包含式投影使用 1,排除式投影使用 0;除 _id 外,不应在同一个普通投影里混用包含与排除。
数组还可以用 $slice 截取。下面只查看订单的第一条明细,不修改原数组。
const orderPreview = db.orders.findOne(
{ _id: "order-1001" },
{
_id: 1,
status: 1,
total: 1,
items: { $slice: 1 }
}
);
print(EJSON.stringify(orderPreview));{"_id":"order-1001","status":"paid","items":[{"bookId":"book-mongodb","title":"MongoDB 从入门到实践","unitPrice":89,"quantity":1}],"total":158}数据库中的订单仍有两条明细;这次结果只返回第一条。投影是读取视图,不是更新操作。
分页首先是排序问题。只按 price 排序时,同价书籍之间没有固定先后;数据增长后,它们可能跨页移动或重复出现。把唯一 _id 作为第二排序键,就能为每条文档建立稳定位置:
{ price: 1, _id: 1 }这表示价格升序,同价时 _id 升序。后面的游标条件必须与这个顺序保持一致。
先查看完整有序列表:
const orderedBooks = db.books
.find(
{ published: true },
{ _id: 1, title: 1, price: 1 }
)
.sort({ price: 1, _id: 1 })
.toArray();
print(EJSON.stringify(orderedBooks));[{"_id":"book-web","title":"现代 Web 基础","price":59},{"_id":"book-node","title":"Node.js 项目开发","price":69},{"_id":"book-design","title":"数据建模的艺术","price":79},{"_id":"book-mongodb","title":"MongoDB 从入门到实践",这条序列是后面两种分页方式的共同基准。
skip(n).limit(m) 容易理解:跳过前 n 条,再取 m 条。它适合后台列表和偏移量较小的页面;偏移量很大时,数据库仍需走过前面的结果,成本会随偏移增长。
我们把每页大小设为 2,第二页需要跳过前 2 条。
const secondPageByOffset = db.books
.find(
{ published: true },
{ _id: 1, title: 1, price: 1 }
)
.sort({ price: 1, _id: 1 })
.skip(2)
.limit(2)
.toArray();
print(EJSON[{"_id":"book-design","title":"数据建模的艺术","price":79},{"_id":"book-mongodb","title":"MongoDB 从入门到实践","price":89}]结果与完整序列的第三、第四条一致。这个方法返回正确,但还不是大偏移列表的理想方案。
游标式分页不说“跳过多少条”,而是说“从上一页最后一个排序键之后继续”。第一页最后一本是:
price = 69, _id = book-node对于 { price: 1, _id: 1 } 的升序,下一页条件是:价格大于 69,或者价格仍等于 69 但 _id 大于 book-node。第二个分支处理同价数据,不能省略。
const cursor = {
price: 69,
id: "book-node"
};
const secondPageByCursor = db.books
.find(
{
published: true,
$or: [
{ price: { $gt: cursor.price } },
{
price: cursor.price,
_id: { $gt: cursor.id }
}
]
},
{ _id: 1, title: 1, price: 1 }
[{"_id":"book-design","title":"数据建模的艺术","price":79},{"_id":"book-mongodb","title":"MongoDB 从入门到实践","price":89}]它与 skip(2) 得到同一页,但查询表达的是一个范围,而不是越来越大的偏移。数据量增长后,还要为 { published: 1, price: 1, _id: 1 } 这一访问模式设计索引;索引部分会用 explain 验证,而不是仅凭字段顺序猜测。
游标值必须来自上一页最后一条真实结果,并同时携带所有排序键。只传 price: 69 会漏掉或重复同价书籍;修改排序方向时,比较符号也要随之调整。
纸舟书店想找出“已发布、库存至少 10、本身带有后端标签”的书,并只返回标题与库存。先自己写过滤器,再展开答案。
三个不同字段放在同一个过滤器中时默认是逻辑 AND;对数组字段使用标量 "后端",表示数组中至少包含这个值。结果按库存降序,同库存时用 _id 固定顺序。
先在自己的 shell 中完成查询。需要核对时展开参考命令:
执行结果:
[{"title":"MongoDB 从入门到实践","stock":19},{"title":"Node.js 项目开发","stock":15}]两本书都已发布、库存不少于 10,并且标签数组包含“后端”。book-design 与 book-web 的标签不匹配,因此没有进入结果。
这一节已经覆盖纸舟书店的核心读取路径:范围与类别组合、逻辑 OR、数组 $all、嵌套点号路径、同元素 $elemMatch、投影以及两种分页。下一节会把重点转向写入并发:怎样用原子操作避免库存丢失,怎样安全批量写入和删除。