今天一大早,报警短信就开始狂轰滥炸。MongoDB 实例的 CPU 瞬间飙到 100%,系统磁盘 IOPS 爆满,所有依赖 MongoDB 的微服务全部响应超时,页面直接白板。 连上数据库排查慢查询日志,罪魁祸首是一个新上线的功能:有同学直接在大几百万级别的集合上乱写了一个极其复杂的 Aggregation Pipeline,而且在 $lookup 关联查询中,外键字段完全没加索引。导致每次查询都触发了全表扫描(COLLSCAN),磁盘物理读取直接炸裂。

很多开发同学觉得 MongoDB 是 NoSQL,就理所当然地认为它可以随便塞数据,查询也随便写。事实上,MongoDB 的聚合管道(Aggregation Pipeline)非常吃内存和 CPU,尤其是 $lookup 操作,本质上是一个嵌套循环(Nested Loop)算法。如果关联的右表字段没有索引,那么左表有多少条匹配记录,右表就要被全表扫描多少次!

为了解决这个性能黑盒,我们对慢查询语句进行了彻底的 explain() 诊断和索引优化。

先来看一下当时出问题的低效聚合查询代码:

// 极其灾难的原始聚合查询:先 lookup 导致右表全表扫描,且 match 放在后面无法使用主索引
db.orders.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "uid",
      as: "userInfo"
    }
  },
  { $unwind: "$userInfo" },
  {
    $match: {
      "userInfo.status": "active",
      "status": "PAID",
      "createdAt": {
        $gte: ISODate("2026-05-01T00:00:00Z"),
        $lte: ISODate("2026-05-31T23:59:59Z")
      }
    }
  },
  { $sort: { "createdAt": -1 } },
  { $limit: 20 }
]);

当我们使用 db.orders.explain("executionStats").aggregate(...) 执行分析时,输出的执行计划惨不忍睹:

  • 出现了大面积的 COLLSCAN
  • totalDocsExamined(扫描文档数)高达数百万,而实际只返回了 20 条数据。
  • executionTimeMillis(执行时间)高达惊人的 8400 毫秒。

造成性能灾难的原因有两个: 第一,管道阶段顺序严重错误。它先执行了 $lookup 将所有订单与用户进行关联,再执行 $match 过滤条件。这意味着几百万的未付款订单和过期订单全部参与了昂贵的多表关联动作。 第二,关联字段缺少索引users 集合的 uid 字段上居然没有任何索引,每一次关联匹配都引发了 users 表的完整物理扫描。

针对这两个大坑,我们进行了如下重构:

  1. 前置过滤与排序(Filter & Sort Early):必须把 $match$sort 提到管道的第一步,确保引擎能利用索引快速裁剪数据集。
  2. 建立正确的复合索引:在 orders 集合上为过滤和排序字段建立复合索引 idx_status_createdAt;在被关联的 users 集合上,必须为关联字段 uid 及状态字段建复合索引 idx_uid_status

以下是重构后的高性能聚合代码和对应的索引创建命令:

// 1. 创建复合索引,为第一阶段的 match 和 sort 提供完美支持
db.orders.createIndex({ status: 1, createdAt: -1 });

// 2. 创建被关联集合的复合索引,消除 lookup 阶段 of COLLSCAN
db.users.createIndex({ uid: 1, status: 1 });

// 3. 重构后的高性能聚合查询
db.orders.aggregate([
  // 第一步:利用复合索引快速过滤并排序,此时结果集已缩减至百级别,且排序是无成本的(Index Sort)
  {
    $match: {
      status: "PAID",
      createdAt: {
        $gte: ISODate("2026-05-01T00:00:00Z"),
        $lte: ISODate("2026-05-31T23:59:59Z")
      }
    }
  },
  { $sort: { createdAt: -1 } },
  { $limit: 20 },
  
  // 第二步:由于结果集只有 20 条,此时进行 lookup 关联只有 20 次索引查询,几乎瞬间完成
  {
    $lookup: {
      from: "users",
      let: { orderUserId: "$userId" },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ["$uid", "$$orderUserId"] },
                { $eq: ["$status", "active"] }
              ]
            }
          }
        }
      ],
      as: "userInfo"
    }
  },
  { $unwind: "$userInfo" }
]);

重构后我们再次用 explain("executionStats") 分析,totalDocsExamined 骤降到 20 次,执行时间也从原来的 8.4 秒降到了仅仅 3 毫秒,响应速度直接拉满,性能提升了几千倍,数据库 CPU 彻底平稳。

总结下来,写 MongoDB 聚合管道必须牢记:永远把 $match 放在最前面,并且一定要保证 $lookup 的被关联表字段上有可用索引,千万不要在管道中做无索引的大表碰撞。

你们在用 MongoDB 做报表统计或数据聚合时,有没有被各种多级 $lookup 或者多重 $group 卡死过?你们是直接在 MongoDB 里死磕索引,还是干脆把数据同步到 Elasticsearch 或 BigQuery 里去查?