MongoDB聚合$lookup后二次$match无索引的性能优化方案问询
这个问题我帮不少开发者排查过,核心痛点就是聚合管道里的过滤逻辑位置不对,导致索引无法生效,触发全集合扫描。结合你的场景(15万商品、1000家店铺),我给你三个递进的解决方案,从调整聚合顺序到优化数据结构,总有一个适合你:
方案1:反向关联——先从ProductShops过滤,再关联Products
你的查询条件是「X店铺有库存且属于Y分类」,其实完全可以先从ProductShops入手,因为店铺维度的过滤能快速缩小数据集:
- 先过滤出X店铺中
stock>0且enabled=true的记录,这一步可以用复合索引直接命中,速度极快; - 再关联Products集合,同时在Lookup的内嵌管道里过滤Y分类的商品;
- 最后去掉未匹配到分类的记录即可。
具体聚合代码如下:
db.ProductShops.aggregate([ // 第一步:快速过滤目标店铺的有效库存商品,用索引{shopId:1, stock:1, enabled:1} { $match: { shopId: "X", stock: { $gt: 0 }, enabled: true } }, // 第二步:关联Products并过滤分类,用Products的{category:1}索引 { $lookup: { from: "Products", localField: "productId", foreignField: "_id", as: "product", pipeline: [ { $match: { category: "Y" } } ] } }, // 第三步:移除未匹配到Y分类的商品(数据量极小,无性能压力) { $match: { product: { $ne: [] } } }, // 展开商品信息(每个productId对应唯一Products文档) { $unwind: "$product" }, // 后续排序、投影、分页按需添加 { $sort: { "product.name": 1 } }, { $project: { _id: 0, productId: 1, price: 1, stock: 1, product: { name: 1, category: 1 } } }, { $skip: 0 }, { $limit: 20 } ])
为什么这个方案快?
假设每个店铺平均有150个在售商品(15万总商品/1000家店),第一步过滤后仅剩下150条左右的数据,后续所有操作都基于这个极小的数据集,完全不会触发全集合扫描。而且两步过滤都用到了索引,性能直接拉满。
方案2:优化正向Lookup的内嵌管道与索引
如果业务逻辑必须从Products集合开始查询,那就要让Lookup的内嵌管道尽可能利用索引,同时减少后续过滤的数据量:
- 先过滤Y分类的商品,用Products的
category索引; - Lookup时用内嵌管道直接过滤X店铺的有效库存,这里要依赖ProductShops的复合索引;
- 最后过滤掉未匹配到店铺的商品(此时数据量已经被第一步过滤缩小)。
代码示例:
db.Products.aggregate([ // 第一步:过滤Y分类商品,用{category:1}索引 { $match: { category: "Y" } }, // 第二步:Lookup时直接过滤目标店铺的有效库存,用ProductShops的{productId:1, shopId:1, stock:1, enabled:1}索引 { $lookup: { from: "ProductShops", let: { pid: "$_id" }, pipeline: [ { $match: { $expr: { $eq: ["$productId", "$$pid"] }, shopId: "X", stock: { $gt: 0 }, enabled: true } } ], as: "productShops" } }, // 第三步:移除无匹配店铺的商品(Y分类假设占10%,仅1.5万条数据,过滤开销可忽略) { $match: { productShops: { $ne: [] } } }, { $unwind: "$productShops" }, // 后续排序、投影、分页 { $sort: { "productShops.price": 1 } }, { $project: { _id: 1, name: 1, price: "$productShops.price", stock: "$productShops.stock" } }, { $skip: 0 }, { $limit: 20 } ])
关键注意点
一定要给ProductShops创建复合索引{productId:1, shopId:1, stock:1, enabled:1},这样Lookup内嵌管道里的$match才能直接命中索引,避免在ProductShops里做全表扫描。
方案3:数据结构内嵌(最优解,若业务允许)
如果你的商品对应的店铺数量不是特别多(比如每个商品平均在几十家店售卖,单文档大小不超过MongoDB的16MB限制),可以考虑把店铺价格库存信息内嵌到Products集合,变成数组字段:
// Products集合结构调整后 { _id: "product_id_1", category: "Y", name: "商品名称", // 新增内嵌数组存储店铺信息 shops: [ { shopId: "shop_id_1", price: 15, stock: 3, enabled: true }, { shopId: "shop_id_2", price: 16, stock: 0, enabled: false } ] }
此时查询直接用find即可,完全不需要聚合,性能是最高的:
db.Products.find({ category: "Y", shops: { $elemMatch: { shopId: "X", stock: { $gt: 0 }, enabled: true } } }).sort({"shops.price":1}).skip(0).limit(20)
同时创建复合索引{category:1, "shops.shopId":1, "shops.stock":1},查询会直接走索引,毫秒级返回结果。
总结
- 优先选方案1:操作最简单,性能提升最明显,完全规避全集合扫描;
- 若必须从Products开始查询,选方案2:通过索引优化Lookup内嵌管道,大幅减少后续过滤压力;
- 若数据结构允许,选方案3:从根本上简化查询,性能最优。
内容的提问来源于stack exchange,提问作者george ath

