bigywater.tech
Spring · 91

N+1 与 @EntityGraph、JOIN FETCH

N+1 到底发了多少条 SQL?@EntityGraph 和 join fetch 有什么区别?同时 fetch 两个集合为什么报错?batch fetch 值不值得开?这篇在 Spring Boot 4.0.8、Hibernate 7、MySQL 上用 200 个作者 × 10 本书逐个抓 SQL。

201 → 1
200 个作者,findAll 再遍历 books 的 SQL 条数,与 join fetch / @EntityGraph 的条数
300 行
两个 Set 一起 join fetch,数据库返回的行数(笛卡尔积)
201 → 5
hibernate.default_batch_fetch_size=50 之后的 SQL 条数

N+1

实测 · 本机执行输出
authorRepo.findAll() 之后遍历每个作者的 books.size()  ⇒ 2000 本书;发出 201 条 SQL(2 种)
    select … from author a1_0
    200 × select … from book b1_0 where b1_0.author_id=?
bookRepo.findAll() 之后访问每本书的 author(@ManyToOne 默认 EAGER)  ⇒ 200 个作者;发出 201 条 SQL(2 种)
    select … from book b1_0
    200 × select … from author a1_0 where a1_0.id=?

一对多的 lazy 和多对一的 EAGER 都会出现 N+1。

修法

实测 · 本机执行输出
@Query("select a from Author a join fetch a.books")  ⇒ 200 个作者,2000 本书;发出 1 条 SQL(1 种)
    select … from author a1_0 join book b1_0 on a1_0.id=b1_0.author_id
@EntityGraph(attributePaths = "books") + @Query("select a from Author a")  ⇒ 200 个作者,2000 本书;发出 1 条 SQL(1 种)
    select … from author a1_0 left join book b1_0 on a1_0.id=b1_0.author_id
@EntityGraph 加在派生查询 findByNameStartingWith("author1")  ⇒ 111 个作者,1110 本书;发出 1 条 SQL(1 种)
    select … from author a1_0 left join book b1_0 on a1_0.id=b1_0.author_id where a1_0.name like ? escape '\\'
bookRepo.withAuthor()(join fetch 把 ManyToOne 一起取回)  ⇒ 200 个作者;发出 1 条 SQL(1 种)
    select … from book b1_0 join author a1_0 on a1_0.id=b1_0.author_id
DTO 投影 select new AuthorDto(name, count(b)) … group by  ⇒ 200 行 DTO;发出 1 条 SQL(1 种)
    select … from author a1_0 join book b1_0 on a1_0.id=b1_0.author_id group by a1_0.name
接口投影 findAllProjectedBy()(只有 getName)  ⇒ 200 行;发出 1 条 SQL(1 种)
    select … from author a1_0

join fetch、@EntityGraph、DTO 与接口投影都是 1 条 SQL;@EntityGraph 用 left join。

两个集合

实测 · 本机执行输出
Author:join fetch a.books join fetch a.awards(两个 List)  ⇒ 抛 InvalidDataAccessApiUsageException:cannot simultaneously fetch multiple bags: [demo.Author.awards, demo.Author.books];发出 0 条 SQL(0 种)
Poet:join fetch p.poems join fetch p.prizes(两个 Set)  ⇒ 20 个诗人;发出 1 条 SQL(1 种)
    select … from poet p1_0 join poem p2_0 on p1_0.id=p2_0.poet_id join prize p3_0 on p1_0.id=p3_0.poet_id
  对应的 SQL 在数据库里实际返回的行数:Poet join poem join prize = 300 行(20 个诗人 × 5 首 × 3 个奖);Author join book = 2000 行

两个 List 一起 fetch 抛异常;两个 Set 可以,但行数是笛卡尔积。

耗时

实测 · 本机执行输出
  N+1(findAll 再遍历 books):25.9 ms
  join fetch:7.5 ms
  @EntityGraph:7.7 ms

N+1 约是 join fetch 的 3.5 倍(本机);远程数据库差距更大。

batch fetch

实测 · 本机执行输出
findAll() 后遍历每个作者的 books.size()  ⇒ 2000 本书;发出 5 条 SQL(2 种)
    select … from author a1_0
    4 × select … from book b1_0 where b1_0.author_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
bookRepo.findAll() 后访问每本书的 author  ⇒ 200 个作者;发出 5 条 SQL(2 种)
    select … from book b1_0
    4 × select … from author a1_0 where a1_0.id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

default_batch_fetch_size 把 N+1 变成 1 + N/批大小。

分步演示

N+1
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK 21.0.12(Homebrew) / Spring Boot 4.0.8(Spring Framework 7.0.9、Tomcat 11.0.24) / Spring Cloud 2025.1.3;用 javac 编译、java 直接运行,依赖由 Maven 从 Maven Central 解析;数字每次运行会有小幅抖动(Hibernate 7、Spring Data JPA 4 随 spring-boot-starter-data-jpa;MySQL 是本机自己起的 26.7.0 实例)。Author 200 个(每人 10 本书、3 个奖项)、Poet 20 个(每人 5 首诗、3 个奖);SQL 来自 Hibernate 的 StatementInspector,按 SQL 形态归并后计数;耗时是在新事务里各取 5 次中位数,数据库在本机所以没有网络延迟
    • 没有验证:@NamedEntityGraph 与 fetch graph / load graph 的区别;@Fetch(FetchMode.SUBSELECT);@BatchSize 注解;二级缓存与查询缓存;远程数据库下的真实差距;Open-In-View 打开时 lazy 加载的行为(见第 92 篇);集合 fetch 加分页的内存分页(见第 89 篇);Hibernate 对 List 去重的内存开销
    京ICP备2026067502号-1京公网安备11010502063487号