$match必须前置且字段有索引才能走索引,因为MongoDB只在首个$match阶段尝试索引扫描;一旦经过$project、$addFields等变换,文档结构改变,后续$match只能内存过滤,无法下推至存储层。

$match 必须放在聚合管道最前面,且字段要有对应索引,否则索引压根不生效
为什么 $match 放在开头才能走索引
MongoDB 的查询优化器只在聚合管道的 $match 阶段(且是第一个能命中索引的 $match)尝试使用索引扫描。一旦数据经过 $project、$addFields、$lookup 或任何计算阶段,原始文档结构就变了,后续 $match 只能做内存过滤,无法下推到存储层——也就用不上索引。
常见错误现象:explain("executionStats") 显示 executionStages.stage === "COLLSCAN",哪怕你建了索引也白搭。
- 索引只对原始字段起作用,
$addFields生成的avgTime字段不能被索引加速 - 即使
$match写在第 3 个阶段,只要前两步改了字段(比如$unset: ["_id"]),MongoDB 也不会把该$match下推 - 聚合里嵌套
$expr(如{ $expr: { $gt: ["$a", "$b"] } })会直接绕过所有常规索引
哪些字段组合值得建复合索引
不是所有 $match 条件都适合索引,关键看实际查询中「等值 → 范围 → 排序」的使用模式。例如这个管道:
db.orders.aggregate([
{ $match: { tenantId: "t-123", status: "shipped", createdAt: { $gte: ISODate("2026-05-01") } } },
{ $group: { _id: "$region", total: { $sum: "$amount" } } }
])
必须建的索引是:db.orders.createIndex({ tenantId: 1, status: 1, createdAt: 1 })。
- 等值字段(
tenantId、status)放前面,确保索引前缀能精确匹配 - 范围字段(
createdAt)只能放最后,它之后的字段无法用于过滤(比如你还想按amount过滤,那得另建索引或物化字段) - 如果管道末尾有
{$sort: {updatedAt: -1}},且你希望避免内存排序,可考虑把updatedAt加进索引末尾:{ tenantId: 1, status: 1, createdAt: 1, updatedAt: -1 }
怎么验证索引真正在用
别猜,用 explain("executionStats") 看真实行为。重点关注三个字段:
-
totalDocsExamined应该 ≈nReturned;如果前者远大于后者(比如 12000 vs 42),说明索引没覆盖全,还在扫多余文档 -
executionStages.stage必须是"IXSCAN",不是"COLLSCAN"或"SUBPLAN" -
indexName要和你预期的一致,比如"tenantId_1_status_1_createdAt_1",而不是空或默认的"_id_"
容易踩的坑:$in 查询字段如果放在范围字段后面(如索引 { a: 1, b: 1, c: 1 },查询 { a: 1, c: { $in: [...] }, b: "x" }),MongoDB 实际只用了 a,c 和 b 全部失效。
聚合里带 $lookup 时的索引陷阱
$lookup 本身不走索引,但它的 localField 和 foreignField 如果没索引,关联集合就会被全表扫描。比如:
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }
必须确保:db.users.createIndex({ _id: 1 })(通常已有),以及 db.orders.createIndex({ userId: 1 })。
- 如果
$lookup后还跟$unwind+$match,那个$match无法下推到$lookup内部,所以关联前的过滤越严格越好 - 大表关联慎用
$lookup,考虑预聚合或冗余字段(如把user.name直接存进订单文档)
真正难的不是建索引,而是每次加新过滤条件时,都要重新跑 explain 看执行路径有没有偏移——索引不会自己适应查询变化。











