Learn
MongoDB/06-projection-sort-paging

投影、排序与分页

查询能不能跑通是一回事,能不能跑得快、返回得体是另一回事。这一章讲三件事:怎么只取需要的字段(投影)、怎么排序、以及怎么正确地分页——最后一个话题里藏着 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 })
⚠️32MB 限制针对的是排序中的文档总大小

即使最终 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 兜底

只要用于分页,排序键的最后一位就应该是 _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耗时
102 ms
100198012 ms
10001998095 ms
10000199980890 ms
10000019999809.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 类型,把「文档里能放什么」讲清楚 →