《Spring Boot 高级》6.3 连接获取与延迟加载

从连接池角度解释一次事务里连接何时获取、何时归还,LazyConnectionDataSourceProxy 为何存在,延迟加载为什么踩空并抛 LazyInitializationException,open-in-view 的作用与代价,以及 EntityGraph 与 JOIN FETCH 的取舍。

本节目标:把「连接从哪来、什么时候还」和「延迟加载为什么踩空」两件事,追到 DataSource 绑定与 Hibernate 代理初始化的层面,并给出 open-in-view 的取舍。
适用版本:Spring Boot 4.1.x(Java 21)

6.3 连接获取与延迟加载

6.2 讲了 flush 什么时候把 SQL 发出去。发 SQL 之前还差一环——得先有一个数据库连接。本节先看连接在事务里的完整生命周期(这决定了你的连接池要开多大),再看延迟加载为什么会在事务边界处「踩空」,最后对比 EntityGraph、JOIN FETCH 与延迟加载的取舍。

默认连接池是 HikariCP(4.1.1 实测为 7.0.2),相关配置在 spring.datasource.hikari.* 下。

6.3.1 一次事务里连接的完整生命周期

把 6.1 的调用链接着往下看,连接相关的部分集中在两处。

事务开始时(JpaTransactionManager#doBegin):

ConnectionHandle conHandle = getJpaDialect().getJdbcConnection(em, definition.isReadOnly());
ConnectionHolder conHolder = new ConnectionHolder(conHandle);
TransactionSynchronizationManager.bindResource(getDataSource(), conHolder);
txObject.setConnectionHolder(conHolder);

HibernateJpaDialect#getJdbcConnection 返回的是一个 HibernateConnectionHandle,它的 getConnection() 实现是:

return this.session.getJdbcCoordinator().getLogicalConnection().getPhysicalConnection();

也就是「向 Hibernate 的 JDBC 协调器要那条逻辑连接背后的物理连接」——这一步才真正从连接池取连接。取到之后,ConnectionHolder 被绑定到 DataSource 这个 key 上,于是同一事务里所有走同一个 DataSource 的代码(JPA 与 JdbcTemplate 混用)拿到的是同一条连接。这就是 Spring 事务能把 JPA 与 JDBC 统一到一条连接上的机制。

事务结束时(JpaTransactionManager#doCleanupAfterCompletion):

TransactionSynchronizationManager.unbindResource(getDataSource());
getJpaDialect().releaseJdbcConnection(conHandle, em);

归还连接、解绑。注意归还发生在清理阶段,而不是方法体返回之后——只要事务没结束,连接就一直被占着。

6.3.2 为什么「方法一进来就占了连接」

这句话对 JDBC 事务是字面成立的:DataSourceTransactionManager#doBegin 直接调用 DataSourceUtils#getConnection(dataSource),而 doGetConnection 里就是 dataSource.getConnection()——方法一进入,连接池里就少了一条连接。

对 JpaTransactionManager 要精确一点,分两种情况:

情况物理连接何时被取
readOnly = true 或显式指定了隔离级别事务开始时就取(beginTransaction 里要拿连接去 setReadOnly / setTransactionIsolation)
默认设置ConnectionHolder 在开始时绑定,物理连接在第一条 SQL 需要时由 Hibernate 取出

但两种情况有一个共同结论:连接一旦取出,就持有到事务提交/回滚。这带来一个非常实际的后果——下面这种写法会把连接池迅速耗尽:

@Transactional
public void importBooks(List<BookDto> dtos) {
    for (BookDto dto : dtos) {
        remoteBookService.enrich(dto);   // 慢 HTTP 调用,期间连接被占着
        repository.save(dto.toEntity());
    }
}

方法体里每一次慢调用都发生在事务内,而事务在方法一进来就持有了连接(JDBC 场景)或至少在第一条 save 之后持有到方法结束(JPA 场景)。10 个并发请求就能把默认连接池占满,后续请求卡在 HikariPool ... Connection is not available, request timed out。正确做法是把远程调用挪到事务外,或拆成「先取数、后开事务写」。

一个量级上的自查方法:把 spring.datasource.hikari.maximum-pool-size 调到很小(如 2),用 ab 或 wrk 压一个带慢外部调用的接口,观察是否出现连接获取超时——如果出现,就说明你的连接持有时间被业务逻辑拉长了,而不是池子太小。

6.3.3 LazyConnectionDataSourceProxy 存在的意义

既然「事务开始就绑定连接持有者」,那有没有可能连持有者都不急着拿物理连接?这就是 org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy 的用途。

它是一个 DataSource 的包装器(继承 DelegatingDataSource),getConnection() 返回的是一条代理连接(内部是 LazyConnectionInvocationHandler)。在你第一次真正执行语句之前,它不会向底层 DataSource 要物理连接;一旦有语句要跑,才真正取。它还有 setReadOnlyDataSource(...),可以给只读事务指向另一个数据源(如只读副本)。

它解决的场景是:一个事务里可能根本没执行任何 SQL。比如某个方法开了事务,但走了缓存命中分支直接返回,或者只做了一次校验。默认情况下事务开始时就会占一条连接,白白浪费;套上 LazyConnectionDataSourceProxy 后,这种「空事务」不会碰连接池。

代价是:连接上的 autoCommit、隔离级别、只读标志这些设置需要被「延迟到真正取连接时再应用」,所以它提供了 setDefaultAutoCommit / setDefaultTransactionIsolation 等方法来声明默认值。配置不当可能出现「连接属性与预期不符」的问题,这也是它没有成为默认值的原因。

6.3.4 4.1 的 connection-fetch=lazy

Spring Boot 4.1 新增了一个属性,把上面这个代理变成了开箱即用:

spring:
  datasource:
    connection-fetch: lazy   # 取值 eager(默认)| lazy

按 4.1 官方发布说明,当它设为 lazy 时,自动配置的连接池 DataSource 会被 LazyConnectionDataSourceProxy 包一层,于是「物理连接只在真正需要执行 JDBC 语句时才从池中取出」。这与 6.3.3 讲的是同一个机制,只是从「手写一个 @Bean」变成了一个属性。

什么时候值得开:读多写少、缓存命中率高、事务里有较多「不碰数据库」分支的服务。什么时候别开:连接属性(隔离级别、只读、autoCommit)对你有严格要求、又不想逐项声明默认值的时候——此时延迟应用属性反而容易出隐蔽问题。

6.3.5 LazyInitializationException 的成因

延迟加载的开关是 jakarta.persistence.FetchType:LAZY 与 EAGER。对 @ManyToOne / @OneToOne,JPA 默认是 EAGER;对 @OneToMany / @ManyToMany,默认是 LAZY。

当一个关联被标记 LAZY 时,Hibernate 不会立刻把它查出来,而是塞一个代理对象(对集合是 PersistentCollection,对单实体是 Hibernate 代理)。这个代理里带着「我是哪个实体的哪个关联」的信息,第一次访问它的非主键属性时才回库初始化。

问题就出在这里:代理初始化需要一个受管的持久化上下文。而上下文在事务结束时被 closeAll() 关掉了(6.1.7)。一旦你在事务外访问一个还没初始化的代理,Hibernate 找不到上下文,就抛:

org.hibernate.LazyInitializationException:
could not initialize proxy [com.example.library.Book#1] - no Session

这句话里的 no Session 就是「当前没有持久化上下文」的直接翻译。看一个典型翻车现场:

@Transactional
public Loan findLoan(Long id) {
    return loanRepository.findById(id).orElseThrow();   // 事务结束,Loan 变 detached
}

// 在 Controller 里(事务已结束):
Loan loan = service.findLoan(1L);
loan.getBook().getTitle();   // book 是 LAZY 代理 → LazyInitializationException

Loan 本身是加载出来的实体,但它的 book 字段可能是个未初始化的代理;事务一关,代理就没法初始化了。

判定方法很直接:看异常栈里有没有 no Session / could not initialize proxy,以及被访问的字段是不是 LAZY 关联。根因永远是「在上下文之外触碰了未初始化的代理」,而不是「延迟加载本身有问题」。

6.3.6 open-in-view 的作用与危害

Spring Boot 默认把 spring.jpa.open-in-view 设为 true,这个默认值从 Boot 2.0 起就在启动时打一条 WARN,提醒你「数据库查询可能发生在视图渲染阶段」。它背后的实现是 org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor:

preHandle(WebRequest):
    if (TransactionSynchronizationManager.hasResource(emf)) { ...计数... }
    else {
        EntityManager em = createEntityManager();
        TransactionSynchronizationManager.bindResource(emf, new EntityManagerHolder(em));
    }

afterCompletion(WebRequest, Exception):
    EntityManagerHolder emHolder = TransactionSynchronizationManager.unbindResource(emf);
    EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());

它做的是:在请求进入 Controller 之前就打开一个 EntityManager 并绑到线程上,直到整个请求处理完(包括视图渲染/JSON 序列化)才关闭。这样延迟加载的代理在整个请求期间都有上下文可用,LazyInitializationException 就不再出现——这正是它「看起来很方便」的原因。

但它带来三个真实代价:

  1. 连接持有时间被拉长到整个请求。 虽然 6.3.2 说过物理连接通常在第一条 SQL 时才取,但一旦取了,就会持有到 afterCompletion——把「事务内持有」放大成「整个请求持有」,高并发下连接池压力显著上升。
  2. 掩盖 N+1。 在序列化阶段逐条初始化代理,会静默地发出成百上千条 SQL,而代码里看不出来。这类问题在 open-in-view 打开时几乎无法从代码审查发现。
  3. 把数据访问泄漏到 Web 层。 序列化器(如 Jackson)在遍历对象图时触发查询,等于让 Web 层决定发什么 SQL,破坏了分层。

社区的共识做法是关掉它:

spring:
  jpa:
    open-in-view: false

关掉之后,延迟加载的代理只在事务内可用。这就要求 Controller 拿到的是「已经取好的数据」——要么在事务内初始化好需要的关联,要么用 DTO 投影。这不是退回原始状态,而是把「取什么数据」的决策交回给 service 层。可以用 /actuator/configprops 或启动日志确认当前值。

6.3.7 EntityGraph / JOIN FETCH 与延迟加载的取舍

要在事务内一次性把关联取出来,有两条主流路径。

路径一:JPQL 的 JOIN FETCH。 在查询里显式声明要连带取出的关联:

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

join fetch 生成的是一条带 JOIN 的 SQL,关联对象在同一个查询里被初始化,之后访问不会再触发额外 SQL。它的问题是不可组合:两个 join fetch 一个集合关联会产生笛卡尔积,Hibernate 会抛 MultipleBagFetchException,需要拆成两次查询或用 Set 代替 List。

路径二:JPA 的 EntityGraph。 jakarta.persistence.EntityGraph 是 2.1 引入的「抓取计划」,用声明式的方式指定要 EAGER 取哪些关联,而不是写死在 JPQL 里:

@EntityGraph(attributePaths = {"book", "member"})
Optional<Loan> findDetailById(Long id);

EntityManager 也有对应的重载:find(EntityGraph<T>, Object, FindOption...)。EntityGraph 的好处是同一个查询可以在不同调用点用不同的抓取计划,避免为每种组合写一条 JPQL。它在实现上通常也翻译成 JOIN(Hibernate 视情况用 JOIN 或二次查询)。

三者取舍:

方案何时取关联优点代价
延迟加载(LAZY)首次访问时按需、简单事务外踩空;易 N+1
JOIN FETCH查询时一条 SQL 搞定、可控不可组合、易笛卡尔积
EntityGraph查询时声明式、可按调用点定制复杂图仍会多查询

通用建议:列表/分页查询永远不要 EAGER 取集合(会 N+1 或大笛卡尔积),用 DTO 投影或 EntityGraph 按需取;单条详情查询可以用 JOIN FETCH 或 EntityGraph 一次性取全。hibernate.default_batch_fetch_size(批量抓取大小)是延迟加载的折中——把 N 次单条初始化合并成 ceil(N/size) 次 IN 查询,配置代价低,值得默认打开。

6.3.8 亲手观察连接与延迟加载

实验一:看连接什么时候被占。 把池子调到 1,在事务里加一段 Thread.sleep,另一个线程请求接口会卡在连接获取:

spring:
  datasource:
    hikari:
      maximum-pool-size: 1
logging:
  level:
    com.zaxxer.hikari: debug

打开 Hikari 的 debug 日志后,能看到 Connection acquired / Connection released 的成对出现,间隔就是连接被持有的时长。

实验二:复现 LazyInitializationException。 用 6.3.5 的 findLoan 写法,在 @Transactional 方法外访问 loan.getBook().getTitle(),同时把 spring.jpa.open-in-view 设为 false。异常栈里会出现 no Session。再把该属性设为 true,同一个调用就不再抛异常——两相对比,open-in-view 的作用一目了然。

实验三:数一次请求发了几条 SQL。 把 org.hibernate.SQL 设为 debug,请求一个返回 10 条 Loan 列表的接口,看日志里 select 的条数。如果是 1 + 10 条(一次列表 + 每条的关联),就是 N+1;换成 @EntityGraph 或 DTO 投影后应降到 1~2 条。

6.3.9 排障清单

现象大概率原因对策
LazyInitializationException事务外访问未初始化的 LAZY 代理事务内初始化、DTO 投影,或谨慎开启 open-in-view
Connection is not available事务内混入慢外部调用,连接被长期占用把远程调用移出事务;或缩短事务
一次请求几百条 SQLN+1,被 open-in-view 掩盖关 open-in-view;用 EntityGraph/JOIN FETCH/batch_fetch_size
MultipleBagFetchException同时 join fetch 两个 List 集合改用 Set,或拆成两次查询
空事务也占连接事务开始即取连接4.1 可试 spring.datasource.connection-fetch=lazy

需要说明的是,spring.datasource.connection-fetch 是 4.1 新增属性,本机未做压测对比;上面的效果描述来自官方发布说明,实际收益请在你的负载下自行测量。

小结

  • 连接的生命周期是「事务开始绑定 → 首次需要时取物理连接 → 事务清理阶段归还」,一旦取出就持有到事务结束。
  • JDBC 事务在方法进入时就取连接;JPA 事务在 readOnly/显式隔离级别时同样在开始时取,否则延迟到首条 SQL,但都持有到提交。
  • LazyConnectionDataSourceProxy 让物理连接延迟到首条语句才取,用于「可能有空事务」的场景;4.1 用 spring.datasource.connection-fetch=lazy 一键开启。
  • LazyInitializationException 的根因是「在持久化上下文之外访问未初始化的 LAZY 代理」,异常里的 no Session 就是证据。
  • spring.jpa.open-in-view 默认 true,用请求级的 EntityManager 消除该异常,代价是连接持有变长、掩盖 N+1、数据访问泄漏到 Web 层;生产建议关闭。
  • JOIN FETCH 与 EntityGraph 都是「查询时取关联」的手段,取舍点在可组合性与复杂度;hibernate.default_batch_fetch_size 是延迟加载的低成本折中。

本章从持久化上下文讲到 flush,再讲到连接与延迟加载,串起了「一次 @Transactional 方法背后框架到底做了什么」。下一章转向事务本身:事务同步回调与事件发布,看 @TransactionalEventListener 是如何踩在事务边界上的。

阅读导航:上一节:6.2 事务与 Flush 时机 · 下一节:7.1 事务同步与事件 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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