新仪表盘在 dev 用 1 万文档跑得飞快。生产 500 万文档同一段聚合要 30 秒,把主节点 CPU 顶到天上。经典嫌疑:$lookup 的关联字段在外集合上没索引、$match 放在了 $group 后面、中间文档大得离谱把 stage 内存搞爆。修法:先用 explain('executionStats') 读管线计划、关联字段两边都加索引、$match 提到最前面、用 $facet 把独立分支并行化。
常见原因
按踩坑频率排序。
1. $lookup 外字段没索引
$lookup 默认对外集合做全表扫描,除非 localField → foreignField 的关联被索引。
怎么判断:explain('executionStats') 里 $lookup 阶段是 COLLSCAN。
2. $match 放在 $lookup 或 $group 之后
管线先把整集合读出来、join 完、再过滤。应该反过来,先过滤。
怎么判断:管线第一段不是 $match 或 $geoNear。
3. 中间文档巨大
$group 配 $push 把所有匹配文档拼成数组。某一组有 50 万条,单文档就超过 16 MB BSON 上限,或者撞上 100 MB stage 内存上限。
怎么判断:报 BSONObjectTooLarge 或 Exceeded memory limit for $group。allowDiskUse: true 能跑但慢。
4. 索引选择性不够
只索引了 status。查询过滤 status + tenant_id。还是 Postgres 那套——选择性最高的字段在前——复合 {tenant_id: 1, status: 1, created_at: -1} 才香。
怎么判断:nReturned 远小于 totalDocsExamined。
5. 排序走内存因为索引对不上
$match 后跟 $sort,前缀字段不一致,索引用不上。MongoDB 退化成内存排序、超过 100 MB 直接放弃。
怎么判断:explain 里 SORT 阶段 inMemory: true 且 usedDisk: true。
最短修复路径
Step 1: 读计划
db.orders.aggregate([
{ $match: { tenant_id: "acme", status: "paid", created_at: { $gte: ISODate("2026-05-01") } } },
{ $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user" } },
{ $unwind: "$user" },
{ $group: { _id: "$user.country", revenue: { $sum: "$amount" } } },
{ $sort: { revenue: -1 } },
], { allowDiskUse: true }).explain("executionStats");
每一阶段都看:
IXSCAN还是COLLSCANtotalDocsExamined跟nReturned的比(越接近 1 越好)executionTimeMillisEstimate$lookup阶段嵌套的lookup.queryPlanner
Step 2: 加对的复合索引
经验法则(Equality、Sort、Range):
// orders:tenant_id、status 等值,created_at 范围
db.orders.createIndex({ tenant_id: 1, status: 1, created_at: -1 });
// users:_id 默认有索引。如果 localField/foreignField 是别的字段,记得索引外字段
db.users.createIndex({ _id: 1 });
非 _id 字段的 lookup:
db.events.createIndex({ user_id: 1 });
db.users.aggregate([{ $lookup: { from: "events", localField: "_id", foreignField: "user_id", as: "events" } }]);
Step 3: 把 $match 提到最前面
调顺序,选择性最高的过滤放第一阶段。
db.orders.aggregate([
// 1. 先狠狠过滤
{ $match: {
tenant_id: "acme",
status: "paid",
created_at: { $gte: ISODate("2026-05-01"), $lt: ISODate("2026-06-01") }
} },
// 2. 只 project 用得到的字段(中间小一点)
{ $project: { user_id: 1, amount: 1 } },
// 3. lookup 走索引外字段
{ $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user", pipeline: [{ $project: { country: 1 } }] } },
{ $unwind: "$user" },
{ $group: { _id: "$user.country", revenue: { $sum: "$amount" } } },
{ $sort: { revenue: -1 } },
]);
$lookup 里嵌套 pipeline:(MongoDB 5.0+)能在外集合上只取要的字段。中间文档小很多。
Step 4: 用 $facet 并行分支
仪表盘要三个独立 rollup?放一个聚合里用 $facet。
db.orders.aggregate([
{ $match: { tenant_id: "acme", created_at: { $gte: ISODate("2026-05-01") } } },
{ $facet: {
byCountry: [ { $group: { _id: "$country", n: { $sum: 1 } } } ],
byStatus: [ { $group: { _id: "$status", n: { $sum: 1 } } } ],
topUsers: [ { $group: { _id: "$user_id", n: { $sum: 1 } } }, { $sort: { n: -1 } }, { $limit: 10 } ],
} },
]);
输入集合扫一遍,分支并行跑。注意 100 MB stage 上限,分支重的话开 allowDiskUse: true。
Step 5: 避开巨大的 $push
别再 $push 后接 $slice,用 $topN/$bottomN(MongoDB 5.2+):
{ $group: {
_id: "$user_id",
recent: { $topN: { n: 5, sortBy: { created_at: -1 }, output: "$_id" } }
} }
天然被 n 截断,不会撞文档大小上限。
Step 6: 改一项验一项
db.orders.aggregate([...]).explain("executionStats")
目标:所有集合访问都是 IXSCAN,totalKeysExamined / nReturned 小于 5,仪表盘查询 executionTimeMillis 1 秒内。
预防
- 每个
$lookup外字段都有索引。 - 复合索引按 Equality - Sort - Range 排。
- 管线第一阶段是
$match(或$geoNear);早$project收窄中间结果。 - 并行 rollup 用
$facet;不要无界$push,用$topN/$bottomN。 - 用
db.setProfilingLevel(1, { slowms: 100 })抓慢查询,每周 review。