56、MongoDB查询操作:查询过滤器、投影、排序、分页(skip/limit)、正则查询

MongoDB 的查询,说白了就是跟数据库要数据。但怎么要得准、要得快,这里头门道不少。我最早用 MongoDB 时,上来就是 find({}) 一把梭,结果数据量一上来,客户端直接卡死。嗯,后来才老老实实把查询过滤器、投影、排序、分页这些基本功练扎实了。

今天咱们就把 MongoDB 查询的五个核心操作捋一遍。你想想看,一个查询从发出到返回结果,中间经历了什么?其实就是:先过滤出符合条件的文档,再决定要哪些字段,然后排个序,最后切一段出来。正则查询则是过滤器的特殊形式,专门对付模糊匹配。

5.1 查询过滤器:精准定位数据

查询过滤器就是 find() 的第一个参数。它告诉 MongoDB:我只想要这些文档。

最基本的用法是等值匹配:

db.users.find({ "status": "active" })

但实际项目中,条件往往更复杂。比如我要查年龄在 18 到 30 之间、并且状态不是 "banned" 的用户:

db.users.find({
    "age": { "$gte": 18, "$lte": 30 },
    "status": { "$ne": "banned" }
})

这里 $gte 是大于等于,$lte 是小于等于,$ne 是不等于。MongoDB 提供了丰富的比较操作符:$gt$lt$in$nin 等等。

我的习惯: 写过滤器时,尽量把最严格的筛选条件放前面。比如先过滤掉大部分数据的条件,能显著提升查询性能。我在项目中遇到过,有人把 $exists 这种低选择性条件写在前面,结果索引根本用不上。

还有一个常用的操作符是 $elemMatch,专门对付数组字段。比如查一个订单里包含某个商品:

db.orders.find({
    "items": {
        "$elemMatch": {
            "product_id": "p1001",
            "quantity": { "$gte": 2 }
        }
    }
})

5.2 投影:只取需要的字段

投影是 find() 的第二个参数。为什么要用投影?说白了,就是省带宽、省内存。你想想看,一个文档有 50 个字段,你只需要其中 3 个,把整个文档传回来多浪费。

投影的写法很简单:

db.users.find(
    { "status": "active" },
    { "name": 1, "email": 1, "_id": 0 }
)

这里 1 表示包含该字段,0 表示排除。注意 _id 默认是包含的,如果你不需要它,必须显式排除。

我曾经踩过的坑: 投影里不能混用包含和排除,除非你排除的是 _id。比如 { "name": 1, "age": 0 } 会报错。要么全包含,要么全排除,这是 MongoDB 的硬性规定。

投影还能配合 $slice 操作符,只返回数组的一部分。比如只取评论的前 5 条:

db.articles.find(
    { "title": "MongoDB 入门" },
    { "comments": { "$slice": 5 } }
)

5.3 排序:让数据有序呈现

排序用 sort() 方法。1 表示升序,-1 表示降序。

db.users.find({ "status": "active" })
    .sort({ "created_at": -1, "name": 1 })

这条查询先按创建时间降序排,时间相同的再按名字升序排。排序字段的顺序很重要,它直接影响索引的使用效率。

我个人习惯,只要数据量超过几千条,排序字段一定要建索引。否则 MongoDB 就得在内存里做排序,一旦超过 32MB 的限制,它会直接报错。

核心原则: 排序字段尽量与查询过滤器的字段组合成复合索引。比如上面的查询,如果经常用 status 过滤并按 created_at 排序,就应该建一个 { "status": 1, "created_at": -1 } 的索引。

5.4 分页:skip 和 limit 的配合

分页是 Web 应用里最常见的需求。MongoDB 用 skip() 跳过指定数量的文档,用 limit() 限制返回数量。

// 第 1 页,每页 20 条
db.users.find({ "status": "active" })
    .sort({ "created_at": -1 })
    .limit(20)
    .skip(0)

// 第 2 页
db.users.find({ "status": "active" })
    .sort({ "created_at": -1 })
    .limit(20)
    .skip(20)

看起来很简单对吧?但这里有个大坑:skip 的性能问题。skip 的实现方式是先扫描并丢弃前 N 条文档,再返回后面的。当页码很大时,比如 skip(10000),MongoDB 得扫描 10020 条文档,然后扔掉前 10000 条。这效率,你想想看。

我曾经踩过的坑: 有个后台管理系统,用户翻到第 500 页时,查询耗时从 20ms 飙升到 3 秒。后来我改用基于游标的分页方式,用 _id 或时间戳做范围查询,才彻底解决这个问题。

基于游标的分页示例:

// 第一页
db.users.find({ "status": "active" })
    .sort({ "_id": 1 })
    .limit(20)

// 第二页,传入上一页最后一个文档的 _id
db.users.find({
    "status": "active",
    "_id": { "$gt": last_id }
}).sort({ "_id": 1 }).limit(20)

这种方式无论翻到多少页,性能都稳定。唯一的代价是客户端需要记住上一页的最后一个 _id

5.5 正则查询:模糊匹配的利器

正则查询用 $regex 操作符。比如查所有名字里带 "张" 的用户:

db.users.find({ "name": { "$regex": "张", "$options": "i" } })

$options 里的 i 表示忽略大小写。其他常用选项还有 m(多行匹配)、x(忽略空白字符)。

正则查询虽然方便,但性能是个大问题。因为它通常无法使用索引。除非你的正则表达式是前缀匹配,比如 ^张,MongoDB 才能利用索引进行范围扫描。

我的建议: 如果业务中频繁需要模糊搜索,别用正则。考虑用 MongoDB 的文本索引(text index)或者引入 Elasticsearch。我在一个电商项目中,商品搜索从正则改成文本索引后,查询耗时从 800ms 降到了 30ms。

正则查询还支持直接写 JavaScript 正则表达式:

db.users.find({ "name": /^张/i })

这两种写法效果一样,但 $regex 写法更灵活,可以动态拼接模式串。

知识体系总览

下面这张图把 MongoDB 查询的五个核心操作串起来了。你可以看到,一个完整的查询流程就是:先过滤,再投影,然后排序,最后分页。正则查询是过滤器的特殊形式。

MongoDB 查询操作知识体系 db.collection.find() 查询过滤器 (Filter) 等值、比较、逻辑、元素、数组操作符 投影 (Projection) 字段包含/排除、$slice 数组截取 排序 (Sort) 升序(1)、降序(-1)、复合排序 分页 (Skip / Limit) skip(N) 跳过、limit(N) 限制数量 正则查询 (Regex) $regex、$options、前缀匹配优化 返回结果集

综合示例:一个完整的查询

最后,咱们把今天学的所有操作串起来,写一个实际场景的查询。假设我们要查一个电商平台的订单:

  • 只查已支付的订单
  • 金额在 100 到 1000 之间
  • 收货地址在 "北京" 的(模糊匹配)
  • 只需要订单号、金额、收货人姓名
  • 按支付时间降序排列
  • 每页 10 条,取第 3 页
db.orders.find(
    {
        "status": "paid",
        "total": { "$gte": 100, "$lte": 1000 },
        "address.city": { "$regex": "北京", "$options": "i" }
    },
    {
        "order_no": 1,
        "total": 1,
        "recipient.name": 1,
        "_id": 0
    }
).sort({ "paid_at": -1 })
 .skip(20)
 .limit(10)

这个查询把过滤器、投影、排序、分页、正则查询全用上了。你想想看,如果不用投影,每个订单文档可能带着几十个字段回来,网络传输量至少翻 5 倍。如果不用分页,一次返回几千条数据,客户端渲染都得卡半天。

嗯,MongoDB 查询的精髓就在这五个操作里。把它们用好,你的查询就能做到又快又准。下次遇到慢查询,别急着怪数据库,先看看你的过滤器有没有走索引,投影有没有省掉多余字段,分页有没有用 skip 翻到天荒地老。


公众号:蓝海资料掘金营,微信 deep3321