MongoDB 聚合管道实战:从慢查询到实时分析

By | 2026年9月23日

为什么你的 MongoDB 聚合查询总是很慢?

很多开发者对 MongoDB 聚合管道的印象是“功能强大但性能堪忧”。实际上,大部分性能问题源于对管道执行顺序和索引利用的误解。本文以一个真实的电商订单分析需求为例,带你从零构建一个高效的聚合查询,并分享我在生产环境中总结的优化经验。

场景与数据准备

假设我们有一个 orders 集合,存储订单信息,以及一个 users 集合,存储用户信息。订单文档结构如下:


// orders 集合示例文档
{
  _id: ObjectId(),
  orderNo: "202405200001",
  userId: ObjectId("..."),
  status: "completed", // pending, completed, cancelled
  totalAmount: 299.00,
  items: [
    { productId: ObjectId("..."), name: "无线耳机", price: 199.00, quantity: 1 },
    { productId: ObjectId("..."), name: "手机壳", price: 100.00, quantity: 1 }
  ],
  createdAt: ISODate("2024-05-20T10:00:00Z"),
  paidAt: ISODate("2024-05-20T10:05:00Z")
}

用户文档结构:


// users 集合示例文档
{
  _id: ObjectId("..."),
  name: "张三",
  level: "VIP", // 普通、VIP、SVIP
  city: "北京"
}

我们的目标是:统计 2024 年 5 月每个城市 VIP 及以上用户的订单总金额和订单数,并按总金额降序排列,只取前 10 个城市。

第一步:构建基础聚合管道

先写出最直观的管道,再逐步优化。


db.orders.aggregate([
  // 1. 筛选时间范围和订单状态
  {
    $match: {
      createdAt: { $gte: ISODate("2024-05-01"), $lt: ISODate("2024-06-01") },
      status: "completed"
    }
  },
  // 2. 关联用户信息
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
    }
  },
  // 3. 展开用户数组(每个订单对应一个用户)
  { $unwind: "$user" },
  // 4. 筛选 VIP 及以上用户
  { $match: { "user.level": { $in: ["VIP", "SVIP"] } } },
  // 5. 按城市分组统计
  {
    $group: {
      _id: "$user.city",
      totalAmount: { $sum: "$totalAmount" },
      orderCount: { $sum: 1 }
    }
  },
  // 6. 排序并取前10
  { $sort: { totalAmount: -1 } },
  { $limit: 10 },
  // 7. 美化输出字段
  {
    $project: {
      _id: 0,
      city: "$_id",
      totalAmount: 1,
      orderCount: 1
    }
  }
])

这个管道能跑出结果,但在数据量大时(比如百万级订单)会非常慢。问题出在 $lookup 和 $unwind 的位置。

第二步:优化核心——尽早过滤,减少数据流

聚合管道是流式处理,每个阶段都会消耗内存和 CPU。黄金法则:尽可能早地减少文档数量。

在上面的管道中,$lookup 发生在 $match 之后,但 $match 只过滤了订单,没有过滤用户等级。这意味着所有 5 月份的已完成订单都会参与 $lookup,即使其中大部分用户不是 VIP。

优化思路:先找出符合条件的用户 ID,再过滤订单。


// 先获取所有 VIP 及以上用户的 ID
const vipUserIds = db.users.distinct("_id", { level: { $in: ["VIP", "SVIP"] } });

// 然后在聚合中直接过滤
db.orders.aggregate([
  {
    $match: {
      createdAt: { $gte: ISODate("2024-05-01"), $lt: ISODate("2024-06-01") },
      status: "completed",
      userId: { $in: vipUserIds } // 提前过滤,大幅减少文档数
    }
  },
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
    }
  },
  { $unwind: "$user" },
  {
    $group: {
      _id: "$user.city",
      totalAmount: { $sum: "$totalAmount" },
      orderCount: { $sum: 1 }
    }
  },
  { $sort: { totalAmount: -1 } },
  { $limit: 10 },
  {
    $project: {
      _id: 0,
      city: "$_id",
      totalAmount: 1,
      orderCount: 1
    }
  }
])

注意:如果 vipUserIds 数组非常大(比如几十万),$in 查询可能变慢。此时可以考虑将用户等级冗余到订单文档中(反范式设计),或者使用 $lookup 的管道形式配合索引。

第三步:利用索引与 explain 分析

为 orders 集合创建复合索引,加速初始 $match:


db.orders.createIndex({ createdAt: 1, status: 1, userId: 1 })

使用 explain 查看执行计划:


db.orders.explain("executionStats").aggregate([...])

关注 executionStats.totalKeysExamined 和 totalDocsExamined,理想情况下它们应该接近返回的文档数。如果 totalDocsExamined 远大于 nReturned,说明索引未充分利用。

第四步:高级技巧——$facet 实现多维度分析

实际业务中,我们可能同时需要“按城市统计”和“按用户等级统计”。用 $facet 可以在一次查询中完成多个聚合,避免多次扫描数据。


db.orders.aggregate([
  {
    $match: {
      createdAt: { $gte: ISODate("2024-05-01"), $lt: ISODate("2024-06-01") },
      status: "completed"
    }
  },
  {
    $facet: {
      byCity: [
        { $match: { userId: { $in: vipUserIds } } },
        { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
        { $unwind: "$user" },
        { $group: { _id: "$user.city", totalAmount: { $sum: "$totalAmount" }, orderCount: { $sum: 1 } } },
        { $sort: { totalAmount: -1 } },
        { $limit: 10 }
      ],
      byLevel: [
        { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
        { $unwind: "$user" },
        { $group: { _id: "$user.level", totalAmount: { $sum: "$totalAmount" }, orderCount: { $sum: 1 } } },
        { $sort: { totalAmount: -1 } }
      ]
    }
  }
])

$facet 的每个子管道独立执行,但共享同一个输入流。适合需要多个维度报表的场景,但注意内存限制(每个子管道结果不能超过 100MB,可开启 allowDiskUse)。

第五步:实时分析——$merge 增量更新

如果数据量极大,每次查询都全量聚合不现实。可以使用 $merge 将聚合结果写入一个汇总集合,实现增量更新。


db.orders.aggregate([
  {
    $match: {
      createdAt: { $gte: ISODate("2024-05-01"), $lt: ISODate("2024-06-01") },
      status: "completed"
    }
  },
  {
    $group: {
      _id: { city: "$user.city", date: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } } },
      totalAmount: { $sum: "$totalAmount" },
      orderCount: { $sum: 1 }
    }
  },
  {
    $merge: {
      into: "daily_city_stats",
      on: "_id",
      whenMatched: "replace",
      whenNotMatched: "insert"
    }
  }
])

这样,dailycitystats 集合就保存了每日各城市的汇总数据,后续查询直接读这个集合,速度提升百倍。

常见坑与最佳实践

  1. $lookup 性能陷阱:$lookup 默认使用索引,但要求 foreignField 有索引。如果没有,会全表扫描。务必为外键字段建索引。
  2. $unwind 后数据膨胀:如果数组很大,$unwind 会产生大量文档。尽量先用 $match 过滤。
  3. 内存限制:聚合阶段默认 100MB 内存限制,大结果集需加 { allowDiskUse: true }。
  4. $sort 位置:$sort 应尽量放在 $limit 之前,但如果 $sort 后跟 $limit,MongoDB 会自动优化。
  5. 使用 $project 减少字段:尽早移除不需要的字段,减少内存占用。
  6. 管道顺序:$match -> $project -> $group -> $sort -> $limit 是黄金顺序。

总结

MongoDB 聚合管道是强大的数据分析工具,但性能取决于你对数据流的理解。记住三个核心原则:尽早过滤、利用索引、减少文档膨胀。通过 explain 分析执行计划,结合 $facet 和 $merge,你可以构建出既灵活又高效的实时分析系统。

下一步,建议深入学习 $graphLookup(图遍历)和 $setWindowFields(窗口函数),它们能解决更复杂的分析场景。