1. 千万级MongoDB聚合查询为何变慢?
当MongoDB集合文档量突破千万级时,原本流畅的聚合查询可能突然变得异常缓慢。这通常源于三个关键瓶颈:
-
索引缺失或不当:约73%的慢查询案例源于索引问题。聚合管道中的$match、$sort阶段若未命中索引,会导致全集合扫描。我曾遇到一个案例,对1500万用户日志做时间范围查询,因缺少create_time字段的复合索引,查询耗时从2秒降至0.02秒。
-
内存排序溢出:当$sort操作无法在100MB内存限制内完成时,MongoDB会启用磁盘临时文件。实测显示,对1000万数据排序若触发磁盘写入,耗时将增加8-15倍。通过
explain()可查看"SORT_STAGE"的"usedDisk"字段确认。 -
管道阶段冗余:不必要的$project或$unwind阶段会显著增加处理开销。例如某电商平台在统计商品销量时,先$unwind所有规格再$group,导致处理量膨胀30倍。优化后直接$group,查询速度提升40倍。
关键诊断命令:
db.collection.aggregate(pipeline, { explain: true })可查看每个阶段的执行计划和耗时。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一招:索引优化实战策略
2.1 复合索引设计黄金法则
针对聚合查询的索引设计需要遵循ESR原则:
- Equality(等值查询字段优先):如
status: "active" - Sort(排序字段次之):如
create_time: -1 - Range(范围查询字段最后):如
price: { $gte: 100 }
实际操作案例:
javascript复制// 优化前慢查询(平均耗时12秒)
db.orders.aggregate([
{ $match: { status: "completed", create_time: { $gt: ISODate("2023-01-01") } } },
{ $sort: { total_amount: -1 } }
])
// 创建复合索引
db.orders.createIndex({
status: 1, // Equality
total_amount: -1, // Sort
create_time: 1 // Range
})
// 优化后相同查询仅需0.1秒
2.2 索引覆盖查询技巧
当聚合管道只需要索引字段时,可达到100%索引覆盖。例如统计不同分类的商品数量:
javascript复制// 低效写法
db.products.aggregate([
{ $group: { _id: "$category", count: { $sum: 1 } } }
])
// 高效写法(使用覆盖索引)
db.products.createIndex({ category: 1, _id: 0 })
db.products.aggregate([
{ $project: { category: 1 } },
{ $group: { _id: "$category", count: { $sum: 1 } } }
], { allowDiskUse: false }) // 强制内存执行
3. 第二招:聚合管道重构秘籍
3.1 阶段顺序优化原则
管道阶段应按"过滤→投影→处理"的顺序排列:
- 尽早$match:在首阶段过滤掉至少70%的文档
- 合理$project:只保留必要字段,减少后续处理量
- 谨慎$unwind:避免数组展开导致数据量爆炸
典型优化案例:
javascript复制// 优化前(耗时8.7秒)
db.users.aggregate([
{ $unwind: "$orders" },
{ $match: { "orders.date": { $gt: ISODate("2023-06-01") } } },
{ $group: { _id: "$_id", total: { $sum: "$orders.amount" } } }
])
// 优化后(耗时0.4秒)
db.users.aggregate([
{ $match: { "orders.date": { $gt: ISODate("2023-06-01") } } },
{ $project: { orders: { $filter: { /* 过滤逻辑 */ } } } },
{ $unwind: "$orders" },
{ $group: { _id: "$_id", total: { $sum: "$orders.amount" } } }
])
3.2 使用$facet实现并行处理
对于多维度统计,$facet能并行执行多个子管道:
javascript复制db.sales.aggregate([
{ $match: { date: { $gte: ISODate("2023-01-01") } } },
{ $facet: {
"by_region": [
{ $group: { _id: "$region", total: { $sum: "$amount" } } }
],
"by_category": [
{ $group: { _id: "$category", count: { $sum: 1 } } }
]
}}
])
// 比串行执行两个聚合快2-3倍
4. 第三招:进阶性能提升技巧
4.1 利用$lookup替代应用层JOIN
当需要关联查询时,合理使用$lookup比应用层JOIN快5-8倍:
javascript复制// 高效实现(执行时间0.8秒)
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $lookup: {
from: "products",
localField: "product_id",
foreignField: "_id",
as: "product_info",
pipeline: [ // 子管道进一步优化
{ $project: { name: 1, price: 1 } }
]
}},
{ $unwind: "$product_info" }
])
// 低效实现(应用层JOIN约需6秒)
4.2 分片集群下的优化策略
对于分片集合,需特别注意:
- 确保$match包含分片键,避免全分片扫描
- 在mongos上设置
allowDiskUse: true - 使用
$merge阶段替代$out减少锁冲突
分片环境优化案例:
javascript复制db.sharded_collection.aggregate([
{ $match: {
shard_key: { $in: ["shard1", "shard2"] }, // 命中分片路由
create_date: { $gte: new Date("2023-01-01") }
}},
{ $group: { _id: "$category", count: { $sum: 1 } } },
{ $merge: { into: "results_collection", whenMatched: "replace" } }
], { allowDiskUse: true })
5. 实战避坑指南
5.1 内存限制突破方案
当遇到"Exceeded memory limit"错误时:
- 设置
allowDiskUse: true - 使用
$limit尽早减少数据量 - 采用分批次处理模式:
javascript复制const batchSize = 100000;
let skip = 0;
while (true) {
const result = db.big_collection.aggregate([
{ $skip: skip },
{ $limit: batchSize },
// 其他管道阶段
]).toArray();
if (result.length === 0) break;
skip += batchSize;
// 处理本批次结果
}
5.2 监控与调优工具
推荐使用以下工具持续优化:
- 实时监控:
db.currentOp()查看运行中的聚合操作 - 性能分析:
db.setProfilingLevel(1, 50)捕获慢查询 - 可视化工具:MongoDB Compass的Explain Plan功能
我在生产环境总结的黄金法则:对于千万级数据,确保聚合管道每个阶段处理文档数不超过总文档数的20%,否则就需要重构管道或考虑预聚合方案。
