Appearance
28Hibernate一级缓存是什么?起什么作用?
如果你已经看完了之前的课时,相信你对 Hibernate 和 JPA 已经有一些深入的认识了,那么这一讲开始,我再对大家平时感到迷惑的概念做一下解释,帮助你更好地掌握 JPA。
这一讲我们来聊聊经常说的 Hibernate 的一级缓存是什么意思,Query Plan Cache 又和一级缓存是什么关系呢?
一级缓存
什么是一级缓存?这个大家最容易存在疑惑,不知道你是否也在工作中遇见过这些问题:没有办法取到最新的数据、不知道一级缓存该如何释放、怎样关闭一级缓存?我们又为什么要用一级缓存呢?
什么是一级缓存?
按照 Hibernate 和 JPA 协议里面的解释,我们经常说的 First Level Cache(一级缓存)也就是我在之前的课时中说过的 PersistenceContext,既然如此,那么就意味着一级缓存的载体是 Session 或者 EntityManager;而一级缓存的实体也就是数据库里面对应的实体。
在 SessionImpl 的实现过程中,我们会发现 PersistenceContext 的实现类 StatefulPersistenceContext 是通过 HashMap 来存储实体信息的,其关键源码如下所示。
java
public class StatefulPersistenceContext implements PersistenceContext {
//根据EntityUniqueKey作为key来储存Entity
private HashMap<EntityUniqueKey, Object> entitiesByUniqueKey;
//根据EntityUniqueKey作为key取当前实体
@Override
public Object getEntity(EntityUniqueKey euk) {
return entitiesByUniqueKey == null ? null : entitiesByUniqueKey.get( euk );
}
//储存实体,如果是第一次,那么创建HashMap<>
@Override
public void addEntity(EntityUniqueKey euk, Object entity) {
if ( entitiesByUniqueKey == null ) {
entitiesByUniqueKey = new HashMap<>( INIT_COLL_SIZE );
}
entitiesByUniqueKey.put( euk, entity );
}
......}
其中 EntityUniqueKey 的核心源码如下所示。
java
public class EntityUniqueKey implements Serializable {
private final String uniqueKeyName;
private final String entityName;
private final Object key;
private final Type keyType;
private final EntityMode entityMode;
private final int hashCode;
@Override
public boolean equals(Object other) {
EntityUniqueKey that = (EntityUniqueKey) other;
return that != null && that.entityName.equals( entityName )
&& that.uniqueKeyName.equals( uniqueKeyName )
&& keyType.isEqual( that.key, key );
}
...
}
通过源码可以看到,用 PersistenceContext 来判断实体是不是同一个,可以直接根据实体里面的主键进行。那么一级缓存的作用是什么呢?
一级缓存的作用
由于一级缓存就是 PersistenceContext,那么一级缓存的最大作用就是管理 Entity 的生命周期,详细的内容我已经在"21 | Persistence Context 所表达的核心概念是什么?"介绍过了,这里我就稍加总结。
New(Transient)状态的,不在一级缓存管理之列,这是新创建的;
Detached 游离状态的,不在一级缓存里面,和 New 的唯一区别是它带有主键和 Version 信息;
Manager、Removed 状态的实体在一级缓存管理之列,所有对这两种状态的实体进行的更新操作,都不会立即更新到数据库里面,只有执行了 flush 之后才会同步到数据库里面。
我们用一张图来表示,如下所示。
注:图片来源于网络
对于实体 1 来说,新增和更新操作都是先进行一级缓存,只有 flush 的时候才会同步到数据库里面。而当我们执行了 entityManager.clean() 或者是 entityManager.detach(entity1),那么实体 1 就会变成游离状态,这时再对实体 1 进行修改,如果再执行 flush 的话,就不会同步到 DB 里面了。我们用代码来说明一下,如下所示。
java
public class UserInfoRepositoryTest {
@Autowired
private UserInfoRepository userInfoRepository;
@PersistenceContext(properties = {@PersistenceProperty(
name = "org.hibernate.flushMode",
value = "MANUAL"//手动flush
)})
private EntityManager entityManager;
@Test
@Transactional
public void testLife() {
UserInfo userInfo = UserInfo.builder().name("new name").build();
//新增一个对象userInfo交给PersistenceContext管理,即一级缓存
entityManager.persist(userInfo);
//此时没有detach和clear之前,flush的时候还会产生更新SQL
userInfo.setName("old name");
entityManager.flush();
entityManager.clear();
// entityManager.detach(userInfo);
// entityManager已经clear,此时已经不会对UserInfo进行更新了
userInfo.setName("new name 11");
entityManager.flush();
//由于有cache机制,相同的对象查询只会触发一次查询SQL
UserInfo u1 = userInfoRepository.findById(1L).get();
//to do some thing
UserInfo u2 = userInfoRepository.findById(1L).get();
}
}
利用我们之前讲过的打印日志的方法,把 SQL 打印一下,输出到控制台的 SQL 如下所示。
java
Hibernate: insert into user_info (create_time, create_user_id, last_modified_time, last_modified_user_id, version, ages, email_address, last_name, name, telephone, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: update user_info set create_time=?, create_user_id=?, last_modified_time=?, last_modified_user_id=?, version=?, ages=?, email_address=?, last_name=?, name=?, telephone=? where id=? and version=?
Hibernate: select userinfo0_.id as id1_2_0_, userinfo0_.create_time as create_t2_2_0_, userinfo0_.create_user_id as create_u3_2_0_, userinfo0_.last_modified_time as last_mod4_2_0_, userinfo0_.last_modified_user_id as last_mod5_2_0_, userinfo0_.version as version6_2_0_, userinfo0_.ages as ages7_2_0_, userinfo0_.email_address as email_ad8_2_0_, userinfo0_.last_name as last_nam9_2_0_, userinfo0_.name as name10_2_0_, userinfo0_.telephone as telepho11_2_0_, rooms1_.user_info_id as user_inf1_3_1_, room2_.id as rooms_id2_3_1_, room2_.id as id1_1_2_, room2_.create_time as create_t2_1_2_, room2_.create_user_id as create_u3_1_2_, room2_.last_modified_time as last_mod4_1_2_, room2_.last_modified_user_id as last_mod5_1_2_, room2_.version as version6_1_2_, room2_.title as title7_1_2_ from user_info userinfo0_ left outer join user_info_rooms rooms1_ on userinfo0_.id=rooms1_.user_info_id left outer join room room2_ on rooms1_.rooms_id=room2_.id where userinfo0_.id=?
通过日志可以看到没有第二次更新。
除此之外,关于一级缓存还有其他问题你应该了解一下。
它的生命周期是怎么样的呢?可想而知,肯定和 Session 一样,这个问题你可以回过头仔细看看"22 | Session 的 open-in-view 对事务的影响是什么?"。但同时实体在一级 Cache 里面的生命周期还受到的 entityManager.clear() 和 entityManger.detach() 两个方法的影响。
一级缓存的大小可以设置吗?这个肯定是不能的,我们从底层原理可以分析出:一级缓存依赖Java 内存堆的大小,所以受到最大堆和最小堆的限制,即清除一级缓存的机制就是利用 JVM 的 GC 机制,清理掉 GC 就会清理掉一级缓存。
所以当我们请求并发量大的时候,Session 的对象就会变得很多,此时就会需要更多内存。当请求结束之后,随着 GC 的回收,里面就会清除一级缓存留下来的对象。
一级缓存可以关闭吗?答案肯定是不能的,除非我们不用 Hibernate 或 JPA,改用 Mybatis,因为一级缓存是 JPA 的最大优势之一。
而在实际工作中,最容易被我们忽略的是和一级缓存差不多的 Query Plan Cache,我们来了解一下。
Query Plan Cache
我们都知道 JPA 里面大部分的查询都是基于 JPQL 查询语法,从而会有一个过程把 JPQL 转化成真正的 SQL,而后到数据库里执行。而 JPQL 转化成原始的 SQL 时,就会消耗一定的性能,所以 Hibernate 设计了一个 QueryPlanCache 的机制,用来存储 JPQL 或者 Criteria Query 到 Native SQL 中转化的结果,也就是说 QueryPlanCache 里面存储了最终要执行的 SQL,以及参数和返回结果的类型。
QueryPlanCache 是什么?
在 Hibernate 中,QueryPlanCache 就是指具体的某一个类。我们通过核心源码看一下它是什么,如下所示。
java
package org.hibernate.engine.query.spi;
//存储query plan 和 query parameter metdata
public class QueryPlanCache implements Serializable {
//queryPlanCache的存储结构为自定义的HashMap结构,用来存储JPQL到SQL的转化过程及其SQL的执行语句和参数,返回结果的metadata;
private final BoundedConcurrentHashMap queryPlanCache;
//这个用来存储@Query的nativeQuery = true的query plan,即原始SQL的meta,包含参数和return type的 meta;
private final BoundedConcurrentHashMap<ParameterMetadataKey,ParameterMetadataImpl> parameterMetadataCache;
//QueryPlanCache的构造方法
public QueryPlanCache(final SessionFactoryImplementor factory, QueryPlanCreator queryPlanCreator) {
this.factory = factory;
this.queryPlanCreator = queryPlanCreator;
//maxParameterMetadata的个数,计算逻辑,可以自定义配置,或者采用默认值
Integer maxParameterMetadataCount = ConfigurationHelper.getInteger(
Environment.QUERY_PLAN_CACHE_PARAMETER_METADATA_MAX_SIZE,
factory.getProperties()
);
if ( maxParameterMetadataCount == null ) {
maxParameterMetadataCount = ConfigurationHelper.getInt(
Environment.QUERY_PLAN_CACHE_MAX_STRONG_REFERENCES,
factory.getProperties(),
DEFAULT_PARAMETER_METADATA_MAX_COUNT
);
}
//maxQueryPlan的个数,计算逻辑,可以自定义配置大小,或者采用默认值
Integer maxQueryPlanCount = ConfigurationHelper.getInteger(
Environment.QUERY_PLAN_CACHE_MAX_SIZE,
factory.getProperties()
);
if ( maxQueryPlanCount == null ) {
maxQueryPlanCount = ConfigurationHelper.getInt(
Environment.QUERY_PLAN_CACHE_MAX_SOFT_REFERENCES,
factory.getProperties(),
DEFAULT_QUERY_PLAN_MAX_COUNT
);
}
//新建一个 BoundedConcurrentHashMap的queryPlanCache,用来存储JPQL和Criteria Query到SQL的转化过程
queryPlanCache = new BoundedConcurrentHashMap( maxQueryPlanCount, 20, BoundedConcurrentHashMap.Eviction.LIRS );
//新建一个 BoundedConcurrentHashMap的parameterMetadataCache,用来存储Native SQL的转化过程
parameterMetadataCache = new BoundedConcurrentHashMap<>(
maxParameterMetadataCount,
20,
BoundedConcurrentHashMap.Eviction.LIRS
);
nativeQueryInterpreter = factory.getServiceRegistry().getService( NativeQueryInterpreter.class );
}
// 默认的parameterMetadataCache的HashMap的存储空间大小,默认128条
public static final int DEFAULT_PARAMETER_METADATA_MAX_COUNT = 128;
//默认的queryPlanCache的HashMap存储空间大小,默认2048条
public static final int DEFAULT_QUERY_PLAN_MAX_COUNT = 2048;
......不重要的代码先省略
}
很好理解,通过源码和概念的分析你就大概知道 QueryPlanCache 是什么了,那么我们再来看一下它的里面具体会存储什么内容呢?
QueryPlanCache 存储的内容
我们新建一个 UserInfoRepository,来测试一下。假设 UserInfoRepository 里面有如下几个方法。
java
public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {
//没有用@Query,直接使用method name defining query
List<UserInfo> findByNameAndCreateTimeBetween(String name, Instant begin, Instant endTime);
//演示SpEL根据数组下标取参数,和根据普通的Parma的名字:name取参数
@Query("select u from UserInfo u where u.lastName like %:#{[0]} and u.name like %:name%")
List<UserInfo> findContainingEscaped(@Param("name") String name);
//SpEL取Parma的名字customer里面的属性
@Query("select u from UserInfo u where u.name = :#{#customer.name}")
List<UserInfo> findUsersByCustomersFirstname(@Param("customer") UserInfo customer);
//利用SpEL根据一个写死的'jack'字符串作为参数
@Query("select u from UserInfo u where u.name = ?#{'jack'}")
List<UserInfo> findOliverBySpELExpressionWithoutArgumentsWithQuestionmark();
@Query(value = "select * from user_info where name=:name",nativeQuery = true)
List<UserInfo> findByName(@Param(value = "name") String name);
}
当项目启动成功之后你会发现,通过 @Query 定义的 nativeQuery=false 的 JPQL,会在启动成功之后预先放在 QueryPlanCache 里面,我们设置一个断点就可以看到如下内容。
发现里面 parameterMetadataCache 是空的,也就是没有放置 nativeQuery=true 的 Query SQL,并且可以看到我们在方法里面定义的其他三个 @Query 的 JPQL 解析过程。那么我们打开第一个详细看一下,如下图所示。
你会发现一个 QueryPlanCache 还是能存挺多东西的:navtive sql、参数、return 等各种 metadata。也可以看出一个简单的 JPQL 查询会有些占用堆内存,所以如果是复杂点的项目,各种查询的 JPQL 多一点的话,启动所需要的最小堆内存会占用 300M、400M 的空间,这是正常现象。
在 UserInfoRepository 的五个方法中,剩下的两个方法分别是 name defining query 和 nativeQuery=true。这两种情况是,当调用的时候发现 QueryPlanCache 里面没有它们,于是就会被增加进去,下次就可以直接从 QueryPlanCache 里面取了。那么我们在 Controller 里面执行这两个方法,如下所示。
java
userInfoRepository.findByNameAndCreateTimeBetween("JK", Instant.now(),Instant.now());
userInfoRepository.findByName("jack");
然后通过断点就会发现 QueryPlanCache 里面多了两个 Cache,如下图所示。
同时,parameterMetadataCache 里面就会多一条 key/value的nativeQuery=true 的解析记录,如下图所示。
通过上面的案例讲解,相信你已经清楚了 QueryPlanCache 的概念,总结起来就是,QueryPlanCache 用来存储的 JQPL 或者 SQL 的 Metadata 信息,从而提升了 Hibernate 执行 JPQL 的性能,因为只有第一次需要把 JPQL 转化成 SQL,后面的每次操作就可以直接从 HashMap 中找到对应的 SQL,直接执行就可以了。
那么它和 Session 到底是什么关系呢?它是否在一级缓存里面?
QueryPlanCache 和 Session 是什么关系?
我们通过查看源码会发现,在 SessionFactoryImpl 的构造方法里面会 new QueryPlanCache(...),关键源码如下。
说明这个 application 只需要创建一次 QueryPlanCache,整个项目周期是单例的,也就是可以被不同的 Session 共享,那么我们可以查看 Session 的关键源码,如下图所示。
也就是说,每一个 SessionImpl 的实例在获得 query plan 之前,都会去同一个 QueryPlanCache 里面查询一下 JPQL 对应的执行计划。所以我们可以看得出来 QueryPlanCache 和 Session 的关系有如下几点。
QueryPlanCache 在整个 Spring Application 周期内就是一个实例;
不同的 Session 作用域,可以代表不同的 SessionImpl 实例共享 QueryPlanCache;
QueryPlanCache 和我们所说的一级缓存完全不是一个概念,这点你要分清楚。
而实际工作中大部分场景 QueryPlanCache 都是没有问题的,只有在 In 的 SQL 查询的场景会引发内存泄漏的问题,我们看一下。
QueryPlanCache 中 In 查询引发的内存泄漏问题
我们在实际的工作中使用 JPA 的时候,会发现其内存越来越大,而不会被垃圾回收机制给回收掉,现象就是堆内存随着时间的推移使用量越来越大,如下图所示,很明显是内存泄漏的问题。
而我们把堆栈拿出来分析的话会发现,其实是 Hibernate 的 QueryPlanCache 占用了大量的内存,如下图所示。
我们点开仔细看的话,发现大部分都是某些 In 相关的 SQL 语句。这就是我们常见的 In 查询引起的内存泄漏,那么为什么会发生这种现象呢?
In 查询条件引发内存泄漏的原因
我们在 UserInfoRepository 里面新增一个 In 条件的查询方法,模拟一下实际工作中的 In 查询条件的场景,如下所示。
java
public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {
//测试In查询条件的情况
List<UserInfo> findByNameAndUrlIn(String name, Collection<String> urls);
}
假设有个需求,查询拥有个人博客地址的用户有哪些?那么我们的 Controller 里面有如下方法。
java
@GetMapping("/users")
public List<UserInfo> getUserInfos(List<String> urls) {
//根据urls批量查询,我们模拟实际工作中的批量查询情况,实际工作中可能会有大量的根据不同的IDS批量查询的场景;
return userInfoRepository.findByNameAndUrlIn("jack",urls);
}
我们 debug 看一下 QueryPlanCache 里面的情况,会发现随着 In 查询条件的个数增加,会生成不同的 QueryPlanCache,如下图所示,分别是 1 个参数、3 个参数、6个参数的情况。
从图中我们可以想象一下,如果业务代码中有各种 In 的查询操作,不同的查询条件的个数肯定在大部分场景中也是不一样的,甚至有些场景我们能一下查询到几百个 ID 对应的数据,可想而知,那得生成多少个 In 相关的 QueryPlanCache 呀。
而依据 QueryPlanCache 的原理,整个工程都是单例的,放进去之后肯定不会进行内存垃圾回收,那么程序运行时间久了之后就会发生内存泄漏,甚至一段时间之后还会导致内存溢出的现象发生。那么该如何解决此类问题呢?
解决 In 查询条件内存泄漏的方法
第一种方法:修改缓存的最大条数限制
正如我们上面介绍的,默认 DEFAULT_QUERY_PLAN_MAX_COUNT = 2048,也就是 query plan 的最大条数限制是 2048。这样默认值可能有点大了,我们可以通过如下方式修改默认值,请看代码。
java
#修改 默认的plan_cache_max_size,太小会影响JPQL的执行性能,所以根据实际情况可以自由调整,不宜太小,也不宜太大,太大可能会引发内存溢出
spring.jpa.properties.hibernate.query.plan_cache_max_size=512
#修改 默认的native query的cache大小
spring.jpa.properties.hibernate.query.plan_parameter_metadata_max_size=128
第二种方法:根据 max plan count 适当增加堆内存大小
因为 QueryPlanMaxCount 是有限制的,那么肯定最大堆内存的使用也是有封顶限制的,我们找到临界值修改最小、最大堆内存即可。
第三种方法:减少 In 的查询 SQL 生成条数,配置如下所示。
java
### 默认情况下,不同的in查询条件的个数会生成不同的plan query cache,我们开启了in_clause_parameter_padding之后会减少in生成cache的个数,会根据参数的格式运用几何的算法生成QueryCache;
spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true
也就是说,当 In 的时候,参数个数会对应归并 QueryPlanCache 变成 1、2、4、8、16、32、64、128 个参数的 QueryPlanCache。那么我们再看一下刚才参数个数分别在 1、3、4、5、6、7、8 个的时候生成 QueryPlanCache 的情况,如下图所示。
我们会发现,In 产生个数是 1 个的时候,它会共享参数为 1 个的 QueryPlanCache;而当参数是 3、4 个 In 参数的时候,它就会使用 4 个参数的 QueryPlanCache;以此类推,当参数是 5、6、7、8 个的时候,会使用 8 个参数的 QueryPlanCache......这种算法可以大大地减少 In 的不同查询参数生成的 QueryPlanCache 个数,占用的内存自然会减少很多。
总结
以上就是本讲介绍的全部内容,主要是帮助你理清工作中关于缓存的一些概念,其实一级缓存的原理我们在前面几讲都有详细介绍。其中你要重点了解一下 Query Plan Cache,因为实际工作中很多人会把它和一级缓存的概念混为一谈。
学习就是不断思考的过程,希望你能踊跃留言讨论。下个课时我会重点介绍二级缓存以及它的最佳实践,到时见。
点击下方链接查看源码(不定时更新)
https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa