《Spring Boot 实战》6.3 N+1 与抓取策略

N+1 是 JPA 生产事故的头号来源。本节从 SQL 日志里还原 N+1 的形态,对比 JOIN FETCH、@EntityGraph 与 @BatchSize 三种抓取策略的代价,讲清分页叠加 fetch join 的经典陷阱与 LazyInitializationException 的根因,并给出定位与验收方法。

本节目标:看懂 N+1 在 SQL 日志里的形态,掌握 JOIN FETCH、@EntityGraph、@BatchSize 的适用与代价,避开分页叠加 fetch join 的陷阱,并建立一套可验收的定位方法。
适用版本:Spring Boot 4.1.x(Java 21)

6.3 N+1 与抓取策略

6.1 把实体挡在了 Service 里,6.2 把查询条件写对了。但查询能跑通不等于性能达标——JPA 最经典的生产事故是 N+1 查询:一次列表请求,数据库收到 N+1 条 SQL。它在开发机上「快得没感觉」,一上量就把连接池和数据库打满。本节从「N+1 长什么样」开始,逐个拆解抓取策略。

6.3.1 N+1 的产生机理

先看一段完全正常的代码:

public List<String> listBorrowedBookTitles(Long memberId) {
    List<Loan> loans = loanRepository.findByMemberId(memberId);
    return loans.stream()
            .map(loan -> loan.getBook().getTitle())
            .toList();
}

Loan.book 是 @ManyToOne(fetch = FetchType.LAZY),查询 Loan 时不会带上 Book,而是给每条 Loan 装一个 Book 的代理。当 loan.getBook().getTitle() 第一次访问代理时,Hibernate 才去数据库把那条 Book 查出来。于是:

  • 第 1 条 SQL:查出该会员的所有 Loan(假设 20 条)。
  • 第 2~21 条 SQL:为每条 Loan 单独查一次 Book。

总计 21 条 SQL,即 1 + N。如果再把 Loan.member 也遍历一遍,就变成 1 + N + N。这就是 N+1 的机理:懒加载把「一次 join」拆成了「一次主查询 + N 次按需查询」,而循环访问把 N 次触发全部释放出来。

开启 SQL 日志后,形态一目了然(示例输出,需连接真实数据库):

Hibernate: select l1_0.id, l1_0.book_id, l1_0.member_id, l1_0.borrowed_at, l1_0.due_at, l1_0.status
           from loan l1_0 where l1_0.member_id=?
Hibernate: select b1_0.id, b1_0.title, b1_0.author, b1_0.category from book b1_0 where b1_0.id=?
Hibernate: select b1_0.id, b1_0.title, b1_0.author, b1_0.category from book b1_0 where b1_0.id=?
Hibernate: select b1_0.id, b1_0.title, b1_0.author, b1_0.category from book b1_0 where b1_0.id=?

判断特征很简单:日志里出现「一条主查询 + 大量结构相同、只有主键不同的单行查询」,就是 N+1。注意开发环境常因为数据量小(比如 3 条)而看不出问题,所以不能靠「跑着不慢」来判断,要靠日志或 SQL 计数来判断。

6.3.2 第一反应:JOIN FETCH

最直接的修法是让主查询顺带把关联取回来。JPQL 的 join fetch 就是干这个的:

@Query("""
        select l
        from Loan l
        join fetch l.book
        join fetch l.member
        where l.member.id = :memberId
        """)
List<Loan> findDetailByMemberId(@Param("memberId") Long memberId);

join fetch 会把 book 和 member 用内连接一起查出来,一条 SQL 拿到全部数据,代理被真实对象替换,后续访问不再触发查询。注意 join fetch 与普通 join 的区别:普通 join 只用于过滤(关联对象仍是代理),join fetch 才会把关联数据装进对象。

join fetch 的三个坑:

坑一:内连接语义。 join fetch l.book 默认是内连接,如果 book_id 为 null 或关联不存在,该行会被丢掉。需要保留时用 left join fetch。

坑二:多个集合 fetch 会笛卡尔积。 同时 join fetch 两个 @OneToMany 集合,行数是两个集合大小的乘积。Hibernate 会警告并可能抛 MultipleBagFetchException(对 List 而言)。要抓多个集合,只能一个用 join fetch,另一个用 @BatchSize。

坑三:别名与 distinct。 Hibernate 6 起,join fetch 的别名语义有变化,需要按新写法处理;集合 fetch 结果重复时加 distinct(在 Hibernate 6+ 里 distinct 更多是语义提示,不会真的去重,但仍是推荐写法)。

6.3.3 @EntityGraph:不改查询的抓取声明

join fetch 要改 JPQL;如果查询是派生查询或不想动 SQL,用 @EntityGraph 声明抓取路径:

public interface LoanRepository extends JpaRepository<Loan, Long> {

    @EntityGraph(attributePaths = {"book", "member"})
    List<Loan> findByMemberId(Long memberId);
}

@EntityGraph 会在不改写方法名的前提下,为这条查询附加一个抓取图,效果上等价于 left join fetch。它的好处是:

  • 可叠加在派生查询上,不需要把方法改写成 @Query。
  • 抓取路径可复用到多个方法:把 @EntityGraph 定义成具名图(@NamedEntityGraph 或 @EntityGraph 的 name),多个查询共享。
  • 可分场景:列表接口只抓 book,详情接口再抓 member。

@EntityGraph 的坑与 join fetch 同源:它不解决集合分页问题(见 6.3.4),对集合路径用 attributePaths 同样可能触发内存分页。

6.3.4 分页 + fetch join:最经典的陷阱

把 join fetch 用在集合关联上,又叠加分页,会撞上 Hibernate 的经典警告:

@Query(value = "select b from Book b join fetch b.loans",
       countQuery = "select count(b) from Book b")
Page<Book> findWithLoans(Pageable pageable);

示例输出(需真实数据库):

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory

含义是:数据库的 limit/offset 无法在「父行 + 子行」被 join 放大后正确工作,Hibernate 只能放弃在 SQL 层分页,改成把符合条件的全部数据查回内存,再在内存里切页。数据量一大,这就是一次全表加载 + OOM。

为什么不能直接分页?因为一行 Book join 出 20 行 Loan,limit 10 限制的是「20 行里的前 10 行」,切出来可能是「1 本书的 10 条借阅记录」,不是「10 本书」。分页单位错了。

正解是两步走:先分页查主键,再按主键抓取。

public interface BookRepository extends JpaRepository<Book, Long> {

    @Query("select b.id from Book b where b.category = :category")
    Page<Long> findIdsByCategory(@Param("category") String category, Pageable pageable);

    @Query("select distinct b from Book b join fetch b.loans where b.id in :ids")
    List<Book> findWithLoansByIds(@Param("ids") List<Long> ids);
}

Service 里先拿到当前页的 id 列表,再用 in :ids 一次性把这一页的关联数据抓全。这样分页在 SQL 层正确生效(findIdsByCategory 只查 id,不会被集合放大),抓取也只有一条 SQL。

另一种更省事的做法是干脆不用 fetch join,改用 @BatchSize(下一节)——分页照常工作,关联数据按批加载。工程上后者更常见,因为它不需要改查询结构。

6.3.5 @BatchSize 与全局批量抓取

@BatchSize 的思路和前面两种都不同:它不消除懒加载,而是把「逐个查」改成「按批查」。默认懒加载一条 Loan 查一次 Book;加了批量后,一次把 50 个 Book 一起查回来。

@Entity
public class Book {

    @BatchSize(size = 50)
    @OneToMany(mappedBy = "book", fetch = FetchType.LAZY)
    private List<Loan> loans = new ArrayList<>();
}

更推荐的是全局配置,不用在每个关联上标:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

default_batch_fetch_size 会对所有懒加载关联生效,把 N 次查询压缩成 ceil(N / 100) 次。100 本书的列表,原来 100 次 Book 查询变成 1 次。这个属性是性价比最高的一个优化:加一行配置,几乎不用改代码,就能把绝大多数 N+1 从「N 次」压到「常数次」。

三种抓取策略放在一起对比:

策略查询次数对分页改动范围适用场景
join fetch1集合上有陷阱改 JPQL单条详情、单集合
@EntityGraph1集合上有陷阱加注解派生查询、多场景复用
@BatchSize / 全局ceil(N/batch)无影响一行配置或注解列表页、分页、兜底

工程上的常见组合是:详情类查询用 @EntityGraph 精确抓取,列表类查询交给全局 default_batch_fetch_size 兜底。 join fetch 则留给「确实需要一次 SQL 拿全、且不涉及集合分页」的场景。

6.3.6 LazyInitializationException 的根因

LazyInitializationException 是 N+1 的近亲,很多团队靠它「发现」了抓取问题,但根因和修法常常被搞反。

它的根因只有一句话:在 Hibernate Session 关闭之后,访问了未初始化的懒加载代理。 Session 的生命周期由事务决定——事务提交,Session 就关闭。所以任何「事务外访问懒加载」都会抛这个异常。

两种典型的错误修法:

错误修法一:把 spring.jpa.open-in-view 打开。 这会让 Session 一直开到请求结束,异常消失了,但 N+1 会静默地在视图层触发——本来会报错的地方,变成了每次渲染都多打 N 条 SQL。Open Session In View 是「把问题藏起来」,不是解决。

错误修法二:在实体上把关联改成 EAGER。 FetchType.EAGER 会让每次查 Loan 都强制 join Book,即使这次根本用不到。它把「按需的 N+1」换成了「无条件的 join 放大」,通常更糟。

正确修法是回到「数据在事务内取全」:

@Transactional(readOnly = true)
public List<LoanDto> listDetail(Long memberId) {
    // 抓取策略保证关联已初始化,转换在事务内完成
    return loanRepository.findDetailByMemberId(memberId)
            .stream()
            .map(loanMapper::toDto)
            .toList();
}

也就是说,N+1 和懒加载异常要用同一套思路解决:不是「让代理活得更久」,而是「在正确的位置把需要的数据一次取回」。6.1 讲的「转换必须在事务内」和这里的「抓取策略」是同一枚硬币的两面。

6.3.7 用日志与统计指标定位

靠肉眼翻日志不可靠,要建立可验收的定位手段。

第一步:打开 SQL 日志。 开发环境至少要能看到 SQL:

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

org.hibernate.SQL=DEBUG 打印 SQL,org.hibernate.orm.jdbc.bind=TRACE(Hibernate 6 起的参数绑定日志分类)打印绑定值。日志里若出现大量同构单行查询,就是 N+1。

第二步:打开 Hibernate 统计。 它会给出「本次会话执行了多少条语句」的精确计数:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

配合 SessionFactory.getStatistics() 可以在测试里断言「一次列表查询的 SQL 条数不超过某个上限」——把性能约束写成会失败的测试,比事后翻日志可靠得多。

第三步:接入 SQL 计数代理。 生产或压测环境用 p6spy、datasource-proxy 这类 JDBC 代理,按请求统计 SQL 条数与耗时,能直接看到「哪个接口的 SQL 条数随数据量线性增长」。这是发现 N+1 最有效的手段——N+1 的典型特征就是「SQL 条数随结果集大小线性增长」。

验收标准可以量化成一条:一个列表接口,SQL 条数应当与「分页大小」无关(理想是常数条)。 如果翻页时 SQL 条数随之增长,就是 N+1 没修干净。

6.3.8 常见坑

坑一:只在开发环境测。 开发库只有几行数据,N+1 完全看不出。要用接近生产的数据量、或直接用 SQL 计数断言。

坑二:用 EAGER 一劳永逸。 它把按需加载变成无条件加载,往往制造更多 join 和更大结果集,还会级联触发下一层懒加载。

坑三:join fetch 抓两个 List 集合。 触发 MultipleBagFetchException 或笛卡尔积。多个集合只能用「一个 fetch + 其余 @BatchSize」。

坑四:集合 fetch 配分页。 见 6.3.4,会退化成内存分页(HHH000104),数据量一大就 OOM。

坑五:打开 Open Session In View 掩盖问题。 异常没了,N+1 转成隐性开销,监控里表现为「数据库 QPS 异常高但代码看不出问题」。

坑六:忘记 default_batch_fetch_size 的代价。 批量的 in (?, ?, ...) 参数过多时,部分数据库对参数个数有上限。100 是常见安全值,超大结果集不要一味调大。

小结

  • N+1 的机理是懒加载把「一次 join」拆成「一次主查询 + N 次按需查询」,循环访问释放了全部 N 次;特征是日志里大量同构单行查询。
  • join fetch 一条 SQL 拿全,但有内连接语义、多集合笛卡尔积、集合分页三个坑。
  • @EntityGraph 等价于 left join fetch,可叠加在派生查询上并跨方法复用,但同样不解决集合分页。
  • 集合 fetch 叠加分页会触发 HHH000104,退化成内存分页;正解是「先分页查主键,再按主键抓取」,或改用 @BatchSize。
  • @BatchSize 与全局 hibernate.default_batch_fetch_size 把 N 次查询压成 ceil(N/batch) 次,是性价比最高的一行配置,对分页无影响。
  • LazyInitializationException 的根因是事务外访问懒加载代理;修法是「在事务内取全数据」,而不是打开 Open Session In View 或改成 EAGER。
  • 定位 N+1 靠三件套:SQL 日志、generate_statistics、SQL 计数代理;验收标准是「列表接口的 SQL 条数与分页大小无关」。

数据访问层到此收束:6.1 划清了实体与 DTO 的边界,6.2 给了复杂查询的选型,6.3 解决了抓取性能。下一章转向接口契约——7.1 用 Springdoc 与 OpenAPI 把这一层查询接口变成可维护、可生成客户端的契约。

阅读导航:上一节:6.2 复杂查询的三种写法 · 下一节:7.1 Springdoc 与 OpenAPI 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计