本节目标:看懂 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 fetch | 1 | 集合上有陷阱 | 改 JPQL | 单条详情、单集合 |
@EntityGraph | 1 | 集合上有陷阱 | 加注解 | 派生查询、多场景复用 |
@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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。