$lookup慢主因是foreignField未建单字段索引。必须为from集合的foreignField单独建索引,复合索引无效;嵌套字段需完整路径索引;分片集群需各分片一致;localField索引是前置加速前提;验证需查from集合explain,非pipeline中IXSCAN字样。

为什么$lookup慢,往往不是因为没建索引,而是索引建错了位置
绝大多数人给$lookup加索引时,只在源集合(即管道输入的集合)上建了索引,却忽略了from集合——也就是被联查的那张表。MongoDB执行$lookup时,会对from集合做一次独立查询:用localField的值去匹配foreignField。如果foreignField没索引,就会全表扫描,数据量一过万,耗时直接跳到秒级。
foreignField必须单独建索引,不能依赖复合索引前缀
常见误区是认为“我已在products集合上建了{_id:1, category:1}索引,$lookup查_id肯定快”。错。MongoDB的索引匹配遵循最左前缀原则:$lookup只用到foreignField单个字段值做等值查找,它不会利用复合索引中非首字段的部分。哪怕你查的是category,而索引是{name:1, category:1},也完全无效。
- 必须显式为
foreignField字段创建单字段索引,例如:db.products.createIndex({ _id: 1 }) - 如果
foreignField是嵌套字段(如metadata.code),索引必须完整匹配路径:db.products.createIndex({ "metadata.code": 1 }) - 分片集群下,该索引需在每个分片上存在,且类型一致(不能一个分片是哈希索引,另一个是普通B-tree)
localField索引不是冗余的,而是必须的前置条件
localField所在集合的索引,作用不在$lookup阶段本身,而在它之前的$match或整个管道入口。但如果你的$lookup写在管道靠前位置(比如第二步),而第一步$match又没走索引,那么大量无关文档会流入$lookup,导致它被迫执行成千上万次无意义的关联查询——这时localField索引就变成了间接加速器。
- 典型错误写法:
db.orders.aggregate([{$project:{productId:1}}, {$lookup:{from:"products", localField:"productId", foreignField:"_id", as:"p"}}])——$project后无法使用productId上的索引过滤,$lookup必然放大 - 正确顺序:先
$match(带索引字段),再$lookup,最后$project -
localField字段若为数组(如tags: ["a","b"]),MongoDB会为每个元素分别执行一次$lookup匹配,此时foreignField索引压力翻倍,务必确认该场景是否真需要
验证索引是否生效,别信explain里的“IXSCAN”字样
db.collection.aggregate(pipeline).explain("executionStats")输出里出现IXSCAN,不代表$lookup用了索引。你需要定位到stages数组中$lookup对应的那个子树,检查它的executionStages里是否有stage: "IXSCAN"且keyPattern匹配foreignField。更可靠的方式是直接查from集合:
db.products.find({ _id: ObjectId("...") }).explain("executionStats")
如果这句返回nReturned为1、totalDocsExamined也为1,说明索引有效;若totalDocsExamined远大于1,索引就没起作用。
真正容易被忽略的是:$lookup的性能瓶颈常常藏在from集合的写入模式里——比如频繁更新foreignField字段,会导致索引页分裂,长期下来即使有索引,B-tree深度也会变大。这种问题不会出现在explain里,只能靠定期运行db.collection.stats()看indexCount和indexDetails中的btreeDepth。


















