投影、排序与分页
查询能不能跑通是一回事,能不能跑得快、返回得体是另一回事。这一章讲三件事:怎么只取需要的字段(投影)、怎么排序、以及怎么正确地分页——最后一个话题里藏着 MongoDB 最常见的线上性能事故。
1. 投影:只取需要的字段
1.1 基本语法
find() 的第二个参数是投影文档,1 表示包含、0 表示排除:
// 只要用户名和城市
db.users.find({}, { username: 1, "profile.city": 1 })[
{ "_id": ObjectId("...201"), "username": "alice", "profile": { "city": "Beijing" } },
{ "_id": ObjectId("...202"), "username": "bob", "profile": { "city": "Shanghai" } }
]注意 _id 默认总是返回,要去掉必须显式排除:
db.users.find({}, { username: 1, "profile.city": 1, _id: 0 })[
{ "username": "alice", "profile": { "city": "Beijing" } },
{ "username": "bob", "profile": { "city": "Shanghai" } }
]1.2 包含与排除不能混用
db.users.find({}, { username: 1, email: 0 })MongoServerError: Cannot do inclusion on field email in exclusion projection规则很简单:一份投影要么是「白名单模式」(全是 1),要么是「黑名单模式」(全是 0)。唯一的例外是 _id,它可以在白名单模式里被设成 0。
两种模式的适用场景不同:
| 模式 | 写法 | 适合 |
|---|---|---|
| 白名单 | username: 1, age: 1 | 接口返回,字段可控、新增字段不会意外泄露 |
| 黑名单 | password: 0, secret: 0 | 内部脚本,只想去掉几个大字段 |
用黑名单投影时,将来给集合加一个 internalNote 字段,它会自动出现在 API 响应里。白名单模式没有这个风险,这是安全上的默认选择。
1.3 为什么投影很重要
投影不只是「返回少一点」,它直接影响性能:
不投影:磁盘/缓存读整个文档 → 序列化整个文档 → 网络传输整个文档
投影: 仍读整个文档(除非覆盖查询)→ 只序列化需要的字段 → 只传需要的字段网络传输和 BSON 序列化的开销经常被低估。一个带 500 条评论内嵌数组的帖子文档可能有 200KB,列表页每页 20 条就是 4MB,而你只需要标题和作者。
更进一步,如果查询条件和投影字段全部被同一个索引覆盖,MongoDB 可以完全不读文档,直接从索引返回结果——这叫覆盖查询,第 9 章会详细讲。
2. 数组投影
普通投影会把整个数组返回。MongoDB 提供三个操作符做数组的部分投影。
2.1 $slice
// 只返回前 3 条评论
db.posts.find({ _id: 100 }, { title: 1, comments: { $slice: 3 } })
// 只返回最后 3 条
db.posts.find({ _id: 100 }, { title: 1, comments: { $slice: -3 } })
// 跳过 10 条,取 5 条(数组内分页)
db.posts.find({ _id: 100 }, { title: 1, comments: { $slice: [10, 5] } })这是「帖子详情页只加载前几条评论」的标准做法,避免把 500 条评论全传给客户端。
2.2 $elemMatch 投影
只返回数组中第一个满足条件的元素:
db.posts.find(
{ _id: 100 },
{ title: 1, comments: { $elemMatch: { author: "alice" } } }
)[
{
"_id": 100,
"title": "MongoDB 数组更新",
"comments": [ { "cid": 1, "author": "alice", "body": "好文", "likes": 3 } ]
}
]2.3 位置投影
// 返回 filter 中匹配到的那一个元素
db.posts.find({ _id: 100, "comments.cid": 2 }, { "comments.$": 1 })$elemMatch 投影的条件独立于 filter,位置投影的条件来自 filter,这是两者的区别。
$elemMatch 和 $ 投影都只返回第一个匹配元素,无法返回「全部满足条件的元素」。需要过滤出多个元素时,必须用聚合管道的 $filter 表达式(第 10 章),或者在应用层过滤。
2.4 聚合里的 $filter(补充)
db.posts.aggregate([
{ $match: { _id: 100 } },
{ $project: {
title: 1,
hotComments: { $filter: {
input: "$comments",
as: "c",
cond: { $gte: ["$$c.likes", 3] }
} }
} }
])这才是「返回所有点赞数大于等于 3 的评论」的正确写法。
3. 排序
db.posts.find().sort({ views: -1 }) // 降序
db.posts.find().sort({ views: -1, createdAt: 1 }) // 多字段
db.users.find().sort({ "profile.city": 1, age: -1 }) // 内嵌字段1 是升序,-1 是降序,多字段按书写顺序依次比较,语义和 SQL 的 ORDER BY 完全一致。
3.1 内存排序的 32MB 限制
如果排序字段没有索引,MongoDB 必须把结果集加载到内存排序,而这块内存有硬上限:
MongoServerError: Executor error during find command :: caused by ::
Sort exceeded memory limit of 33554432 bytes, but did not opt in to external sorting.解决方案有两个,优先选第一个:
// 方案一(推荐):建索引,让排序走索引,零内存开销
db.posts.createIndex({ views: -1 })
// 方案二:聚合管道里允许落盘(慢,但不会失败)
db.posts.aggregate([ { $sort: { views: -1 } } ], { allowDiskUse: true })即使最终 limit(10),如果排序前有 10 万个文档要参与比较,占用的仍是这 10 万个文档的大小。加了 limit 之后 MongoDB 会用 Top-K 优化只保留 K 个,但前提是有 limit;没有 limit 的大结果集排序几乎必然撞墙。
3.2 排序如何利用索引
索引是有序结构,所以只要排序字段是索引的前缀,就能直接顺序读取索引,不需要额外排序步骤:
db.posts.createIndex({ authorId: 1, createdAt: -1 })
// 能走索引排序
db.posts.find({ authorId: "alice" }).sort({ createdAt: -1 }) ✔
// 方向全部相反也可以(索引可以反向扫描)
db.posts.find({ authorId: "alice" }).sort({ createdAt: 1 }) ✔
// 方向不一致就不行
db.posts.find().sort({ authorId: -1, createdAt: -1 }) ✘ 需要内存排序第 9 章的 ESR 规则会把这件事讲透。
3.3 排序的稳定性问题
MongoDB 的排序不保证稳定:两个文档排序键相同时,返回顺序是不确定的,而且不同次查询可能不同。这在分页时会导致「第一页看到的文档,第二页又出现一次」。
// 不稳定:如果有多篇帖子 views 都是 100
db.posts.find().sort({ views: -1 }).skip(20).limit(10)
// 稳定:加一个唯一字段做 tie-breaker
db.posts.find().sort({ views: -1, _id: 1 }).skip(20).limit(10)只要用于分页,排序键的最后一位就应该是 _id(或任何唯一字段)。这是零成本的保险,能消灭一整类「数据重复/丢失」的诡异 bug。
4. 分页:skip/limit 与它的代价
4.1 传统分页
const pageSize = 20
const page = 3
db.posts.find().sort({ createdAt: -1, _id: 1 })
.skip((page - 1) * pageSize)
.limit(pageSize)对应 SQL 的 LIMIT 20 OFFSET 40。前几页很快,问题出在深页。
4.2 深分页为什么慢
skip(n) 不是「跳过」,而是「取出来再丢掉」:
请求第 5000 页(skip 99980, limit 20):
索引扫描 ──▶ 读取第 1 个文档 ──▶ 丢弃
读取第 2 个文档 ──▶ 丢弃
...
读取第 99980 个 ──▶ 丢弃 ← 99980 次无用功
读取第 99981 个 ──▶ 返回
...
读取第 100000 个 ─▶ 返回
实际耗时与页码成正比,第 5000 页可能要几秒在分片集群里更糟:mongos 必须从每个分片都取 skip + limit 条数据回来合并排序再丢弃,放大倍数等于分片数。
实测一个 1000 万文档的集合:
| 页码 | skip | 耗时 |
|---|---|---|
| 1 | 0 | 2 ms |
| 100 | 1980 | 12 ms |
| 1000 | 19980 | 95 ms |
| 10000 | 199980 | 890 ms |
| 100000 | 1999980 | 9.2 s |
4.3 游标分页(keyset pagination)
思路:不记「跳过多少条」,而是记「上一页最后一条是什么」,把它变成一个范围查询。
// 第一页
const first = db.posts.find()
.sort({ _id: -1 })
.limit(20)
.toArray()
const lastId = first[first.length - 1]._id
// 下一页:从 lastId 继续
db.posts.find({ _id: { $lt: lastId } })
.sort({ _id: -1 })
.limit(20)无论第几页,索引都能直接定位到起点,耗时恒定在毫秒级。
游标分页:
索引 B 树 ──▶ 直接 seek 到 lastId ──▶ 顺序读 20 条 ──▶ 返回
(O(log N)) (O(20))
与页码无关,永远这么快4.4 按非唯一字段做游标分页
如果排序键不唯一(比如 createdAt 或 views),需要用复合游标:把排序键和 _id 一起作为断点。
// 上一页最后一条:views = 300, _id = ObjectId("...abc")
db.posts.find({
$or: [
{ views: { $lt: 300 } },
{ views: 300, _id: { $gt: ObjectId("665f1a2b3c4d5e6f708192ab") } }
]
})
.sort({ views: -1, _id: 1 })
.limit(20)逻辑是「views 更小的,或者 views 相同但 _id 更大的」。为了让它走索引,需要建立:
db.posts.createIndex({ views: -1, _id: 1 })实践中通常会把游标编码成一个不透明的字符串返回给前端:
// 编码
const cursor = Buffer.from(JSON.stringify({ v: 300, i: lastId.toString() })).toString("base64")
// 前端下次请求带上 cursor,服务端解码后构造上面的 $or 查询4.5 两种分页方案对比
| 维度 | skip/limit | 游标分页 |
|---|---|---|
| 深页性能 | 随页码线性劣化 | 恒定 |
| 能否跳页 | 可以,直接跳第 N 页 | 不行,只能上/下一页 |
| 能否显示总页数 | 可以(但 count 本身也慢) | 通常不显示 |
| 数据实时变化时 | 会重复/漏数据 | 稳定 |
| 分片集群 | 放大严重 | 无放大 |
| 实现复杂度 | 极简 | 中等 |
很多事故是这样发生的:接口用 skip/limit 实现,页面上只有「下一页」按钮所以从没超过第 10 页;某天爬虫直接构造 page=50000 请求,数据库 CPU 打满。一定要在接口层对页码设上限(比如最多 100 页),或者干脆改成游标分页。
产品经理要「共 12345 条」时,先确认是不是真的需要精确值。countDocuments 在大集合上和深分页一样慢。可选方案:用 estimatedDocumentCount() 显示近似值、只显示「100+」、或者用单独的计数器文档配合 $inc 维护。
5. 综合示例:内容社区的信息流接口
// 首页信息流:按发布时间倒序,游标分页,只取列表页需要的字段
function feed(beforeId, size = 20) {
const filter = { status: "published" }
if (beforeId) filter._id = { $lt: beforeId }
return db.posts.find(filter, {
title: 1,
authorId: 1,
"stats.likes": 1,
"stats.comments": 1,
coverUrl: 1,
createdAt: 1,
comments: { $slice: 2 } // 预览两条热评
})
.sort({ _id: -1 })
.limit(size)
.toArray()
}配套索引:
db.posts.createIndex({ status: 1, _id: -1 })一、给 posts 插入 5 万条测试数据(用 mongosh 循环 + bulkWrite),分别测量第 1 页、第 100 页、第 1000 页 skip/limit 的耗时,把结果记录成表格;二、把同样的分页改成基于 _id 的游标分页,重新测量,对比差距;三、实现「按点赞数倒序」的游标分页,注意点赞数不唯一,需要复合游标;四、写一个查询,返回帖子标题加上「点赞数大于等于 5 的全部评论」,说明为什么不能用 $elemMatch 投影。
小结
- 投影的包含与排除不能混用,
_id是唯一例外;对外接口一律用白名单 $slice做数组截断,$elemMatch与$投影只能返回一个元素,要多个用聚合的$filter- 无索引排序有 32MB 内存上限,正解是建索引而不是开
allowDiskUse - 排序不稳定,分页排序键末尾永远补一个
_id skip(n)是「取出再丢弃」,深分页耗时随页码线性增长,分片下还要乘以分片数- 游标分页把分页变成范围查询,耗时恒定,代价是不能跳页
- 下一章讲 BSON 类型,把「文档里能放什么」讲清楚 →