Skip to content

24为什么总会遇到LazyException?如何解决? ​

你好,欢迎学习第 24 讲。在我们的实际工作中,经常会遇到 Lazy Exception,所谓的 Lazy Exception 具体一点就是 LazyInitializationException。我经常看到有些同事会遇到这一问题,而他们的处理方式都很复杂,并非最佳实践。那么这一讲,我们就来剖析一下这一概念的原理以及解决方式。

我们先从一个案例入手,看一下什么是 LazyInitializationException。

什么是 LazyInitializationException 异常? ​

这是一个重现 Lazy 异常的例子,下面我将通过 4 个步骤带你一起看一下什么是 Lazy 异常。

第一步:为了方便我们测试,我们把 spring.jpa.open-in-view 设置成 false,代码如下。

java
spring.jpa.open-in-view=false

第二步:新建一个一对多的关联实体:UserInfo 用户信息,一个用户有多个地址 Address。代码如下所示。

java
@Entity
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@Table
public class UserInfo extends BaseEntity {
   private String name;
   private Integer ages;
   private String lastName;
   private String emailAddress;
   private String telephone;
   //假设一个用户有多个地址,取数据的方式用lazy的模式(默认也是lazy的);采用CascadeType.PERSIST方便插入演示数据;
   @OneToMany(mappedBy = "userInfo",cascade = CascadeType.PERSIST,fetch = FetchType.LAZY)
   private List<Address> addressList;
}
@Entity
@Table
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
public class Address extends BaseEntity {
   private String city;
   //维护关联关系的一方,默认都是lazy模式
   @ManyToOne
   private UserInfo userInfo;
}

第三步:我们再新建一个 Controller,取用户的基本信息,并且查看一下 Address 的地址信息,代码如下。

java
@GetMapping("/user/info/{id}")
public UserInfo getUserInfoFromPath(@PathVariable("id") Long id) {
  UserInfo u1 =  userInfoRepository.findById(id).get();
  //触发lazy加载,取userInfo里面的地址信息
  System.out.println(u1.getAddressList().get(0).getCity());
  return u1;
}

第四步:启动项目,我们直接发起如下请求。

java
### get user info的接口
GET /user/info/1 HTTP/1.1
Host: 127.0.0.1:8087
Content-Type: application/json
Cache-Control: no-cache

然后我们就可以如期得到 Lazy 异常,如下述代码所示。

java
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.jpa.demo.db.UserInfo.addressList, could not initialize proxy - no Session
   at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:606) ~[hibernate-core-5.4.20.Final.jar:5.4.20.Final]
   at org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:218) ~[hibernate-core-5.4.20.Final.jar:5.4.20.Final]
   at org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:585) ~[hibernate-core-5.4.20.Final.jar:5.4.20.Final]
   at org.hibernate.collection.internal.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149) ~[hibernate-core-5.4.20.Final.jar:5.4.20.Final]
   at org.hibernate.collection.internal.PersistentBag.get(PersistentBag.java:561) ~[hibernate-core-5.4.20.Final.jar:5.4.20.Final]
   at com.example.jpa.demo.web.UserInfoController.getUserInfoFromPath(UserInfoController.java:29) ~[main/:na]

通过上面的异常信息基本可以看到,我们的 UserInfo 实体对象加载 Address 的时候,产生了 Lazy 异常,是因为 no session。那么发生异常的根本原因是什么呢?它的加载原理是什么样的?我们接着分析。

Lazy 加载机制的原理分析 ​

我们都知道 JPA 里有 Lazy 的机制,所谓的 Lazy 就是指,当我们使用关联关系的时候,只有用到被关联关系的一方才会请求数据库去加载数据,也就是说关联关系的真实数据不是立马加载的,只有用到的时候才会加载。

而 Hibernate 的实现机制中提供了 PersistentCollection 的机制,利用代理机制改变了关联关系的集合类型,从而实现了懒加载机制,我们详细看一下。

PersistentCollection 集合类 ​

PersistentCollection 是一个集合类的接口,实现类包括如下几种,如下图所示。

也就是说 Hibernate 通过 PersistentCollection 的实现类 AbstractPersistentCollection 的所有子类,对 JDK 里面提供的 List、Map、SortMap、Array、Set、SortedSet 进行了扩展,从而实现了具有懒加载的特性。所以在 Hibernate 里面支持的关联关系的类型只有下面五种。

  • java.util.List

  • java.util.Set

  • java.util.Map

  • java.util.SortedSet

  • java.util.SortedMap

关于这几种类型,Hibernate 官方也提供了扩展 AbstractPersistentCollection 的方法,不是本讲的重点,我就不多介绍了。

下面我们以 PersistentBag 为例,介绍一下 Lazy 原理的关键之处。

PersistentBag 为例详解原理 ​

通过 PersistentBag 的关键源码,我们来看一下集合类 List 是怎么实现的,代码如下所示。

java
//PersistentBag继承AbstractPersistentCollection,从而继承了PersistenceCollection的一些公共功能、session的持有、lazy的特性、entity的状态转化等功能;同时PersistentBag也实现了java.util.List的所有方法,即对List进行读写的时候包装lazy逻辑
public class PersistentBag extends AbstractPersistentCollection implements List {
    //PersistentBag构造方法,当初始化实体对象对集合初始化的时候,把当前的Session保持住
    public PersistentBag(SessionImplementor session) {
       this( (SharedSessionContractImplementor) session );
    }
    // 从这个方法可以看出来其对List和ArrayList的支持
    @SuppressWarnings("unchecked")
    public PersistentBag(SharedSessionContractImplementor session, Collection coll) {
       super( session );
       providedCollection = coll;
       if ( coll instanceof List ) {
          bag = (List) coll;
       }
       else {
          bag = new ArrayList( coll );
       }
       setInitialized();
       setDirectlyAccessible( true );
    }
    //以下是一些关键的List的实现方法,基本上都是在原有的List功能的基础上增加调用父类AbstractPersistentCollection里面的read()和write()方法
    @Override
    @SuppressWarnings("unchecked")
    public Object remove(int i) {
       write();
       return bag.remove( i );
    }
    @Override
    @SuppressWarnings("unchecked")
    public Object set(int i, Object o) {
       write();
       return bag.set( i, o );
    }
    @Override
    @SuppressWarnings("unchecked")
    public List subList(int start, int end) {
       read();
       return new ListProxy( bag.subList( start, end ) );
    }
    @Override
    public boolean entryExists(Object entry, int i) {
       return entry != null;
    }
    // toString被调用的时候会触发read()
    @Override
    public String toString() {
       read();
       return bag.toString();
    }
    .....//其他方法类似,就不一一举例了
}

那么我们再看一下 AbstractPersistentCollection 的关键实现,在 AbstractPersistentCollection 中会有大量通过 Session 来初始化关联关系的方法,这些方法基本是利用当前 Session 和当前 Session 中持有的 Connection 来重新操作 DB,从而取到数据库里面的数据。

所以我们发生的 LazyInitializationExcetion 基本都是从这个类里面抛出来的,从源码里面可以看到其严重依赖当前的 Session,关键源码如下图所示。

所以在默认的情况下,如果我们把 Session 关闭了,想利用 Lazy 的机制加载管理关系,就会发生异常了。我们通过实例看一下,在上面例子的 Controller 上加一个 debug 断点,可以看到如下图显示的内容:我们的 Address 指向了 PersistentBag 代理实例类。

同时我们再设置断点的话也可以看到,PersistentBag 被初始化的时候,会传进来 Session 的上下文,即包含 Datasource 和需要执行 Lazy 的 sql。

而需要执行 Lazy 的 sql,我们通过 debug 的栈信息可以看到其中有个 instantiate,有兴趣的同学可以 debug 看一下,关键断点信息如下图所示。

再继续 debug 的话,也会看到调用 AbstractPersistentCollection 的初始化 Lazy 的方法,如下所示。

通过源码分析和实例讲解,你已经基本上知道了 Lazy 的原理,也就是需要 Lazy 的关联关系会初始化成 PersistentCollection,并且依赖持有的 Session。而当操作 List、Map 等集合类的一些基本方法的时候会触发 read(),并利用当前的 Session 进行懒加载。

那么在实际工作中,哪些场景可能会产生 Lazy 异常呢?

Lazy 异常的常见场景与解决方法 ​

场景一:跨事务,事务之外的场景 ​

我们在前面的课时讲过 Session 和事务之间的关系,当 spring.jpa.open-in-view=false 的时候,每个事务就会独立持有 Session;那么当我们在事务之外操作 lazy 的关联关系的时候,就容易发生 Lazy 异常。

正如上面列举的 Demo 一样,一开始我就将 open-in-view 设置成了 false,而 userInfoRepository.findById(id) 又是一个独立事务,方法操作结束之后事务结束,事务结束之后 session close。所以当我们再操作 UserInfo 中 Address 对象的时候,就发生了 Lazy 异常。

实际工作中这种情况比较多见,应该如何解决呢?

第一种方式:简单粗暴地设置为 spring.jpa.open-in-view=true

通过上面的分析,我们可以知道无非就是 Session 的关闭导致了 Lazy 异常,所以简单粗暴的办法就是加大 Session 的生命周期,将 Session 的生命周期和请求的生命周期设置成一样。但是 open-in-view 可能会带来的副作用你必须要牢记于心,有以下几点。

  1. 它对 Connection 的影响是什么?连接池有没有很好地监控?利用率是怎么样的?

  2. 实体的状态在整个 Session 的生命周期之间的变更都是有效的,数据的更新是不是预期的,你要心里有数。

  3. N+1 的 SQL 是不是我们期望的?(这个下一讲我会详细介绍)性能有没有影响?等等。

第二种方式:也是简单粗暴改成 Eager 模式

我们直接采用 Eager 的模式,这样也不会有 Lazy 异常的问题。如下述代码所示。

java
public class UserInfo extends BaseEntity {
   private String name;
   //直接采用eager模式
   @OneToMany(mappedBy = "userInfo",cascade = CascadeType.PERSIST,fetch = FetchType.EAGER)
   private List<Address> addressList;

但是这种做法我不推荐你使用,因为本来我不想查 Address 信息,这样会白白地触发对 Address 的查询,导致性能有点浪费。
第三种方式:将可能发生 Lazy 的操作和取数据放在同一个事务里面

这种方式怎么理解呢?我们改造一下上面 Demo 的 Controller 的写法,代码如下所示。

java
@RestController
@Log4j2
public class UserInfoController {
   @Autowired
   private UserInfoService userInfoService;
   @GetMapping("/user/info/{id}")
   public UserInfo getUserInfoFromPath(@PathVariable("id") Long id) {
     //controller里面改调用service方法,这个service明确地返回了UserInfo和Address信息
      UserInfo u1 =  userInfoService.getUserInfoAndAddress(id);
      System.out.println(u1.getAddressList().get(0).getCity());
      return u1;
   }

Service 的实现如下所示,我们在里面用事务包装,利用事务,让可能触发 Lazy 的操作提前在事务里面发生。

java
/**
 * 我们把逻辑封装在service方法里面,方法名字语义要清晰,就是说这个方法会取UserInfo的信息和Address的信息
 * @param id
 */
@Override
@Transactional
public UserInfo getUserInfoAndAddress(Long id) {
   UserInfo u1 =  userInfoRepository.findById(id).get();
   u1.getAddressList().size();//在同一个事务里面触发lazy;不需要查询address的地方就不需要触发了
   return u1;
}

这个时候就要求我们对方法名的语义和注释比较清晰了,这个方法还有个缺点,就是 Service 返回的依然还是 UserInfo 的实体,如果在关联关系多的情况下,依然有犯错的可能性发生。

第四种方式:Service 层之外都用 DTO 或者其他 POJO,而不是 Entity

这种是最复杂的,但却是最有效的、不会出问题的方式,我们在 Service 层返回 DTO,改造一下 Service 方法,代码如下所示。

java
@Transactional
public UserInfoDto getUserInfoAndAddress(Long id) {
   UserInfo u1 =  userInfoRepository.findById(id).get();
   //按照业务要求,需要什么返回什么就可以了,让实体在service层之外是不可见的
   return UserInfoDto.builder().name(u1.getName()).addressList(u1.getAddressList()).build();/
}

而 UserInfoDto 也就是根据我们的业务需要创建不同的 DTO 即可。例如,我们只需要 name 和 address 的时候,代码如下。

java
@Data
@Builder
public class UserInfoDto {
    private String name;
    private List<Address> addressList;
}

除了 DTO 我们还可以采用任何语义的 POJO,宗旨就是 Entity 对 Service 层之外是不可见的。也可以采用"04 | 如何利用 Repository 中的方法返回值解决实际问题?"讲过的 Projection 的方式,返回接口类型的 POJO,这样控制的粒度更细,读写都可以分开。

将 Entity 控制在 Service 层还有个好处就是,有的时候我们会使用各种 RPC 框架进行远程方法调度,可以直接调用 Service 方法通过 TCP 协议,例如 Dubbo,这样也就天然支持了。

场景二:异步线程的时候 ​

既然跨事务容易发生问题,那么异步线程的时候更容易发生 Lazy 异常,你可以先自己想一想该怎么解决。

异步线程的时候,我们再套用上面的四种方式,你会发现,其中的第一种就不适用了,因为异步开启的事务和 DB 操作默认是不受 open-in-view 控制的。所以我们可以明确地知道,开启的异步方法会用到实体参数的哪些关联关系,是否需要按照上面的第三种和第四种方式进行提前处理呢?这些都是需要我们心中有数的,而不是简单地把异步开启就完事了。

场景三:Controller 直接返回实体也会产生 Lazy Exception ​

工作中我们经常为了省事,直接在 Contoller 里面返回 Entity,这个时候很容易发生 Lazy 异常,例如下面这个场景。

java
@GetMapping("/user/info/{id}")
public UserInfo getUserInfoFromPath(@PathVariable("id") Long id) {
  return userInfoRepository.findById(id).get();//controller层直接将UserInfo返回给view层了;
}

类似上面的 Contoller,我们直接将 UserInfo 实体对象当成 VO 对象,且直接当成返回结果了,当我们请求上面的 API 的时候也会发生 Lazy 异常,我们看下代码。

java
o.hibernate.LazyInitializationException  : failed to lazily initialize a collection of role: com.example.jpa.demo.db.UserInfo.addressList, could not initialize proxy - no Session
   org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.jpa.demo.db.UserInfo.addressList, could not initialize proxy - no Session

此 VO 发生的异常与其他 Lazy 异常不同的时候,我们仔细观察,会发现如下信息。

java
 Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON:  failed to lazily initialize a collection of role: com.example.jpa.demo.db.UserInfo.addressList

通过日志可以知道,此时发生 Lazy 异常的主要原因是 JSON 系列化的时候会触发 Lazy 加载。这个时候就有了第五种解决 Lazy 异常的方式,就是利用 @JsonIgnoreProperties("addressList") 排除我们不想序列化的属性即可。

但是这种方式的弊端是用这个集合的只能全局配置,没办法有特例配置。因此最佳实践还是采用上面所说的第一种方式和第四种方式。

场景四:自定义的拦截器和 filter 中无意的 toString 操作 ​

第四个 Lazy 异常的场景就是我们打印一些日志,或者无意间触发 toString 的操作也会发生 Lazy 异常,这种处理方法也很简单(第六种处理 Lazy 异常的方式):toString 里面排除掉不需要 Lazy 加载的关联关系即可。如果我们用 lombok 的话,直接 @ToString(exclude = "addressList") 排除掉就好了,完整例子如下所示。

java
@ToString(exclude = "addressList")
@JsonIgnoreProperties("addressList")
public class UserInfo extends BaseEntity {
......}

以上介绍的四个 Lazy 异常的场景和六种处理方式,你在实际工作中可以灵活运用,其中最主要的是要知道背后的原理和触发 Lazy 产生的性能影响是什么(意外的 SQL 执行)。

Hibernate 官方还提供了第七种处理 Lazy 异常的方式:利用 Hibernate 的配置,我们来了解一下。

hibernate.enable_lazy_load_no_trans 配置 ​

Hibernate 官方提供了 hibernate.enable_lazy_load_no_trans 配置,是否允许在关闭之后依然支持 Lazy 加载,此非 JPA 标准,所以你在用的时候需要关注版本变化。

其使用方法很简单,我们直接在 application.properties 里面增加如下配置即可,请看下面的代码。

java
## 运行在session关闭之后,重新lazy操作
spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true

此时我们不需要做任何其他修改,当在事务之外,甚至是 Session 之外,触发 Lazy 操作的时候也不会报错,也会正常地进行取数据。

但是我建议你不要用这个方法,因为一旦开启了这个对 Lazy 的操作就不可控了,会发生预期之外的 Lazy 异常,然后你只能通过我们上面所说的处理 Lazy 异常的第三种和第四种方式解决成预期之内的,否则的话,还会带来很多预期之外的 SQL 执行。这就会造成一种误解,即使用 Hibernate 或者 JPA 会导致性能变差,其实本质原因是我们不了解原理,没能正确使用。

所以到目前为止,Spring Data JPA 中,hibernate.enable_lazy_load_no_trans 默认是 false,这和 spring.jpa.open-in-view 默认是 true 是相同的道理。所以如果我们都采用 Spring Boot 的默认配置,一般是没有任何问题的;而有的时候为了更优的配置,我们需要知道底层的原理,这样才能判断出来我们业务场景的最佳实践是什么。

以上我重点介绍了 LazyInitializationException,其实 JPA 里面的异常类型还非常多,下面简单介绍一下。

Javax.persistence.PersistenceException 异常类型 ​

我们顺藤摸瓜,可以看到 LazyInitializationException 是 HibernateException 里面的,也可以看到 HibernateException 的父类 Javax.persistence.PersistenceException 下面有很多细分的异常,如下图所示。

当我们遇到异常的时候不要慌张,仔细看日志基本就能知道是什么问题了。

另外需要注意的是,当我们遇到上面这些异常的时候,不同的异常有不同的处理方式,比如 OptimisticLockException 就需要进行重试;而针对 NoSuchBeanException 的异常,就要检查我们的实体配置是否妥当。

你通过上面的异常 Hierarchy 做到心中有数就好了,遇到实际情况再实际分析即可。

总结 ​

所以为什么我们总会遇到 Lazy Exception 呢?当我们知道原理之后是不是应对起来就非常游刃有余了?而 spring.jpa.open-in-view 设置 true/false 是个权衡性的问题,没有绝对的对和错,就看我们的使用场景是什么样的了。

总之遇到问题不要慌,看一下源码,想一下我们讲的原理知识,或许你就能找到答案。下一讲我们来聊聊经典的 N+1 的 SQL 问题。到时见。

点击下方链接查看源码(不定时更新)
https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa