摘要
JDK:1.8.0_202
MyBatis Version:3.5.9
# 一:前言
一般提到MyBatis缓存的时候,都是指二级缓存。一级缓存(也叫本地缓存)默认会启用,并且不能控制,因此很少会提到。
# 二:一级缓存
测试
@Test
public void testL1Cache() {
Customers c1 = null;
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
CustomersMapper mapper = sqlSession.getMapper(CustomersMapper.class);
// 查询 id = 10001
c1 = mapper.selectById(10001);
c1.setCustName("gz");
// 再次查询 id=10001
Customers c2 = mapper.selectById(10001);
// 虽然没有更新数据库,但是这个用户名和c1重新赋值的名字相同
Assert.assertEquals("gz", c2.getCustName());
// c1 和 c2 完全就是同一个实例
Assert.assertEquals(c1, c2);
}
System.out.println("开启新sqlSession");
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
CustomersMapper mapper = sqlSession.getMapper(CustomersMapper.class);
// 查询 id = 10001
Customers c3 = mapper.selectById(10001);
Assert.assertNotEquals("gz", c3.getCustName());
// 非同一实例
Assert.assertNotEquals(c1, c3);
// 执行删除操作
mapper.deleteById(10044);
// 再次查询 id=10001
Customers c4 = mapper.selectById(10001);
// c3 和 c4 不是同一实例
Assert.assertNotEquals(c3, c4);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
运行结果
上面代码中的 c1 和 c2 是同一对象,之所以如此就是因为 MyBatis 的一级缓存。MyBatis 的一级缓存存在于 SqlSession 的生命周期中,在同一个SqlSession 中查询时,MyBatis会把执行的方法和参数通过算法生成缓存的键值,将键值和查询结果存入一个Map对象中。如果同一个SqlSession中执行的方法和参数完全一致,那么通过算法会生成相同的键值,当Map缓存对象中已经存在该键值时,则会返回缓存中的对象。
如果不想让 selectById 方法使用一级缓存,可以对该方法做如下修改。
<select id="selectById" resultMap="BaseResultMap" flushCache="true">
...
</select>
2
3
flushCache="true"
这个属性配置为true后,会在查询数据前清空当前的一级缓存,因此该方法每次都会重新从数据库中查询数据。
INSERT、UPDATE、DELETE 操作都会清空一级缓存。一级缓存存在于 SqlSession 的生命周期中。
# 三:二级缓存
二级缓存存在于 SqlSessionFactory 的生命周期中。目前项目基本都是单个 SqlSessionFactory,如果存在多个情况,它们的缓存都是绑定在各自对象上的,缓存数据在一般情况下是不相通的。只有在使用如 Redis 这样的缓存数据库时,才可以共享缓存。
# 3.1 开启二级缓存
mybatis-config.xml
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
2
3
默认为true,可以不用显式配置。如果设置为false,即使后面有二级缓存配置,也不会生效。
# 3.2 XML配置二级缓存
Mapper.xml
MyBatis的二级缓存是和命名空间绑定的,即二级缓存需要配置在 Mapper.xml 映射文件中,或者配置在 Mapper.java 接口中。在映射文件中,命名空间就是 XML 根节点 mapper 的 namespace属性。在Mapper接口中,命名空间就是接口的全限定名称。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="top.qform.mapper.CustomersMapper">
<cache/>
<!-- 其他配置 -->
</mapper>
2
3
4
5
6
默认的二级缓存会有如下效果
- 映射语句文件中的所有SELECT语句将会被缓存;
- 映射语句文件中的所有INSERT、UPDATE、DELETE语句会刷新缓存;
- 缓存会使用Least Recently Used(LRU,最近最少使用的)算法来收回;
- 根据时间表(如no Flush Interval,没有刷新间隔),缓存不会以任何时间顺序来刷新;
- 缓存会存储集合或对象(无论查询方法返回什么类型的值)的1024个引用;
- 缓存会被视为read/write(可读/可写)的,意味着对象检索不是共享的,而且可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。
上面的这些属性都可以通过缓存元素的属性来修改,例如:
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
上面的配置,创建了一个FIFO缓存,并每隔60秒刷新一次,存储集合或对象的512个引用,而且返回的对象被认为是只读的,因此在不同线程中的调用者之间修改它们会导致冲突。
cache可以配置的属性如下:
- eviction(收回策略)
- LRU(最近最少使用):移除最长时间不被使用的对象,这是默认值。
- FIFO(先进先出):按对象进入缓存的顺序来移除它们。
- SOFT(软引用):移除基于垃圾回收器状态和软引用规则的对象。
- WEAK(弱引用):更积极地移除基于垃圾收集器状态和弱引用规则的对象。
- flushInterval(刷新间隔):可以被设置为任意的正整数,而且它们代表一个合理的毫秒形式的时间段。默认情况不设置,即没有刷新间隔,缓存仅仅在调用语句时刷新。
- size(引用数目):可以被设置为任意正整数,要记住缓存的对象数目和运行环境的可用内存资源数目。默认值是1024。
- readOnly(只读):属性可以被设置为true或false。只读的缓存会给所有调用者返回缓存对象的相同实例,因此这些对象不能被修改,这提供了很重要的性能优势。可读写的缓存会通过序列化返回缓存对象的拷贝,这种方式会慢一些,但是安全,因此默认是false。
# 3.3 接口配置二级缓存
Mapper.java
@CacheNamespace
public interface CustomersMapper {
// ... ...
}
2
3
4
同样也可以在注释中配置各项属性,如下:
@CacheNamespace(
eviction = FifoCache.class,
flushInterval = 60000,
size = 512,
readWrite = true)
2
3
4
5
这里的 readWrite 属性和XML中的 readOnly 属性一样,用于配置缓存是否为只读类型,在这里 true 为读写,false 为只读,默认为 true。
# 3.4 配置异常
当同时使用注解方式和 XML 映射文件时,如果同时配置了上述的二级缓存,就会抛出如下异常。
详细错误
org.apache.ibatis.exceptions.PersistenceException:
### Error building SqlSession.
### The error may exist in top/qform/mapper/CustomersMapper.java (best guess)
### The error occurred while processing mapper_resultMap[BaseResultMap]
### Cause: org.apache.ibatis.builder.BuilderException: Error parsing SQL Mapper Configuration. Cause: java.lang.IllegalArgumentException: Caches collection already contains value for top.qform.mapper.CustomersMapper
at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
at org.apache.ibatis.session.SqlSessionFactoryBuilder.build(SqlSessionFactoryBuilder.java:52)
at org.apache.ibatis.session.SqlSessionFactoryBuilder.build(SqlSessionFactoryBuilder.java:36)
at top.qform.CustomersTest2.init(CustomersTest2.java:28)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.RunBefores.invokeMethod(RunBefores.java:33)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:24)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)
Caused by: org.apache.ibatis.builder.BuilderException: Error parsing SQL Mapper Configuration. Cause: java.lang.IllegalArgumentException: Caches collection already contains value for top.qform.mapper.CustomersMapper
at org.apache.ibatis.builder.xml.XMLConfigBuilder.parseConfiguration(XMLConfigBuilder.java:122)
at org.apache.ibatis.builder.xml.XMLConfigBuilder.parse(XMLConfigBuilder.java:99)
at org.apache.ibatis.session.SqlSessionFactoryBuilder.build(SqlSessionFactoryBuilder.java:50)
... 18 more
Caused by: java.lang.IllegalArgumentException: Caches collection already contains value for top.qform.mapper.CustomersMapper
at org.apache.ibatis.session.Configuration$StrictMap.put(Configuration.java:1037)
at org.apache.ibatis.session.Configuration$StrictMap.put(Configuration.java:993)
at org.apache.ibatis.session.Configuration.addCache(Configuration.java:729)
at org.apache.ibatis.builder.MapperBuilderAssistant.useNewCache(MapperBuilderAssistant.java:140)
at org.apache.ibatis.builder.annotation.MapperAnnotationBuilder.parseCache(MapperAnnotationBuilder.java:190)
at org.apache.ibatis.builder.annotation.MapperAnnotationBuilder.parse(MapperAnnotationBuilder.java:121)
at org.apache.ibatis.binding.MapperRegistry.addMapper(MapperRegistry.java:72)
at org.apache.ibatis.session.Configuration.addMapper(Configuration.java:864)
at org.apache.ibatis.builder.xml.XMLMapperBuilder.bindMapperForNamespace(XMLMapperBuilder.java:432)
at org.apache.ibatis.builder.xml.XMLMapperBuilder.parse(XMLMapperBuilder.java:97)
at org.apache.ibatis.builder.xml.XMLConfigBuilder.mapperElement(XMLConfigBuilder.java:379)
at org.apache.ibatis.builder.xml.XMLConfigBuilder.parseConfiguration(XMLConfigBuilder.java:120)
... 20 more
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
这时候可以选择只配置XML,如果想要 Mapper 接口中也配置,就需要使用参照缓存,在 Mapper 接口中,配置如下:
@CacheNamespaceRef(CustomersMapper.class)
public interface CustomersMapper {
}
2
3
反正,XML映射文件或者其他接口的缓存,在XML中也可以配置参照缓存,如下:
<cache-ref namespace="top.qform.mapper.CustomersMapper"/>
MyBatis中很少会同时使用Mapper接口注解方式和XML映射文件,所以参照缓存并不是为了解决这个问题而设计的。参照缓存除了能够通过引用其他缓存减少配置外,主要的作用是解决脏读
# 3.5 使用二级缓存
如果配置的是可读写的缓存,MyBatis使用 SerializedCache(org.apache.ibatis.cache.decorators.SerializedCache)序列化缓存来实现可读写缓存类,并通过序列化和反序列化来保证通过缓存获取数据,得到的是一个新的实例。因此,如果配置为只读缓存,MyBatis就会使用Map来存储缓存值,这种情况下,从缓存中获取的对象就是同一个实例。
设置可读写缓存
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="false"/>
测试
@Test
public void testL2Cache() {
Customers c1 = null;
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
CustomersMapper mapper = sqlSession.getMapper(CustomersMapper.class);
// 查询 id = 10001
c1 = mapper.selectById(10001);
c1.setCustName("gz");
// 再次查询 id=10001
Customers c2 = mapper.selectById(10001);
// 虽然没有更新数据库,但是这个用户名和c1重新赋值的名字相同
Assert.assertEquals("gz", c2.getCustName());
// c1 和 c2 同一个实例
Assert.assertEquals(c1, c2);
}
System.out.println("开启新sqlSession");
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
CustomersMapper mapper = sqlSession.getMapper(CustomersMapper.class);
// 查询 id = 10001
Customers c3 = mapper.selectById(10001);
Assert.assertEquals("gz", c3.getCustName());
// 非同一实例
Assert.assertNotEquals(c1, c3);
// 再次查询 id=10001
Customers c4 = mapper.selectById(10001);
// c3 和 c4 不是同一实例
Assert.assertNotEquals(c3, c4);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
运行结果
说明
日志中存在好几条以Cache Hit Ratio开头的语句,后面输出的值为当前执行方法的二级缓存命中率。第一个 c1 和 c2 是完全相同的实例,这里使用的是一级缓存,所以返回同一个实例。
当关闭第一个 SqlSession 时,SqlSession才会保存查询数据到二级缓存中。在这之后二级缓存才有了缓存数据,所以第一个 SqlSession 的两次查询时,命中率都是0。
第二个 SqlSession,获取 c3 时,日志中并没有输出数据库查询,而是输出了命中率,这时的命中率是0.3333333333333333。这是第3次查询,并且得到了缓存的值,因为该方法一共被请求了3次,有1次命中,所以命中率就是三分之一。后面获取 c4 时,就是4次请求,2次命中,命中率为0.5。并且因为可读写缓存的缘故,c3 和 c4 都是反序列化得到的结果,所以它们不是相同的实例。在这一部分,这两个实例是读写安全的,其属性不会互相影响。
上面的例子并没有真正的读写安全。
上面测试中加入了一段不该有的代码,即 c1.setCustName("gz")
,这里修改 c1 的属性值后,按理应该更新数据,更新后会清空一、二级缓存,这样在第二部分的代码中就不会出现查询结果的是 gz
的情况了。所以想要安全使用,需要避免毫无意义的修改。这样就可以避免人为产生的脏数据,避免缓存和数据库的数据不一致。
MyBatis默认提供的缓存实现是基于Map实现的内存缓存,已经可以满足基本的应用。但是当需要缓存大量的数据时,不能仅仅通过提高内存来使用MyBatis的二级缓存,还可以选择一些类似EhCache的缓存框架或Redis缓存数据库等工具来保存MyBatis的二级缓存数据。
# 3.6 适用场景
二级缓存虽然好处很多,但并不是什么时候都可以使用。在以下场景中,推荐使用二级缓存。
- 以查询为主的应用中,只有尽可能少的增、删、改操作;
- 绝大多数以单表操作存在时,由于很少存在互相关联的情况,因此不会出现脏数据;
- 可以按业务划分对表进行分组时,如关联的表比较少,可以通过参照缓存进行配置;
除了推荐使用的情况,如果脏读对系统没有影响,也可以考虑使用。在无法保证数据不出现脏读的情况下,建议在业务层使用可控制的缓存代替二级缓存。
# 四:集成Redis缓存
MyBatis项目开发者提供了Redis的MyBatis二级缓存实现,项目名为 redis-cache,Github的项目地址为 https://github.com/mybatis/redis-cache (opens new window) ,可以访问 Maven主仓库 (opens new window) 查看,会发现只有两个beta版,所以下面介绍另外集成方式。
// 待补充
# 五:参考文献
- 《MyBatis从入门到精通 - 刘增辉》
- MyBatis官网 (opens new window)