为什么你的 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 集合就保存了每日各城市的汇总数据,后续查询直接读这个集合,速度提升百倍。
常见坑与最佳实践
- $lookup 性能陷阱:
$lookup默认使用索引,但要求foreignField有索引。如果没有,会全表扫描。务必为外键字段建索引。 - $unwind 后数据膨胀:如果数组很大,
$unwind会产生大量文档。尽量先用$match过滤。 - 内存限制:聚合阶段默认 100MB 内存限制,大结果集需加
{ allowDiskUse: true }。 - $sort 位置:
$sort应尽量放在$limit之前,但如果$sort后跟$limit,MongoDB 会自动优化。 - 使用 $project 减少字段:尽早移除不需要的字段,减少内存占用。
- 管道顺序:
$match->$project->$group->$sort->$limit是黄金顺序。
总结
MongoDB 聚合管道是强大的数据分析工具,但性能取决于你对数据流的理解。记住三个核心原则:尽早过滤、利用索引、减少文档膨胀。通过 explain 分析执行计划,结合 $facet 和 $merge,你可以构建出既灵活又高效的实时分析系统。
下一步,建议深入学习 $graphLookup(图遍历)和 $setWindowFields(窗口函数),它们能解决更复杂的分析场景。