2
0
0

03-MongoDB 关联查询:N+1 慢查询、两次查询法与 $lookup

2026-10-21
2026-10-21

📝 笔记摘要:MongoDB 的关联查询:N+1、两次查询法与 $lookup

关系库里 JOIN 一行能写完的事,MongoDB 默认不让写。于是就有了那条在 NoSQL 项目里非常常见的路子:先查主表拿到一批 id,再拿着这些 id 去查另一张表,最后在内存里拼起来。

这条路子本身没问题——问题在于它是 N+1 还是 两次查询法,中间差了整数量级的性能。而且它在三种场景下会直接失效:关联数据要参与过滤、要参与排序、或者关联表比主表大得多。这篇把三种解法的边界划清楚:什么时候两次查询法就够,什么时候必须上 $lookup,以及 $lookup 自己有哪些坑。

MongoDB 系列 共 3 篇,这是第 3 篇
  01 MongoDB 基础 文档模型、CRUD 与聚合管道
  02 MongoDB 索引与查询性能 复合索引、覆盖查询与执行计划
  03-MongoDB 关联查询 N+1 慢查询、两次查询法与 $lookup ← 本篇

这篇的结构
  1. 关联查询:NoSQL 里最常被问倒的题
  2. 先分清两种「内存查询」:N+1 和两次查询法
  3. 两次查询法:怎么写才不是 N+1
  4. 两次查询法的三个失效场景
  5. $lookup:把 join 放回数据库
  6. $lookup 的三个坑
  7. let + pipeline:等值匹配不够用的时候
  8. $unwind 与一对多的行数放大
  9. 怎么发现 N+1:慢查询日志
  10. 最终选型:嵌入、引用、还是关联

1. 关联查询:NoSQL 里最常被问倒的题

「MongoDB 能不能 join」这个问题,几乎每个从关系库转过来的人都会问一遍。答案是:没有 JOIN 这个语法,但有三条路能把关联做出来,代价各不相同。

嵌入 写入时就把关联数据冗余进主文档,读的时候零成本
引用 主文档只存一个 id,关联时自己去查另一张集合
服务端关联 用聚合管道的 $lookup,把 join 放回数据库里做

选哪条路,取决于三个问题:

关联数据和主数据是不是总在一起读写? 是 → 考虑嵌入
关联数据要不要参与过滤、排序、分页? 要 → 引用方案不够用
关联表比主表大还是小? 大 → 拉回内存就是灾难

第一个问题的答案在 MongoDB 基础 第 5 节(嵌入 vs 引用)讲过;第二个问题的答案在 02 篇的索引部分也铺垫过(MongoDB 索引与查询性能)。这篇专门讲第三条路,以及「引用方案到底什么时候够用」这个判断。

2. 先分清两种「内存查询」:N+1 和两次查询法

「先查主表,再拿 id 查另一张表」这个描述,覆盖了两种完全不同的实现,成本差得很远。

第一种:N+1。拿到一条就查一次:

List<Order> orders = orderRepository.findByStatus("paid");
for (Order order : orders) {
    order.setCustomer(customerRepository.findById(order.getCustomerId()).orElse(null));
}

20 条订单就是 21 次网络往返。MongoDB 单次查询在同机房内可能只要 1 毫秒,但 20 次串行下来,一次网络抖动就足以把它放大到秒级。

还有个更隐蔽的代价:连接池。Java 驱动里每个 MongoClient 对拓扑里的每台 server 维护一个连接池,maxPoolSize 默认是 100。用满了,后面的请求就得排队等连接(waitQueueTimeoutMS 默认 120 秒)。

10 个并发请求 × 每请求 21 次查询 瞬间要 210 个连接,池子只有 100
表现 接口平时很快,偶尔卡几十秒,日志里是一堆 Timed out while waiting for a connection
根因 不是 MongoDB 慢,是应用把连接池当成了队列

第二种:两次查询法(Two Phase Loading)。先把 id 收齐,一次 IN 批量拉回来,在内存里拼:

List<Order> orders = orderRepository.findByStatus("paid");

List<String> customerIds = orders.stream()
        .map(Order::getCustomerId)
        .distinct()
        .toList();

Map<String, Customer> customerMap = customerRepository.findAllById(customerIds).stream()
        .collect(Collectors.toMap(Customer::getId, Function.identity()));

orders.forEach(o -> o.setCustomer(customerMap.get(o.getCustomerId())));

往返次数固定是 2,和主表有多少条完全无关。

这不是落后做法 它是权衡之后的正确选择。JPA 生态里 @BatchSize 干的就是同一件事:把 N 次单查合并成一批 IN
什么时候用 关联数据只用来「带出来展示」,不参与过滤排序
什么时候不用 见第 4 节,三个场景直接失效

两种写法的差别只有一行代码,代价却是一个数量级:

N+1 与两次查询法的对比图:左边逐条查询,二十条主数据打二十一次往返,连接池被瞬间占满;右边先把 id 去重收齐再用一次 in 批量查,往返次数固定为两次,与数据量无关

3. 两次查询法:怎么写才不是 N+1

如果确定要走两次查询法,下面几个细节决定它是不是退化回 N+1。

一、必须一次 IN 拉,不要循环。这是 N+1 和两次查询法的分界线:

// 一次批量
List<Customer> customers = customerRepository.findAllById(customerIds);

// 等价的原生写法
Query query = Query.query(Criteria.where("_id").in(customerIds));
List<Customer> customers = mongoTemplate.find(query, Customer.class);

二、id 要去重。一个客户有十张订单,不去重的话 IN 列表里就是十份同样的 id,查询本身没错,但列表白白变大,MongoDB 还得去重。

三、id 列表要分批。$in 的数组是 BSON 文档的一部分,单文档上限 16 MB,几万个 id 就可能顶到天花板。经验值是一批几千个,超了就切。

List<String> ids = ...;
List<Customer> all = new ArrayList<>();
for (int i = 0; i < ids.size(); i += 2000) {
    all.addAll(customerRepository.findAllById(
            ids.subList(i, Math.min(i + 2000, ids.size()))));
}

四、id 必须来自分页之后的那一页。这是最容易出错的地方:先全量查一遍主表拿 id,再对 id 列表做分页——分页作用在了错误的集合上,而且每次翻页都要全量重查。正确顺序是先分页主表,再拿这一页的 id 去关联。

五、空列表要短路。$in: [] 匹配不到任何文档,结果本来就是对的,但没必要发这一趟请求。

if (customerIds.isEmpty()) {
    return orders;   // 一次往返都省下
}

六、拼装用 Map,别用嵌套循环。用 List.contains() 在内存里找,是 O(n²),数据量一大就成新的瓶颈。

4. 两次查询法的三个失效场景

两次查询法能用的前提是:关联数据只被「带出来」,不参与任何数据库侧的判断。一旦关联数据要参与过滤、排序或者分页,这条路就断了。

场景一:过滤条件落在关联表上

需求是「只看 VIP 客户的订单」。两次查询法只有两个选择,两个都不好:

拉全量客户回内存过滤 客户表有多少拉多少,为了筛出 20 条订单把几十万条客户拖进内存
反过来先查客户 查客户那一步没法分页,因为 limit 加在客户查询上;而且客户到订单是反的一对多,翻页语义直接错乱

场景二:排序在关联表上

「按客户等级排序、按客户名称分页」。两次查询法只能把整页拉回内存,在应用层排序——这正是数据库该干的活,而且数据量一大就 OOM。

场景三:关联表比主表大

主表 20 条订单,关联表 50 万条商品明细。两次查询法的 IN 列表要装进 50 万个 id,文档直接顶到 16 MB 上限,而且拉回来的数据量是主表的几十倍。

判断口诀 关联数据只用来显示 → 两次查询法;关联数据要参与过滤或排序 → 必须下推到数据库

5. $lookup:把 join 放回数据库

要把过滤、排序、分页全放在数据库侧做,MongoDB 的工具是聚合管道里的 $lookup。

db.orders.aggregate([
  { $match: { status: "paid" } },
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" },
  { $match: { "customer.level": "vip" } },
  { $sort: { amount: -1 } },
  { $limit: 20 }
])

和两次查询法比,差别全在「在哪做」:

过滤 在数据库里按 customer.level 筛完再返回,不用把不相关的客户拉回内存
排序 在数据库里排完,索引也可能用得上
分页 $limit 作用在最终结果上,返回多少就是多少
数据量 不受关联表大小影响,这是两次查询法做不到的

Java 侧用 Spring Data 写:

Aggregation aggregation = Aggregation.newAggregation(
        match(Criteria.where("status").is("paid")),
        lookup("customers", "customerId", "_id", "customer"),
        unwind("customer"),
        match(Criteria.where("customer.level").is("vip")),
        sort(DESC, "amount"),
        limit(20));

AggregationResults<OrderView> results = mongoTemplate.aggregate(
        aggregation, "orders", OrderView.class);

List<OrderView> list = results.getMappedResults();

注意 lookup 在 Aggregation 上是静态方法,四参数版本是 lookup(from, localField, foreignField, as),参数全是字符串。unwind 也有三参数版本 unwind(path, includeArrayIndex, preserveNullAndEmptyArrays),下面第 8 节要用。

$lookup 从 MongoDB 5.1 起,from 集合可以是分片集合。

6. $lookup 的三个坑

坑一:被关联的集合必须有索引

官方文档说得很直接:等值匹配的 $lookup 在 from 集合的 foreignField 上有索引时性能才好;没有索引时,官方用词是「likely has poor performance」。

关联 _id 天然有索引,不用管
关联业务键 skuCode、tenantId 这类,必须自己先建索引
没建索引的表现 不报错,只是慢;explain 里能看到子查询在做全集合扫描

这条和 02 篇的索引那套是连着的:$lookup 的性能问题,八成是被关联表上缺索引,不是 $lookup 本身慢。

坑二:$unwind 默认是 INNER JOIN 语义

$lookup 的结果放在 as 指定的字段里,而且是个数组——哪怕只匹配到一条,数组里也只有一条。真正能当对象用的是 $unwind。

但 $unwind 的默认行为是:字段缺失、为 null、或者是空数组的文档,整条被丢弃,不会输出。

// 默认:$unwind "$customer" 时,关联不上的订单会凭空消失
{ $unwind: "$customer" }

// LEFT JOIN 语义:关联不上的订单保留,customer 为空
{ $unwind: { path: "$customer", preserveNullAndEmptyArrays: true } }

为什么这是个坑 $lookup + $unwind 写出来像 JOIN,但默认行为是 INNER JOIN。要展示「订单 + 客户名,没有客户的订单也要显示」的时候,订单会莫名其妙少一批
排查方向 数据看着少了,先想到 preserveNullAndEmptyArrays

坑三:相关子查询是逐条跑的

$lookup 的 pipeline 如果引用了外层文档的字段($$cid),就是相关子查询:外层每输出一条文档,子查询就跑一遍。外层出 1 万条,子查询就跑 1 万次。

不引用外层字段的叫无关子查询,只跑一次然后把结果缓存起来,后续复用。

相关子查询慢 能减少传给 $lookup 的文档数量——在 $match 里把条件卡死,别指望在 $lookup 之后才筛
官方给的第二招 重新审视数据模型,确认这个查询形态是不是模型本身带来的

7. let + pipeline:等值匹配不够用的时候

localField / foreignField 只能做等值匹配。条件一复杂,就得换成 let + pipeline 写法:

{
  $lookup: {
    from: "customers",
    let: { cid: "$customerId", amt: "$amount" },
    pipeline: [
      {
        $match: {
          $expr: {
            $and: [
              { $eq: ["$_id", "$$cid"] },
              { $gte: ["$creditLimit", "$$amt"] }
            ]
          }
        }
      }
    ],
    as: "customer"
  }
}

let 定义的变量在 pipeline 里用 $$变量名 引用。Java 侧对应的就是 Aggregation.lookup() 那个无参的流式入口:

lookup()
    .from("customers")
    .localField("customerId")
    .foreignField("_id")
    .let(newVariable("cid").forField("customerId"))
    .pipeline(match(ctx -> new Document("$expr",
            new Document("$eq", List.of("$_id", "$$cid")))))
    .as("customer")

这里有一条最容易踩、也最反直觉的规则,关于索引能不能用上:

$expr 里的比较运算符能用上 from 集合上的索引 但索引只能用于「字段和常量」的比较,let 操作数必须解析成常量
能用索引 { $gte: ["$creditLimit", 1000] }——右边是常量
用不上索引 { $gte: ["$creditLimit", "$$amt"] }——右边是外层文档的值,逐条不同
let 操作数解析为空或缺失 索引同样用不上
multikey、partial、sparse 索引 在 $lookup 里一律不参与

也就是说:关联条件里只要带了外层文档的字段值,索引就帮不上忙了。这种时候只有两条路——把条件下推成常量,或者改数据模型。

还有一条限制:pipeline 里不能直接访问输入文档的字段,必须通过 let 变量转一手;pipeline 里也不能带 $out 和 $merge。

8. $unwind 与一对多的行数放大

关联到一对多的数据时,$lookup 的 as 里是多个元素的数组,$unwind 摊平之后,行数被放大了。

$lookup 加 $unwind 的行数放大示意图:三张订单关联到不同数量的明细行,摊平后从三行变成六行,排序和 limit 的语义随之改变

放大之后有两个后果:

排序的语义变了 要的是「前 20 个订单」,放大后变成「前 20 个订单-明细行」,可能全落在同一个订单上
内存和排序开销 中间结果集是放大后的行数,$sort 要在全量上做

还有两个参数能控制摊平行为:

{
  $unwind: {
    path: "$items",
    includeArrayIndex: "itemIndex",              // 保留原数组下标
    preserveNullAndEmptyArrays: true              // 空数组的文档也保留
  }
}

「先取前 20 个订单,再关联它们的明细」这种需求,用一次 $lookup 做不到——因为 $limit 和过滤在关联字段上的 $match 顺序是矛盾的:先 $limit 就没法用关联字段过滤,先过滤就没法 $limit。真要做,要么查两遍(先取 id 再关联),要么换个模型。

9. 怎么发现 N+1:慢查询日志

N+1 在代码里很难看出来,因为循环写得很自然。得让数据库把现场记录下来。

// 打开分析器
db.setProfilingLevel(2)

// 看最近的查询
db.system.profile.find().sort({ ts: -1 }).limit(20)

// 用完关掉
db.setProfilingLevel(0)

level 2 全量记录,写进 profiler 和 diagnostic log
level 1 只记超过 slowms 的慢操作
level 0 关闭
默认是关的 不主动开就什么都没有

官方给的警告要认真看:profiling 会降低性能,而且会把未加密的查询数据写进 system log。生产环境开之前先想清楚这两条。

mongos 上开不了 mongos 没有集合可写,database profiler 用不了,只能写 diagnostic log
5.0 起的变化 profiler 的 level、slowms、sampleRate、filter 的改动都会记进 log 文件
Atlas Infinite 只支持 level 为 0

看什么特征:同一个 ns(命名空间)在很短时间窗口里出现大量重复的查询,而且参数只差一个 id——这就是 N+1 的指纹。

10. 最终选型:嵌入、引用、还是关联

把三条路放一起,按优先级往下走:

第一步:能不能嵌入? 关联数据总跟主数据一起读写、数量有上限、单独拿出来没意义 → 嵌入,读取零成本,一次查询搞定
第二步:两次查询法够不够? 关联数据只用来带出来展示,不参与过滤排序 → 两次查询法,固定两次往返
第三步:上 $lookup 关联数据要参与过滤、排序、分页,或者关联表比主表大 → $lookup,把判断下推到数据库

官方对 $lookup 的态度也印证这个顺序——文档里写着:为了减少对 $lookup 的依赖,可以考虑用嵌入的数据模型,把关联数据存在同一个集合里。

最后补一条容易被忽略的:如果关联表很大,考虑「引用 + 冗余关键字段」的折中。比如订单里不只存 customerId,还冗余一份 customerName 和 customerLevel。列表页只按这两个冗余字段展示,完全不用关联;真要查客户详情时再按 id 查一次。这种「读多写少」的场景,冗余的收益远大于成本。

💡 补充:关联查询这一块最容易被忽略的其实是「两次查询法」和「N+1」的区别。很多团队看到「在内存里拼数据」就当成反模式,其实只要做到了「一次 IN 批量拉、往返次数与数据量无关」,它就是标准解法,很多 ORM 在背后也是这么干的。真正要警惕的是循环里逐条查,以及把本该下推到数据库的过滤和排序搬到了应用层。判断标准就一条:关联数据参不参与「这条记录要不要返回」的判断——参与就必须下推,不参与就可以在内存里拼。

🔥 一句话总结:关联查询没有银弹,顺序是先想能不能嵌入,再看两次查询法够不够,最后才上 $lookup;用了 $lookup 就要记住三件事——被关联表要有索引、$unwind 默认是 INNER JOIN 语义、$expr 里的索引只在比常量时才用得上。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!