《Spring Boot + JPA 实战》第9招 N+1怎么治

01、痛点

凌晨两点,运维群里弹出一条告警:订单服务数据库连接池耗尽,订单列表接口响应时间从200毫秒飙到8秒。开发组的第一反应是“是不是数据库被打满了”,DBA拉出慢SQL一看,没有一条复杂SQL,全是最简单的主键查询,但数量多得吓人——一次分页请求,居然执行了201条SQL。

我是老李,一个在后端和数据库之间来回折腾了十年的程序员。这种场面我并不陌生。代码仓库里,订单查询逻辑只有几行:先查一页订单,再在循环里取客户名称和订单明细数量。Java代码看起来完全正常,单元测试也全过。但一到数据量上来,问题就暴露了。这就是JPA最典型的生产性能问题——N+1。

很多团队用Spring Boot + JPA做业务,功能跑通了,性能却埋在ORM的加载策略里。为什么现在要讲?因为这类问题不会在开发环境出现,却会在生产环境凌晨准时爆炸。它不靠复杂SQL,而是靠SQL数量线性增长,把你的连接池慢慢拖死。

02、是什么

N+1,指的是ORM在查询主实体列表时,先发1条SQL拿到N条主记录;随后在访问每条记录的关联属性时,又逐条发SQL,总共需要N+1条甚至更多。

以一个订单列表为例。假设Order关联Customer和OrderItem,两个关联都配置为LAZY。查询100个Order时:

  • 第1条SQL:查询100个Order。
  • 接下来访问每个Order的customer.getName(),触发100条customer查询。
  • 再访问每个Order的items.size(),又触发100条order_item查询。

最终SQL数量是1 + 100 + 100 = 201条。如果页大小是200,SQL就是401条。数据量越大,SQL数量线性增长。

JPA中的LAZY不是不加载,而是延迟到真正访问属性时才加载。Hibernate返回的常常是代理对象,例如CustomerProxy。它看起来像Customer,但内部没有数据;当你调用getName()时,代理对象才向数据库发一条SQL。这个过程被封装得很好,所以开发者常常无感知。

还有个概念要分清:JPA规范里,@ManyToOne和@OneToOne默认是EAGER,@OneToMany和@ManyToMany默认是LAZY。不过为了性能,很多项目会显式把@ManyToOne改成LAZY。问题不在于LAZY本身,而在于加载时机没有被显式管理。

03、为什么

N+1之所以泛滥,根因是ORM把“对象导航”变成了“SQL生成”,但开发者看到的是对象,不是SQL。

在数据量小的时候,多几十条SQL完全不敏感。开发环境、测试环境可能只有几十条数据,连接池充裕,日志也没开全。等到生产环境数据量上来,N+1就会从温顺的小问题变成连接池杀手。

一个常见的错误做法是:既然LAZY会触发N+1,那我把所有关联都改成EAGER不就好了?答案是不能。

EAGER看似消除了延迟加载,实际上把“何时加载”和“加载多少”这两个问题混在了一起。如果所有关联都改成EAGER,Hibernate会在主查询里生成大量Join。更危险的是,当多个一对多关联同时EAGER时,会产生笛卡尔积。

举个例子:Order同时EAGER加载items和payments,两个都是一对多。查100个订单,其中每个订单平均4个items、3个payments。Hibernate如果用两个left join,返回行数会接近100×4×3=1200行。如果再加一个一对多关联,行数继续相乘。数据库需要处理巨大的中间结果集,网络传输、内存占用全部膨胀。这不是根治N+1,而是制造更隐蔽的性能炸弹。

所以,解决N+1必须回答两个独立的问题:第一,什么时候加载?第二,加载多少?LAZY和EAGER只回答第一个问题,而且回答得过于粗糙;真正需要的是针对每个接口,设计明确的加载边界和查询形状。

04、架构

要理解N+1优化,先看JPA的查询执行链路。

一次查询大致经过:EntityManager -> Session -> PersistenceContext(一级缓存) -> 代理对象 -> JDBC -> 数据库。实体映射中的fetch策略,决定代理对象什么时候发SQL;而查询本身怎么写,决定一次SQL能带回多少数据。

围绕这条链路,有四种常用工具:

Fetch Join:在JPQL里使用join fetch,让主实体和关联实体在同一条SQL里加载。它直接初始化关联属性,不再生成代理。适合列表页需要同时展示主表和少数字段的场景。

EntityGraph:JPA 2.1提供的抓取图机制。可以在实体或Repository方法上声明一个图,动态覆盖映射里的LAZY/EAGER策略。它比手写join fetch更灵活,尤其适合不同接口对关联字段有不同需求的情况。

Batch Fetch:Hibernate的扩展能力。它不把关联都Join到主查询,而是把原本N条逐条ID查询,合并成一条IN查询。例如设置default_batch_fetch_size为100,访问100个customer时只发1条IN查询。它仍然延迟加载,但大幅减少了SQL数量。

Projection:只查询DTO或接口投影,不返回托管实体。既然不返回实体,也就没有代理对象和延迟加载,从根源上避免了N+1。它同时解决了“加载多少”的问题。

从架构角度看,Fetch Join和EntityGraph主要解决“何时加载”,Batch Fetch降低延迟加载的次数,Projection则直接减少加载量。实际项目中往往是组合使用,而不是二选一。

05、实战

下面用一个Spring Boot + JPA项目模拟真实场景。实体定义如下:

@Entity
public class Order {
  @Id private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  private Customer customer;

  @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
  private List<OrderItem> items;
}

Customer和OrderItem略过,只需要知道Order.customer是ManyToOne,Order.items是OneToMany。

第一版:故意产生200+ SQL

List<Order> orders = orderRepository.findAll(pageable).getContent(); // 1条SQL
for (Order order : orders) {
    order.getCustomer().getName();   // 每个订单1条,共100条
    order.getItems().size();         // 每个订单1条,共100条
}

访问100个Order,最终SQL数1 + 100 + 100 = 201。这就是典型的N+1。

优化一:Fetch Join

@Query("select distinct o from Order o " +
       "join fetch o.customer c " +
       "left join fetch o.items i")
List<Order> findAllWithDetails();

这里用distinct是因为left join fetch一对多集合会让根实体重复。经过这条JPQL,主查询和两个关联都被加载进同一个结果集,SQL数量降到1条。但如果关联集合很大,结果集行数会膨胀,需要结合业务判断。

优化二:EntityGraph

@EntityGraph(attributePaths = {"customer", "items"})
@Query("select o from Order o")
List<Order> findAllWithGraph();

EntityGraph会覆盖实体上的LAZY设置,按图加载customer和items。对于包含一个ManyToOne和一个OneToMany的情况,Hibernate通常能在少量SQL内完成加载,不再逐条查询。它的好处是,当多个接口需要不同关联字段时,可以在方法上声明不同的图,而不是写死join fetch。

优化三:Projection

如果订单列表页根本不展示完整items,只需要客户名和明细数量,使用投影更合适:

public interface OrderProjection {
    Long getId();
    String getCustomerName();
    Long getItemCount();
}
@Query("select o.id as id, c.name as customerName, " +
       "(select count(i) from OrderItem i where i.order = o) as itemCount " +
       "from Order o join o.customer c")
List<OrderProjection> findOrderProjections();

这条查询只返回列表页需要的三列,不要托管实体,也不存在代理对象。SQL数量稳定在1条,而且查询结果集比实体小得多。

另外,如果不想改代码,可以先配置Batch Fetch:

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 100

这样第一版代码中100个customer加载会合并成1条IN查询,100个items加载也合并成1条IN查询,总SQL数从201降到3。它不能替代查询设计,但适合作为快速止血手段。

06、洞见

JPA最大的性能危险,不是SQL写错,而是开发者根本不知道执行了多少条SQL。

我们写代码时,眼睛盯的是Java对象和业务逻辑;数据库中实际执行的SQL,往往要等到出了问题才去翻日志。很多团队甚至没有开启SQL日志。换句话说,N+1可能已经在生产环境跑了几个月,只是数据量还没到临界点。

把关联全部改为EAGER,就是在这种“看不见SQL”的状态下做出的拍脑袋决定。它可能让SQL数量变少,但单条SQL的复杂度急剧上升。多个一对多关联EAGER会让数据库Join出巨大中间结果,甚至出现笛卡尔积。表面上接口慢了,SQL少了,实际上数据库消耗更大,调优更难。

所以,判断JPA性能不能只看响应时间,还要看SQL数量和查询形状。每一条查询都要有预算:这个接口允许几条SQL?返回多少行?关联哪些字段?只有把SQL计数显性化,才能避免被ORM的便利性反噬。

07、行动

给关键接口增加SQL Count测试,把查询次数纳入验收。

具体做法是,在测试环境启用Hibernate Statistics,执行接口后断言SQL数量不超过预算。比如订单列表接口,预算设为5条;如果某次改动把SQL数量从3条拉到201条,测试立刻失败。

示例:

@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderApiSqlCountTest {

  @Autowired
  private SessionFactory sessionFactory;

  @Test
  void orderListShouldNotTriggerNPlusOne() throws Exception {
      sessionFactory.getStatistics().clear();

      mockMvc.perform(get("/orders?page=0&size=100"))
             .andExpect(status().isOk());

      long sqlCount = sessionFactory.getStatistics().getPrepareStatementCount();
      assertThat(sqlCount).isLessThanOrEqualTo(5);
  }
}

需要开启hibernate.generate_statistics=true。如果不想用Hibernate Statistics,也可以用datasource-proxy或p6spy在数据源层统计SQL,逻辑类似。

把SQL Count测试放进CI流水线,和单元测试一起跑。任何人对查询逻辑、实体映射、Repository做了改动,只要SQL数量超标,构建就失败。这样N+1就不再有潜伏机会。

除此之外,生产环境还应打开慢SQL日志,定期检查SQL数量异常接口。对关键列表、详情接口建立性能基线,查询次数和响应时间一起监控。

08、小结

N+1不是JPA的Bug,而是加载策略和查询边界不清晰的结果。它的本质是:开发者用对象思维写代码,却没有管理对象背后的SQL。

解决思路也很清晰:

  • 用Fetch Join或EntityGraph管理“何时加载”;
  • 用Projection管理“加载多少”;
  • 用Batch Fetch快速降低延迟加载产生的SQL次数;
  • 别把所有关联改成EAGER,那只会制造新的灾难;
  • 最后,用SQL Count测试守住底线。

下次写JPA查询时,不妨先问自己一句:这个接口,到底应该执行几条SQL?

09、链接与概念说明

概念说明

  • N+1查询:先查询N条主数据,再对每条数据逐条查询关联,导致SQL数量线性增长。
  • LAZY / EAGER:JPA的加载时机策略。LAZY延迟到属性访问时加载,EAGER立即加载。
  • Proxy:Hibernate为延迟加载生成的代理对象,未初始化时访问属性会触发SQL。
  • Fetch Join:JPQL的join fetch子句,在一条SQL中初始化关联实体。
  • EntityGraph:JPA抓取图,可覆盖实体映射中的fetch策略。
  • Batch Fetch:Hibernate批量抓取机制,将多条单个ID查询合并为IN查询。
  • Projection:只查询DTO或接口投影,不返回托管实体。
  • 笛卡尔积:多个一对多关联同时Join时行数相乘,导致结果集膨胀。

参考链接

posted @ 2026-10-10 23:49  李福春  阅读(3)  评论(0)    收藏  举报