DevFix
Spring Boot已验证

JPA N+1 查询问题:一次列表查询为何打了 101 条 SQL

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:N+1 不是 bug,是懒加载的副作用——1 条查询拿到 N 个父实体,遍历时访问每个父的懒加载关联又各触发 1 条查询,合计 N+1 条。根因是 FetchMode.SELECT(默认每实体单查),不是 LAZY 本身;改成 EAGER 只是把 N+1 从“按需触发”变成“自动触发”,查询数不变。正确解法是按场景用 JOIN FETCH@EntityGraph@BatchSize

背景

JPA 用对象关联(member.getOrders())代替了 SQL 的显式 JOIN。好处是代码自然,代价是“对象图导航”在数据库侧并不便宜。当你查一批父实体、又逐个访问它们的关联集合时,每个集合访问都会单独发一条 SQL——这在小数据量下毫无感知,数据一上去就线性爆炸。

现象

一个“查部门列表并统计每个部门人数”的接口,控制台刷出大量重复 SQL:

-- 第 1 条:查所有部门
select d.id, d.nombre from departamento d;

-- 第 2..N+1 条:每个部门各查一次员工
select e.id from empleado e where e.departamento_id = ?;
select e.id from empleado e where e.departamento_id = ?;
select e.id from empleado e where e.departamento_id = ?;
-- ... 每有一个部门就多一条

50 个部门就是 51 条查询,500 个就是 501 条。开发环境只有几条数据看不出问题,一上生产就慢——一个列表接口因为 101 条 SQL 拖成 12 秒、CPU 95% 的案例很常见。

根因分析

先把两个容易混的概念分开:

  • FetchType 决定“何时”加载LAZY 是访问时才加载,EAGER 是父实体加载时立即加载。
  • FetchMode 决定“如何”加载:默认 SELECT 是每个实体单独一条 SELECT;此外还有 JOIN(一次性 join)、SUBSELECT(一条子查询装下所有集合)、BATCH(用 IN 分批)。

N+1 的根子是 FetchMode.SELECT,不是 LAZY 很多人以为“改成 EAGER 就好了”,但如果还是 SELECT 模式,查询数一模一样,只不过从“用到才查”变成“一进来就查”。典型的错误修复就是这样:把 LAZYEAGER 消灭了 LazyInitializationException,却把 N+1 藏得更深。

默认加载策略也要记住:@ManyToOne / @OneToOne 默认 EAGER,@OneToMany / @ManyToMany 默认 LAZY。

解决方案

方案 A:JOIN FETCH(最直接)

用 JPQL 显式把关联一起 fetch,一条 SQL 搞定:

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("SELECT o FROM Order o JOIN FETCH o.member")
    List<Order> findAllWithMember();
}

@OneToMany 集合,要用 DISTINCT 去重(父实体因 join 会产生重复行):

@Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();

JOIN FETCH 有两个坑:

  1. 不能和分页 Pageable 一起用。Hibernate 会打日志 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory,意思是它把整张表查到内存里再切页,数据一大直接 OOM。
  2. 一条查询别 join 两个集合。同时 JOIN FETCH 两个集合会抛 MultipleBagFetchException 或产生笛卡尔积。

方案 B:@EntityGraph(声明式 fetch join)

不想手写 JPQL 时,用 Spring Data 的注解声明要 fetch 的路径:

public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = {"member"})
    List<Order> findAll();
}

内部走 LEFT OUTER JOIN,效果等同 fetch join,代码更简洁。适合简单查询、快速止漏;复杂动态条件还是直接用 fetch join 更灵活。

方案 C:@BatchSize / default_batch_fetch_size(分页场景首选)

把 N 次单条查询,压成 ceil(N/batchSize) 次带 IN 的批量查询。可以在实体上:

@BatchSize(size = 25)
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();

也可以全局配置,省得每个实体都标:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25

它和分页兼容,是“深度嵌套对象图”最省心的安全网。代价是它不是“1 条查询”,而是把 N+1 降到 N/25 + 1 条,对“一定要单查询”的场景仍不够。

方案 D:DTO Projection(只读列表直接丢掉实体)

如果列表页只展示几个字段,根本不用加载整个实体图,直接投影到 DTO:

@Query("SELECT new com.example.ProjectionDto(d.id, d.name, size(d.employees)) " +
       "FROM Department d")
List<ProjectionDto> findAllSummary();

不碰关联集合,N+1 自然无从发生。这是只读接口的最优解。

怎么发现 N+1

  • 本地:开 spring.jpa.show-sql=truelogging.level.org.hibernate.SQL=DEBUG,看到同一条 select ... where ...=? 反复出现就是 N+1。
  • CI 防回归:用 datasource-proxyassertj-db 在集成测试里断言查询次数,一旦哪次改动重新引入 N+1,测试直接红。

小结

  • N+1 来自 FetchMode.SELECT,不是 LAZY,改 EAGER 不是解法。
  • 决策口诀:单条不复杂查询用 @EntityGraph;条件复杂用 JOIN FETCH;要分页或嵌套对象图用 default_batch_fetch_size;只读列表直接 DTO 投影。
  • JOIN FETCH + 分页 = 内存分页 + OOM 风险,务必避开。
  • 在 CI 里加查询次数断言,比事后看 APM 更可靠。

来源

最后更新于 2026-08-22

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法