大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
投影与游标
这一课你将能做到
- 用投影(projection)精确控制返回字段,包括内嵌字段与数组切片
- 说清楚游标(cursor)的工作机制,用 sort/skip/limit 做分页,并说出深分页的替代方案
- 理解 read preference 的直觉,知道什么业务可以读从节点
为什么要学
「查出整条文档再让 Java 挑字段」是新手最常见的浪费:一条提交记录带着完整代码和全部用例输出可能几十 KB,列表页只需要 slug 和分数,100 条就是几 MB 的无效网络传输。投影让 MongoDB 在服务端就把字段裁好。
游标则决定了你怎么「消费」查询结果:一次性 toArray 百万条会打爆内存;skip(100000) 翻页会越来越慢直到超时。这一课教你正确地遍历与分页。
先建立图像
查询执行的两个维度:
find( filter, projection )
│ │
选哪些文档 每个文档留哪些字段
(行裁剪) (列裁剪)
游标:服务端只把结果分批(batch)发给客户端
┌────────┐ batch 1 (101条) ┌────────┐
│ server │ ──────────────────> │ client │ it / hasNext() 再取下一批
└────────┘ <──── getMore ───── └────────┘
投影规则速查:
{ name: 1, score: 1 } 只返回 name、score(以及默认的 _id)
{ name: 1, _id: 0 } 只返回 name,去掉 _id
{ code: 0, cases: 0 } 排除 code 和 cases,其余全返回
{ "cases.name": 1 } 只返回数组元素的 name 子字段
{ cases: { $slice: 3 } } 只返回数组前 3 个元素
铁律:除 _id 外,include 和 exclude 不能混用。 {name: 1, code: 0} 会报错。
跟着做
准备数据:
use codeforge
db.submissions.drop()
db.submissions.insertMany([
{ userId: 1024, lessonSlug: "mdbrm-intro", status: "accepted", score: 100,
code: "db.x.find()", cases: [{name:"c1",passed:true},{name:"c2",passed:true},{name:"c3",passed:false}],
submittedAt: new Date("2026-10-01") },
{ userId: 1024, lessonSlug: "mdbrm-crud", status: "accepted", score: 85,
code: "db.x.updateOne()", cases: [{name:"c1",passed:true},{name:"c2",passed:false}],
submittedAt: new Date("2026-10-04") },
{ userId: 2048, lessonSlug: "mdbrm-intro", status: "failed", score: 40,
code: "db.x.remove()", cases: [{name:"c1",passed:false}],
submittedAt: new Date("2026-10-03") }
])
步骤 1:基础投影
列表页只要概要:
db.submissions.find(
{ userId: 1024 },
{ lessonSlug: 1, status: 1, score: 1, submittedAt: 1, _id: 0 }
)
步骤 2:内嵌字段与数组切片
只看用例名字和第一个失败用例:
db.submissions.find(
{ userId: 1024 },
{ lessonSlug: 1, "cases.name": 1, "cases.passed": 1 }
)
// 只要数组前 2 个元素
db.submissions.find(
{ userId: 1024 },
{ lessonSlug: 1, cases: { $slice: 2 } }
)
// $slice 也支持 [skip, limit]:跳过前 1 个取 2 个
db.submissions.find({}, { cases: { $slice: [1, 2] } })
步骤 3:$elemMatch 投影(注意:和查询里的 $elemMatch 不同!)
只返回「第一个失败的用例」:
db.submissions.find(
{ userId: 1024 },
{ lessonSlug: 1, cases: { $elemMatch: { passed: false } } }
)
投影里的 $elemMatch 不影响哪些文档被选中,只裁剪数组——返回的 cases 只含第一个匹配元素。
步骤 4:排序 + 分页
按提交时间倒序,每页 2 条:
// 第 1 页
db.submissions.find({ userId: 1024 }).sort({ submittedAt: -1 }).limit(2)
// 第 2 页(skip 方案,小数据量可用)
db.submissions.find({ userId: 1024 }).sort({ submittedAt: -1 }).skip(2).limit(2)
排序规则:-1 降序、1 升序;多字段 {score: -1, submittedAt: -1} 从左到右生效。
步骤 5:深分页的正确姿势 —— 游标式(seek)分页
skip 的本质是「扫描并丢弃前 N 条」,第 10 万页要丢 200 万条。用上一页的最后一条做锚点:
// 第 1 页
let page1 = db.submissions.find({ userId: 1024 })
.sort({ submittedAt: -1, _id: -1 }).limit(2).toArray()
let last = page1[page1.length - 1]
// 第 2 页:条件锚定「比上一页最后一条更早」
db.submissions.find({
userId: 1024,
$or: [
{ submittedAt: { $lt: last.submittedAt } },
{ submittedAt: last.submittedAt, _id: { $lt: last._id } }
]
}).sort({ submittedAt: -1, _id: -1 }).limit(2)
_id 做 tie-breaker 防止同一时间戳的多条数据翻页时丢失/重复。时间戳 + _id 的联合条件对应复合索引(见索引课),百万级数据翻页也是常数时间。
步骤 6:游标手动遍历
const cursor = db.submissions.find({ status: "accepted" }).batchSize(2)
while (cursor.hasNext()) {
const doc = cursor.next()
print(doc.lessonSlug, doc.score)
}
cursor.close()
驱动层(Java/Node)都是这个模型:游标惰性取批次,遍历完自动关闭。应用层切勿对大结果集 .toArray()。
步骤 7:read preference 直觉
// 读偏好:数据看板类可以读从节点,减轻主节点压力
db.submissions.find({ status: "accepted" })
.readPref("secondaryPreferred")
注意从节点有复制延迟(通常毫秒级),「我刚提交完立刻刷新看到结果」这种读己之写场景必须读主节点(默认 primary)。
讲清楚要点
- 投影默认带
_id:不想要必须显式_id: 0,这是最常见的小意外。 - include/exclude 不可混用(
_id除外):{a: 1, b: 0}报错。想清楚是「白名单」还是「黑名单」。 - 投影不省磁盘 IO,省的是网络与内存(覆盖查询除外,见索引课):MongoDB 仍可能读整条文档再裁剪,但传输和序列化成本大降。
- sort 无索引 = 内存排序,超过 32MB(内部排序上限)直接报错。排序字段必须有索引支撑,或 limit 小结果集。
- skip 是 O(N) 的:页码越深越慢。深翻页用 seek 分页(锚点条件),无限滚动加载场景尤其必须。
- 游标有生命周期:服务端游标默认闲置 10 分钟(cursorTimeoutMillis)被回收;遍历大集合时别让单批处理耗时过久,否则报
CursorNotFound。 - 数组投影三件套:
$slice(按位置切)、$elemMatch(按条件取第一个)、$位置投影({"cases.$": 1}返回查询条件匹配的第一个元素)。
你可能会懵的地方
- 投影能让查询更快吗? 对速度提升有限(除非覆盖查询),主要收益在网络传输和应用内存。但把它当纪律:列表接口永远只取列表需要的字段。
limit(1)和findOne()一样吗? findOne 等价于find().limit(1)且返回文档而非游标。性能上 limit(1) 让服务端找到一条就停,很重要。- 为什么排序后再 skip 翻页会丢数据?
排序键不唯一(比如多条 submittedAt 相同)时,翻页边界不稳定。解法就是步骤 5 的 tie-breaker:排序键 +
_id联合。 - tailable cursor 是什么? 用在 capped collection(固定大小集合)上的「持续游标」,数据来了就继续读,类似 tail -f。oplog 就是它实现的,普通集合用不了。
- batchSize 调大好还是调小好? 大:减少网络往返;小:首响应快、内存压力小。驱动默认通常 101 条首批。流式处理场景不必动它,只有明确瓶颈才调。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 列表接口不投影,整条文档全返回 | 网络/内存浪费,泄露内部字段 | 第二参数白名单投影 |
{name: 1, code: 0} 混用 include/exclude | 直接报错 | 二选一,_id 除外 |
深翻页 skip(500000).limit(20) | 扫描丢弃 50 万条,越来越慢 | seek 分页(锚点 + _id tie-breaker) |
大结果集 .toArray() | 应用内存爆掉 | 游标迭代逐批处理 |
| 无索引字段排序 + 大数据量 | 超 32MB 内存排序报错 | 给排序键建索引 |
卡点急救
- 报
Sort exceeded memory limit:排序无索引。临时验证加.allowDiskUse()(聚合用得多),根治是建排序索引。 - 遍历中途
CursorNotFound:游标闲置超时。缩短每批处理时间,或noCursorTimeout()(用完必须手动 close,否则泄露)。 - 投影里数组返回是空的:检查是不是
$elemMatch条件没匹配任何元素——此时该字段返回空数组或不返回,不是查询失败。
面试常问
- MongoDB 深分页怎么做?
skip/limit 只适合浅分页;深分页用 seek 方式:上一页最后一条的排序键值作为下一页的查询条件,配合
(排序键, _id)复合索引,任何页都是常数复杂度。 - 投影和覆盖查询的关系? 如果查询条件和投影字段全部包含在某索引里,MongoDB 可以直接从索引返回结果,完全不读文档(Covered Query),这时投影才带来真正的查询加速。
- 为什么 MongoDB 的 find 返回游标而不是数组? 结果集可能远大于内存,游标让服务端按批次推送、客户端流式消费;同时也是惰性求值,limit(5) 时服务端不会傻算全部。
自测三问
{lessonSlug: 1, code: 0}这个投影合法吗?为什么?- 投影里的
$elemMatch和查询条件里的$elemMatch作用一样吗? - 为什么 skip 深分页慢?写出 seek 分页的核心思路。
小结一句话
投影管「取哪些列」、游标管「怎么流式取」、seek 分页管「翻到第 N 万页也不慢」——列表接口三板斧。
延伸
- 官方文档:Project Fields to Return from Query
- 官方文档:Cursor Methods
- 下一课:聚合管道——当 find 不够用时,用流水线做分组统计、多表关联、报表计算。