MongoDB(二):索引、查询优化与聚合管道
MongoDB(二):索引、查询优化与聚合管道
导语:MongoDB 的性能问题 90% 出在「索引没建对 / 没走索引 / 返回了不必要的字段」。本篇覆盖索引类型全览、复合索引的最左前缀与 ESR 规则、索引失效清单、
explain的正确读法、慢查询定位,以及聚合管道的常用阶段与优化技巧,共 18 题。这一部分与 MySQL 索引篇思路相通但细节不同,建议对照复习。
一、索引类型与原理
1. MongoDB 支持哪些索引类型?(必问)
答:
| 类型 | 创建方式 | 用途与要点 |
|---|---|---|
| 单字段索引 | createIndex({ name: 1 }) | 最基础;1 升序、-1 降序 |
| 复合索引 | createIndex({ a: 1, b: -1 }) | 多条件查询,字段顺序决定能否命中 |
| 多键索引(Multikey) | 对数组字段建索引自动成为多键 | 数组每个元素都会生成索引项 |
| 唯一索引 | { unique: true } | 约束唯一;_id 索引天然唯一且不可删 |
| 部分索引(Partial) | { partialFilterExpression: {...} } | 只索引符合条件的文档(更小、更快) |
| 稀疏索引(Sparse) | { sparse: true } | 只索引字段存在的文档 |
| TTL 索引 | { expireAfterSeconds: n } | 到期自动删除(会话、验证码、日志) |
| 文本索引(Text) | createIndex({ title: "text" }) | 全文检索,每集合只能有一个文本索引 |
| 地理空间索引 | "2dsphere" / "2d" | 附近的人、范围检索 |
| 哈希索引 | { field: "hashed" } | 等值查询 + 哈希分片键专用,不支持范围 |
| 通配符索引(Wildcard) | createIndex({ "$**": 1 }) | 4.2+,字段不固定的查询;不能作分片键 |
| 隐藏索引(Hidden) | hideIndex() / unhideIndex() | 4.4+,只对查询计划隐藏,用来安全评估「删掉这个索引会怎样」 |
| 聚集集合索引(Clustered) | clusteredIndex(建集合时) | 5.3+,数据按 _id 聚集存储 |
// 常用索引创建示例
db.users.createIndex({ email: 1 }, { unique: true }) // 唯一
db.orders.createIndex({ userId: 1, createdAt: -1 }) // 复合
db.orders.createIndex({ expireAt: 1 }, { expireAfterSeconds: 3600 }) // TTL
db.users.createIndex({ name: 1 }, { partialFilterExpression: { vip: true } })// 部分索引
db.logs.createIndex({ "$**": 1 }) // 通配符
db.users.getIndexes() // 查看现有索引
db.users.dropIndex("email_1") // 删除索引上限:每个集合最多 64 个索引,复合索引最多 32 个字段。索引不是越多越好——写放大、内存占用、优化器选错索引都是代价。
2. 为什么说「没有索引的查询是灾难」?COLLSCAN 是什么?
答: MongoDB 的查询若没有可用索引,会执行 COLLSCAN(集合扫描):把集合中所有文档读出来逐个匹配。后果是:
- 磁盘 IO 与内存占用随集合规模线性增长;
- 并发稍高即出现 page fault 与排队,QPS 断崖式下跌;
- 一旦集合大于内存,性能会数量级下降。
explain 里出现 "stage": "COLLSCAN" 就说明「没走索引」,这是排查性能问题的第一个信号。
// 集合有 1000 万文档时,这条查询会扫描全部文档
db.orders.find({ userId: "U-1" }).explain("executionStats")
// 输出关注点:
// winningPlan.stage: "COLLSCAN" ← 没走索引
// executionStats.totalDocsExamined: 10000000
// executionStats.nReturned: 3
// 期望:IXSCAN,且 totalDocsExamined ≈ nReturned3. 复合索引的「最左前缀」原则是什么?(必问)
答: 与 MySQL 类似:复合索引 { a: 1, b: 1, c: 1 } 只能被「以 a 开头的连续前缀」高效利用。
| 查询条件 | 能否用上该索引 | 说明 |
|---|---|---|
{a} | ✅ | 用 a |
{a, b} | ✅ | 用 a + b |
{a, b, c} | ✅ | 全部用上 |
{b} / {c} / {b, c} | ❌ | 跳过最左字段 → 退化(可能仍走索引扫描,但无法定位,效率极低) |
{a, c} | ⚠️ | 只用 a 定位,c 只能在索引中过滤(无法用于范围裁剪) |
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })
db.orders.find({ userId: "U-1", status: "PAID" }) // ✅ 命中 userId+status
db.orders.find({ userId: "U-1", status: "PAID" }).sort({ createdAt: -1 }) // ✅ 命中全部,且排序也走索引
db.orders.find({ status: "PAID" }) // ❌ 跳过了最左的 userIdMongoDB 与 MySQL 的一个重要差异:MongoDB 里当查询条件缺少前缀字段时,仍有可能出现「IXSCAN 但
totalKeysExamined极大」的情况(索引全扫描),所以不能只看是不是 IXSCAN,还要看keysExamined / nReturned的比值。
4. 什么是 ESR 规则?复合索引字段应该怎么排序?(高频加分)
答: ESR = Equality(等值) → Sort(排序) → Range(范围)。复合索引的字段顺序应按这个优先级排列:
- E:先放等值匹配的字段(如
status: "PAID"、userId: "U-1"); - S:再放排序用到的字段(支持
sort走索引、避免内存排序); - R:最后放范围查询字段(
$gt/$lt/$in之外的区间条件)。
原因:等值字段能把索引定位到一段连续区间;排序字段紧跟其后可以「利用索引顺序直接输出」,省掉内存排序;范围字段一旦出现,其后的字段就无法再用于索引定位(只能过滤),所以放最后。
// 需求:查某用户已完成、金额 > 100 的订单,按下单时间倒序
db.orders.find({ userId: "U-1", status: "DONE", amount: { $gt: 100 } })
.sort({ createdAt: -1 })
// ❌ 错误顺序:范围字段在排序字段之前,排序只能走内存 SORT
db.orders.createIndex({ userId: 1, status: 1, amount: 1, createdAt: -1 })
// ✅ 正确顺序(E → S → R):userId、status 等值,createdAt 排序,amount 范围
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1, amount: 1 })记忆技巧:「等值打头,排序居中,范围压尾」。当排序字段与范围字段冲突时,优先满足「查询能定位 + 排序走索引」,因为内存排序的代价(尤其大结果集)远高于多过滤一点数据。
5. 多键索引(数组索引)有什么特殊之处?
答: 对数组字段建索引时,MongoDB 会为数组中的每个元素生成一个索引项,称为 Multikey 索引。
db.articles.createIndex({ tags: 1 })
// 匹配数组中的任一元素
db.articles.find({ tags: "mongodb" }) // ✅ 走索引
db.articles.find({ tags: { $in: ["mongodb", "java"] } })// ✅ 走索引
db.articles.find({ tags: { $all: ["mongodb", "java"] } })// ✅ 走索引(多个索引项求交)
// 精确匹配整个数组
db.articles.find({ tags: ["mongodb", "java"] }) // 需要整个数组相等(顺序也一致)关键限制(面试常问):
- 数组中的元素如果是文档,
{ "items.price": 1 }这种点号路径索引也是多键索引; - 同一个复合索引里,最多只能有一个数组字段(两个数组字段无法建立多键索引,会报错);
- 多键索引不能支持覆盖查询:因为一个文档对应多个索引项,索引里无法还原文档(
explain中会出现"isMultiKey": true); - 并行数组(parallel arrays)问题:
{ a: [1,2], b: ["x","y"] }上对a、b建复合索引时,$elemMatch才能表达「同一个元素同时匹配 a 与 b」的语义,普通条件会变成「跨元素组合匹配」。
// 数组元素是文档:注意用 $elemMatch 表达「同一元素内的多条件」
db.orders.find({ "items": { $elemMatch: { sku: "A-1", qty: { $gt: 1 } } } })6. 什么是覆盖查询(Covered Query)?
答: 当查询条件与返回字段全部都在索引中时,MongoDB 可以只读索引、不回表,这就是覆盖查询。它是 MongoDB 优化的重要手段(省一次随机 IO)。
db.users.createIndex({ name: 1, age: 1 })
// 覆盖查询:条件 name,投影只返回 name 与 _id(_id 默认在索引?—— 需要显式排除或包含)
db.users.find({ name: "张三" }, { _id: 0, name: 1, age: 1 })
.explain("executionStats")
// 判定标准:executionStats.totalDocsExamined == 0
// 且 winningPlan 中没有 FETCH 阶段两个易错点:
- 必须排除
_id(除非_id在索引中):查询默认返回_id,而它不在索引里就会回表; - 多键索引不能覆盖查询(原因见上一题)。
7. 如何判断一个索引建得好不好?
答: 看三个维度:
| 维度 | 说明 | 工具 |
|---|---|---|
| 选择性(Selectivity) | 字段值越分散越好(性别只有 3 个值 → 选择性差) | db.coll.distinct(field).length / countDocuments() |
| 命中情况 | keysExamined ≈ nReturned 最佳;比值悬殊说明索引没过滤掉多少数据 | explain("executionStats") |
| 使用频率 | 长期 accesses.ops == 0 的索引是纯负担 | db.coll.aggregate([{ $indexStats: {} }]) |
// 每个索引被使用的次数
db.orders.aggregate([{ $indexStats: {} }])
// 输出示例:{ name: "userId_1_status_1", accesses: { ops: 15230, since: ISODate(...) } }
// 计算字段选择性(比值越接近 1 越好)
const total = db.users.countDocuments()
const distinct = db.users.distinct("city").length
print("city 选择性:" + (distinct / total).toFixed(3))经验:低选择性字段放在复合索引的最后,或者用部分索引救场(例如只索引
status: "PENDING"的少量文档)。
二、索引使用与失效
8. 哪些写法会导致索引失效?(必备清单)
答:
| 写法 | 是否走索引 | 原因与替代方案 |
|---|---|---|
{ field: "x" }(类型一致) | ✅ | 正常 |
{ field: 123 } 但字段存的是字符串 "123" | ❌ | 类型不一致,最隐蔽的坑,务必保证驱动映射类型正确 |
{ field: /abc/ } | ❌ | 非前缀正则必须全量扫描 |
{ field: /^abc/ } | ✅ | 前缀匹配可走索引(区分大小写、不能带 i 选项) |
{ field: /^abc/i } | ❌ | 带 i 无法利用索引顺序 |
{ field: { $ne: "x" } } / $nin / $not | ⚠️ | 否定条件无法定位区间,通常退化为索引全扫描或 COLLSCAN |
{ field: { $exists: true } } | ⚠️ | 可走稀疏/普通索引但过滤性差;用稀疏索引或部分索引优化 |
{ $where: "..." } / $expr 中运算 | ❌ | JS 表达式/服务端计算,无法用索引 |
{ field: { $regex: "^a", $options: "i" } } | ❌ | 同上带 i |
{ a: 1, b: 1 } 而索引是 {b: 1} | ❌ | 缺少最左前缀 |
sort({ field: 1 }) 而索引是 { field: -1 } | ❌ | 排序方向必须与索引一致(或完全反向才行) |
{ $or: [ {a: 1}, {b: 1} ] } 中 b 无索引 | ⚠️ | 任一分支无索引都可能触发全集合扫描,需每个分支都能用索引 |
| 数组字段超过 1 个在复合索引中 | ❌ | 无法建立多键复合索引 |
// 类型不一致导致索引白建(高频线上事故)
db.users.insertMany([{ age: 18 }, { age: "18" }])
db.users.createIndex({ age: 1 })
db.users.find({ age: 18 }) // 只命中 Int 的文档
db.users.find({ age: "18" }) // 只命中 String 的文档
// 想两者都命中只能 $or 或统一类型(推荐统一类型 + 应用层强约束)9. 排序(sort)能用索引吗?
答: 能,但有条件:
- 排序字段必须是索引的连续前缀(与最左前缀一致);
- 排序方向必须与索引一致,或完全相反:
db.orders.createIndex({ a: 1, b: -1 })
db.orders.find().sort({ a: 1, b: -1 }) // ✅ 完全一致
db.orders.find().sort({ a: -1, b: 1 }) // ✅ 完全反向(反向扫描索引)
db.orders.find().sort({ a: 1, b: 1 }) // ❌ 部分反向 → 内存排序
db.orders.find().sort({ b: -1 }) // ❌ 跳过最左字段 → 内存排序explain中winningPlan出现SORT或SORT_KEY_GENERATOR就代表内存排序(受 100MB 内存限制,超限直接报错,需allowDiskUse);- 排序 + 范围字段共存时:范围条件之后的排序字段无法利用索引顺序(这就是 ESR 规则的由来)。
10. $or、$in、$regex 与索引的关系?
答:
$in:等价于多个等值查询,可以用索引(MongoDB 会做多次索引查找后合并),常用于「状态 IN (...)」;$or:如果每个分支都有可用索引,MongoDB 会分别执行再合并(OR阶段),可以走索引;只要有一个分支没索引,就可能退化为 COLLSCAN;$regex:只有左锚定的前缀匹配(/^abc/、区分大小写)能利用索引;/abc/、/^abc/i都不行。全文检索请用文本索引 +$text。
// $or 每个分支都命中索引(userId_1、orderNo_1 都存在)→ 可走索引
db.orders.find({ $or: [ { userId: "U-1" }, { orderNo: "1001" } ] })
// 优化技巧:能用 $in 就别用多个 $or 分支做等值匹配
db.orders.find({ status: { $in: ["PAID", "SHIPPED"] } }) // ✅ 走索引11. 唯一索引、稀疏索引、部分索引有什么区别?
答:
| 类型 | 约束范围 | 索引内容 | 典型场景 |
|---|---|---|---|
| 唯一索引 | 所有文档该字段不能重复 | 全部文档 | 手机号、邮箱、订单号 |
| 稀疏索引 | 只索引字段存在的文档 | 跳过「字段缺失」的文档 | 可选字段(部分文档没有 phone) |
| 部分索引 | 只索引满足过滤条件的文档 | 更少的文档 → 更小、更快 | 只索引「未完成」的订单 |
// 稀疏索引:字段缺失的文档不参与索引,也不占空间
db.users.createIndex({ phone: 1 }, { unique: true, sparse: true })
// 语义:phone 存在的文档必须唯一,没有 phone 的文档不受约束
// 部分索引(功能更强,4.x 推荐):只索引 status = PENDING 的文档
db.orders.createIndex(
{ createdAt: 1 },
{ partialFilterExpression: { status: "PENDING" } }
)
// 只有满足条件的文档进入索引,索引体积小很多;但查询必须带该条件才能命中此索引选择建议:只需「字段存在」用稀疏索引;需要「按任意条件过滤」用部分索引。注意:唯一索引 + 稀疏索引的组合不能与部分索引混用(同一个索引不能同时声明 sparse 与 partialFilterExpression)。
12. 索引创建与维护要注意什么?
答:
- 4.2 起索引构建统一使用优化流程,旧的前台/后台(
background)语义已淡化(选项仍被接受但被忽略)。大集合建索引用createIndex会占用资源,建议在低峰期执行; - 灰度删除索引:先用
hideIndex()隐藏,观察一段时间($indexStats使用量、慢查询)确认无影响再删除;隐藏索引对查询计划不可见,但仍会被写入维护,所以空间不会立刻释放; - 重建/压缩:删除并重建索引可消除碎片(也等价于
compact的效果之一); - 写放大:每个索引都会让写入变慢,
n个索引意味着一次插入要写n+1次结构。索引数量与写性能成反比; _id索引默认存在、唯一、不可删除。
db.orders.hideIndex("userId_1_status_1") // 隐藏,模拟删除
db.orders.unhideIndex("userId_1_status_1") // 恢复
db.orders.dropIndex("userId_1_status_1") // 确认无用后真正删除三、explain 与慢查询优化
13. explain 怎么看?哪些指标最关键?
答: MongoDB 的 explain 有三种模式:
| 模式 | 特点 |
|---|---|
queryPlanner(默认) | 只看选中的计划与候选计划,不真正执行 |
executionStats | 实际执行,给出扫描/返回数量与耗时(最常用) |
allPlansExecution | 执行所有候选计划并给出对比(排查「选错索引」时用) |
db.orders.find({ userId: "U-1", status: "PAID" }).sort({ createdAt: -1 })
.explain("executionStats"){
queryPlanner: {
winningPlan: {
stage: "FETCH", // 顶层阶段
inputStage: {
stage: "IXSCAN", // 走了索引
indexName: "userId_1_status_1_createdAt_-1",
isMultiKey: false,
keysExamined: 3
}
},
rejectedPlans: [ ... ] // 被淘汰的候选计划(可看优化器为何选它)
},
executionStats: {
nReturned: 3, // 返回文档数
executionTimeMillis: 0,
totalKeysExamined: 3, // 扫描索引键数
totalDocsExamined: 3, // 回表读取文档数
executionStages: { ... }
}
}解读三定律:
- 看
stage:出现COLLSCAN就是没走索引;出现SORT就是内存排序;IXSCAN+FETCH是正常回表查询; - 看比值:
totalKeysExamined / nReturned与totalDocsExamined / nReturned都应接近 1。若docsExamined远大于nReturned,说明索引过滤性差或缺少更多索引字段; - 看
rejectedPlans:优化器选错索引时,这里能看到「它其实考虑过更好的计划」,可用 hint 强制或用 索引优化 修正。
// 强制使用指定索引(临时验证,不要长期依赖)
db.orders.find({ userId: "U-1" }).hint({ userId: 1 }).explain("executionStats")常见执行阶段速查:
| 阶段 | 含义 |
|---|---|
COLLSCAN | 全集合扫描(最差) |
IXSCAN | 索引扫描(好) |
FETCH | 根据索引结果回表取文档 |
SORT / SORT_KEY_GENERATOR | 内存排序(超 100MB 报错,需 allowDiskUse) |
LIMIT / SKIP | 分页限制 |
PROJECTION | 投影(字段裁剪) |
COUNT_SCAN | 直接由索引完成计数 |
TEXT / GEO_NEAR_2DSPHERE | 文本/地理查询 |
OR / AND_SORTED | 多分支合并 |
SHARD_MERGE | 分片集群在 mongos 上合并各分片结果 |
14. 生产环境如何定位慢查询?
答:
① 慢查询日志 + Profiler
// 开启 profiler:记录 >100ms 的操作(1=只记慢操作,2=记录所有)
db.setProfilingLevel(1, { slowms: 100 })
// 查看最近的慢操作
db.system.profile.find({ millis: { $gt: 100 } })
.sort({ ts: -1 }).limit(10)
.projection({ op: 1, ns: 1, millis: 1, workingMillis: 1, planSummary: 1, "command.filter": 1 })
// 关闭
db.setProfilingLevel(0)
millisvsworkingMillis(8.0 起):millis是总耗时(含排队等待),workingMillis才是 MongoDB 真正处理的时间。慢查询里如果millis高但workingMillis低,说明瓶颈在排队(并发太高或锁等待),而不是查询本身慢——这是 8.0 之后排查的重要区分点。
② 实时查看正在执行的操作
db.adminCommand({ currentOp: 1, secs_running: { $gte: 2 } }) // 超过 2 秒的操作
db.currentOp({ "command.find": { $exists: true } }) // 只看 find③ 找「没用上索引」的查询
db.adminCommand({ currentOp: 1, planSummary: "COLLSCAN" }) // 正在做全表扫描的操作
db.system.profile.find({ planSummary: "COLLSCAN" }) // 历史全表扫描④ 看索引使用情况,清理无用索引
db.orders.aggregate([{ $indexStats: {} }])15. 优化一个慢查询的完整思路是什么?(场景题)
答: 按下面的顺序做,成本从低到高:
- 确认是否真的慢:用
explain("executionStats")看executionTimeMillis、docsExamined、nReturned,而不是凭感觉; - 消灭 COLLSCAN:给查询条件字段建索引(按 ESR 排列);如果涉及
$or,确保每个分支都有索引; - 减少扫描量:加索引字段让
docsExamined ≈ nReturned;考虑部分索引缩小索引体积; - 消灭内存排序:让
sort字段排在索引的等值字段之后; - 做覆盖查询:投影只返回必要字段,争取
docsExamined = 0; - 减少返回量:分页(游标分页代替
skip)、避免把大数组/大字段全量返回($slice、$project裁剪); - 拆分与预计算:热点统计结果用预聚合(写入时维护计数、或用
$merge做物化视图); - 架构层面:工作集超过内存时考虑加内存 / 分片 / 冷热数据分离(历史数据归档)。
// 优化前:COLLSCAN + 内存排序 + 返回全字段
db.orders.find({ userId: "U-1", status: { $ne: "CANCELLED" } }).sort({ createdAt: -1 })
// 优化后:建立 ESR 索引 + 只返回必要字段 + 用游标分页
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })
db.orders.find(
{ userId: "U-1", status: { $in: ["PAID", "SHIPPED"] } }, // 用 $in 代替 $ne
{ _id: 1, orderNo: 1, amount: 1 }
).sort({ createdAt: -1 }).limit(20)四、聚合管道
16. 聚合管道是什么?和 find、MapReduce 有什么区别?
答: 聚合管道(Aggregation Pipeline)把文档依次送入一系列阶段(stage),每个阶段对输入做变换后输出给下一个阶段,形如流水线。它相当于 MongoDB 的「SQL + GROUP BY + JOIN + 窗口函数」超集。
| 对比 | find | 聚合管道 | MapReduce |
|---|---|---|---|
| 能力 | 查询 + 投影 | 分组、统计、关联、展开、窗口、多路分支 | 任意 JS 计算 |
| 性能 | 最高(简单查询) | 好(能利用索引的阶段会下推) | 差,JS 引擎执行,分片下要在 mongos 汇总 |
| 状态 | 推荐 | 推荐(复杂分析) | 已弃用(官方建议一律用聚合管道替代;需要 JS 计算时用 $function/$accumulator) |
db.orders.aggregate([
{ $match: { status: "PAID" } }, // 过滤
{ $group: { _id: "$userId", total: { $sum: "$amount" }, cnt: { $sum: 1 } } }, // 分组聚合
{ $sort: { total: -1 } }, // 排序
{ $limit: 10 } // 取 Top10
])17. 常用聚合阶段有哪些?(附实战示例)
答:
| 阶段 | 作用 | 类比 SQL |
|---|---|---|
$match | 过滤文档 | WHERE / HAVING |
$project | 字段裁剪/计算/重命名 | SELECT |
$group | 分组聚合 | GROUP BY |
$sort | 排序 | ORDER BY |
$limit / $skip | 分页 | LIMIT / OFFSET |
$unwind | 把数组拆成多条 | 无(相当于炸开) |
$lookup | 关联其它集合 | LEFT JOIN |
$count | 计数 | COUNT(*) |
$facet | 一个管道并行多个子管道 | 多个查询合并 |
$bucket | 按区间分桶 | CASE WHEN + GROUP BY |
$setWindowFields | 窗口函数(5.0+) | OVER (PARTITION BY ...) |
$merge / $out | 结果写入集合(物化视图) | INSERT ... SELECT |
实战 1:$unwind + $group 统计商品销量
db.orders.aggregate([
{ $match: { status: "PAID" } },
{ $unwind: "$items" }, // 展开明细数组
{ $group: {
_id: "$items.sku",
qty: { $sum: "$items.qty" },
amount: { $sum: { $multiply: ["$items.price", "$items.qty"] } }
}},
{ $sort: { qty: -1 } },
{ $limit: 5 }
])实战 2:$lookup 关联(相当于 LEFT JOIN)
db.orders.aggregate([
{ $match: { _id: "order_1001" } },
{ $lookup: {
from: "users", // 关联的集合
localField: "userId", // 本集合字段
foreignField: "_id", // 被关联集合字段(务必有索引!)
as: "user" // 结果放在数组字段
}},
{ $unwind: { path: "$user", preserveNullAndEmptyArrays: true } }, // 数组转对象,保留无匹配
{ $project: { orderNo: 1, "user.name": 1, "user.city": 1 } }
])实战 3:$facet 一次查询同时拿到「总数 + 当前页 + 各状态统计」
db.orders.aggregate([
{ $match: { userId: "U-1" } },
{ $facet: {
total: [{ $count: "count" }],
pageData: [{ $sort: { createdAt: -1 } }, { $skip: 20 }, { $limit: 10 }],
byStatus: [{ $group: { _id: "$status", cnt: { $sum: 1 } } }]
}}
])实战 4:窗口函数 $setWindowFields(5.0+,算累计值/排名)
db.sales.aggregate([
{ $setWindowFields: {
partitionBy: "$region", // 按地区分组
sortBy: { date: 1 },
output: {
cumulative: { $sum: "$amount", window: { documents: ["unbounded", "current"] } },
rank: { $rank: {} }
}
}}
])18. 聚合管道的优化技巧与限制有哪些?(必问)
答:
① 尽早 $match / $project,把过滤和裁剪前置
// ❌ 先联表再过滤:$lookup 处理了全部订单
db.orders.aggregate([
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
{ $match: { status: "PAID" } }
])
// ✅ 先过滤:$lookup 只处理命中的少量订单(且 $match 可能命中索引)
db.orders.aggregate([
{ $match: { status: "PAID" } },
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }
])② 内存与磁盘限制
- 单个阶段(尤其
$group、$sort)默认内存上限 100MB,超出报错,需显式{ allowDiskUse: true }(会写临时文件,变慢); $group的_id基数越大,内存占用越高,尽量先$match缩小数据量;- 结果集不要用
$out/$merge反复全量重写,增量场景用$merge配合whenMatched。
③ 索引与下推
- 管道开头的
$match、$sort可以利用索引(这正是「前置」的价值); $lookup的foreignField必须有索引,否则每次关联都是全表扫描;$group之后的操作无法再用索引。
④ 分片集群下的注意点
- 尽量让管道开头包含分片键,把查询定向到单个/少数分片;
- 不含分片键的聚合会广播到所有分片,再由 mongos 合并(
SHARD_MERGE),开销大; $lookup关联分片集合的能力在 5.1+ 才支持(此前 foreign 集合必须未分片);- 8.0 起,分片集合的事务中也可以使用
$lookup。
⑤ 其他限制
$out/$merge必须是管道的最后一个阶段;$facet内部子管道不能使用$out/$merge,且不能超过 100MB 内存($facet各子管道共享限制);- 聚合结果文档同样受 16MB 限制(
$group后单个结果文档不能超限)。
// 大结果集聚合:显式允许落盘
db.logs.aggregate([
{ $match: { ts: { $gte: new Date("2026-09-01") } } },
{ $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$ts" } }, cnt: { $sum: 1 } } },
{ $sort: { _id: 1 } }
], { allowDiskUse: true })