MyBatis 缓存配置

5/30/2022 MyBatis

摘要

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);
    }
}
1
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>
1
2
3

flushCache="true" 这个属性配置为true后,会在查询数据前清空当前的一级缓存,因此该方法每次都会重新从数据库中查询数据。

INSERT、UPDATE、DELETE 操作都会清空一级缓存。一级缓存存在于 SqlSession 的生命周期中。

# 三:二级缓存

二级缓存存在于 SqlSessionFactory 的生命周期中。目前项目基本都是单个 SqlSessionFactory,如果存在多个情况,它们的缓存都是绑定在各自对象上的,缓存数据在一般情况下是不相通的。只有在使用如 Redis 这样的缓存数据库时,才可以共享缓存。

# 3.1 开启二级缓存

mybatis-config.xml

<settings>
	<setting name="cacheEnabled" value="true"/>
</settings>
1
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>
1
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"/>
1

上面的配置,创建了一个FIFO缓存,并每隔60秒刷新一次,存储集合或对象的512个引用,而且返回的对象被认为是只读的,因此在不同线程中的调用者之间修改它们会导致冲突。

cache可以配置的属性如下:

  • eviction(收回策略)
    • LRU(最近最少使用):移除最长时间不被使用的对象,这是默认值。
    • FIFO(先进先出):按对象进入缓存的顺序来移除它们。
    • SOFT(软引用):移除基于垃圾回收器状态和软引用规则的对象。
    • WEAK(弱引用):更积极地移除基于垃圾收集器状态和弱引用规则的对象。
  • flushInterval(刷新间隔):可以被设置为任意的正整数,而且它们代表一个合理的毫秒形式的时间段。默认情况不设置,即没有刷新间隔,缓存仅仅在调用语句时刷新。
  • size(引用数目):可以被设置为任意正整数,要记住缓存的对象数目和运行环境的可用内存资源数目。默认值是1024。
  • readOnly(只读):属性可以被设置为true或false。只读的缓存会给所有调用者返回缓存对象的相同实例,因此这些对象不能被修改,这提供了很重要的性能优势。可读写的缓存会通过序列化返回缓存对象的拷贝,这种方式会慢一些,但是安全,因此默认是false。

# 3.3 接口配置二级缓存

Mapper.java

 




@CacheNamespace
public interface CustomersMapper {
	// ... ...
}
1
2
3
4

同样也可以在注释中配置各项属性,如下:

@CacheNamespace(
        eviction = FifoCache.class,
        flushInterval = 60000,
        size = 512,
        readWrite = true)
1
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
1
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 {
}
1
2
3

反正,XML映射文件或者其他接口的缓存,在XML中也可以配置参照缓存,如下:

<cache-ref namespace="top.qform.mapper.CustomersMapper"/>
1

MyBatis中很少会同时使用Mapper接口注解方式和XML映射文件,所以参照缓存并不是为了解决这个问题而设计的。参照缓存除了能够通过引用其他缓存减少配置外,主要的作用是解决脏读

# 3.5 使用二级缓存

如果配置的是可读写的缓存,MyBatis使用 SerializedCache(org.apache.ibatis.cache.decorators.SerializedCache)序列化缓存来实现可读写缓存类,并通过序列化和反序列化来保证通过缓存获取数据,得到的是一个的实例。因此,如果配置为只读缓存,MyBatis就会使用Map来存储缓存值,这种情况下,从缓存中获取的对象就是同一个实例。

设置可读写缓存

<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="false"/>
1

测试

@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);
    }
}
1
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版,所以下面介绍另外集成方式。

// 待补充

# 五:参考文献

最后更新: 10/22/2022, 10:41:05 AM