一句话先说结论: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 模式,查询数一模一样,只不过从“用到才查”变成“一进来就查”。典型的错误修复就是这样:把 LAZY 改 EAGER 消灭了 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 有两个坑:
- 不能和分页
Pageable一起用。Hibernate 会打日志HHH000104: firstResult/maxResults specified with collection fetch; applying in memory,意思是它把整张表查到内存里再切页,数据一大直接 OOM。 - 一条查询别 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=true或logging.level.org.hibernate.SQL=DEBUG,看到同一条select ... where ...=?反复出现就是 N+1。 - CI 防回归:用
datasource-proxy或assertj-db在集成测试里断言查询次数,一旦哪次改动重新引入 N+1,测试直接红。
小结
- N+1 来自
FetchMode.SELECT,不是LAZY,改EAGER不是解法。 - 决策口诀:单条不复杂查询用
@EntityGraph;条件复杂用JOIN FETCH;要分页或嵌套对象图用default_batch_fetch_size;只读列表直接 DTO 投影。 JOIN FETCH+ 分页 = 内存分页 + OOM 风险,务必避开。- 在 CI 里加查询次数断言,比事后看 APM 更可靠。
来源
- The N+1 Problem: All Four Fetch Strategies Explained — umur
- How to Avoid the N+1 Query Issue in JPA and Hibernate — JavaThinking
- Hibernate N+1 — 12-Second Page Due to 101 SQL Queries — TheCodeForge
- JPA N+1 문제 해결 — fetch join vs @EntityGraph vs BatchSize — riberio
- Optimización del problema N+1 — Certidevs