前言
我们知道 Mybatis
作为常见的 Java
数据库访问层的 ORM
框架,其缓存分为一级缓存和二级缓存。
大多数情况下,我们使用的都是 Mybatis
缓存的默认配置,但是 Mybatis
缓存机制有一些不足之处,在使用中容易引起脏数据问题,形成一些潜在隐患。
今天,我们就来看下 Mybatis
的缓存机制,了解其底层的一些原理,来方便我们排查、解决以后可能出现的由 Mybatis
缓存引起的问题。
正文
缓存实现
我们通过 Mybatis
提供的 jar
包来看下缓存的整体结构,如下:
可以看到其缓存相关类位于 cache
包下,其中有一个 Cache
接口,有一个默认的实现类 PerpetualCache
,它是用 HashMap
实现的。
其他的实现类使用了装饰器模式,这些装饰器可以额外实现很多的功能,如回收策略、日志记录、定时刷新等等。使用装饰器模式的好处就是这些类可以互相组装,提供丰富的缓存操控能力。
这些缓存实现类可以总结如下表:
缓存实现类 | 描述 | 作用 | 装饰条件 |
PerpetualCache | 基本缓存 | 默认就是 PerpetualCache ,当然我们也可以自定义具备基本功能的缓存实现类,如 RedisCache 、 ESCache 等,来借助三方工具实现缓存 | 无 |
LruCache | LRU策略的缓存 | 当缓存达到上限的时候,删除最近最少使用的对象(Least Recently Used)。默认上限 1024 ,其底层是使用的 LinkedHashMap 实现的 | eviction="LRU" (默认配置) |
FifoCache | FIFO策略的缓存 | 当缓存对象达到上限时,首先删除最先入队的对象。默认上限1024,底层使用 LinkedList 实现。 | eviction="FIFO" |
SoftCache | 软引用清理策略的缓存 | 通过软引用来实现缓存,当 JVM 内存不足时,会自动清理掉这些缓存,基于 SoftReference | eviction="SOFT" |
WeakCache | 弱引用清理策略的缓存 | 通过弱引用来实现缓存,当 JVM 内存不足时,会自动清理掉这些缓存,基于 WeakReference | eviction="WEAK" |
LoggingCache | 带日志功能的缓存 | 记录部分缓存处理情况,如:输出缓存的命中率 | 基本 |
SynchronizedCache | 同步缓存 | 基于 synchronized 关键字实现,解决并发问题 | 基本 |
BlockingCache | 阻塞缓存 | 通过对 get/set 方法里加锁,保证只有一个线程操作缓存,基于 ReentrantLock 实现 | blocking=true |
SerializedCache | 支持序列化的缓存 | 将对象序列化后存入缓存,取出时反序列化 | readOnly=false (默认配置) |
ScheduledCache | 定时调度的缓存 | 在进行 get/set/remove/getSize 等操作前,判断缓存时间是否超过了设置的最长缓存时间,默认一小时,如果是则清空缓存。 | flushinterval 不为空 |
TransactionalCache | 事务缓存 | 在二级缓存中使用,可以一次存入多个缓存,移除多个缓存 | TransactionalCacheManager 中用 Map 维护对应关系 |
对缓存实现结构有了大致了解后,我们分别来看下一级缓存和二级缓存。
一级缓存
介绍
在应用程序中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的 SQL
, Mybatis
提供了一级缓存的方案优化此场景,如果是相同的 SQL
语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。其过程具体如下:
每个 SqlSession
中持有 Executor
,每个 Executor
中有一个 Local Cache
。当用户发起查询时, Mybaits
根据当前执行的语句生成 MappedStatement
,在 Local Cache
中进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存未命中,查询数据库,并将结果写入 Local Cache
,最后返回结果给用户。
配置
如何使用 Mybatis
的一级缓存呢?
我们只需在 Mybatis
配置文件 mybatis-config.xml
中,添加如下语句,就可以使用一级缓存了。
1
| <setting name="localCacheScope" value="SESSION"/>
|
对于集成 Mybaits
的 springboot
项目,也可以直接在 properties
或者 yml
文件里直接进行配置,如下:
1
| mybatis.configuration.local-cache-scope=session
|
value
共有两个选项, SESSION
和 STATEMENT
,默认是 SESSION
级别,即在一个 Mybatis
会话中执行的所有语句,都会共享这个缓存。另一种是 STATEMENT
级别,可以理解为缓存只对当前执行的这一个 Statement
有效。
测试
我们接下来通过实验来了解 Mybaits
一级缓存的效果。
我们首先创建表 student
,并向表中添加一些数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `stu_no` varchar(20) NOT NULL COMMENT '学号', `stu_sex` char(1) DEFAULT NULL COMMENT '学生性别', `stu_birthday` date DEFAULT NULL COMMENT '学生生日', `stu_class` char(2) DEFAULT NULL COMMENT '学生班级', `stu_name` varchar(50) DEFAULT NULL COMMENT '学生姓名', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8; INSERT INTO `student` (`id`, `stu_no`, `stu_sex`, `stu_birthday`, `stu_class`, `stu_name`) VALUES ('1', '1', '1', '2004-03-03', '1', '张三'); INSERT INTO `student` (`id`, `stu_no`, `stu_sex`, `stu_birthday`, `stu_class`, `stu_name`) VALUES ('2', '2', '0', '2005-06-02', '2', '李四'); INSERT INTO `student` (`id`, `stu_no`, `stu_sex`, `stu_birthday`, `stu_class`, `stu_name`) VALUES ('3', '3', '1', '2003-01-07', '3', '王五');
|
同时提供 Mapper
及 pojo
类,过程略。
测试一
我们通过单元测试,测试如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @RunWith(SpringRunner.class) @SpringBootTest public class DemoApplicationTests { @Autowired private SqlSessionFactory sqlSessionFactory; @Test public void cacheTest() { SqlSession sqlSession = sqlSessionFactory.openSession(true); StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class); System.out.println(studentMapper.selectByPrimaryKey(1)); System.out.println(studentMapper.selectByPrimaryKey(1)); System.out.println(studentMapper.selectByPrimaryKey(1)); } }
|
记得开启日志的 DEBUG
级别。
可以看到三次查询,实际只有第一次真正查询了数据库,后续的查询使用的是一级缓存。
测试二
上述代码中,我们增加对数据库的修改操作,验证在一次数据库会话中,如果对数据库发生了修改操作,一级缓存是否失效。
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Test public void cacheTest1() { SqlSession sqlSession = sqlSessionFactory.openSession(true); StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class); studentMapper.selectByPrimaryKey(1); StudentModel studentModel = new StudentModel(); studentModel.setStuName("赵六"); studentModel.setStuNo("4"); studentModel.setStuClass("1"); studentModel.setStuSex("1"); studentMapper.insert(studentModel); studentMapper.selectByPrimaryKey(1); }
|
输出如下日志:
可以看到,在修改操作后执行相同的查询, 一级缓存失效,会再次查询数据库。
测试三
开启两个 SqlSession
,在 sqlSession1
中查询数据,使一级缓存生效,在 sqlSession2
中更新数据库,验证一级缓存只在数据库会话内部共享。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Test public void cacheTest3() { SqlSession sqlSession1 = sqlSessionFactory.openSession(true); SqlSession sqlSession2 = sqlSessionFactory.openSession(true); StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class); StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); studentMapper2.updateStudentName("张二",1); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); }
|
日志如下:
可以看到, sqlSession2
更新了 id
为1的学生的姓名,从 张三
变为 张二
,但在 session1
之后的查询中, id
为1的学生名字还是 张三
,出现了脏数据,也说明了一级缓存只在数据库会话内部共享。
工作流程及源码
工作流程
一级缓存执行的时序图可以用下图来表示:
源码分析
下面我们来看下 Mybaits
查询相关的核心类和一级缓存源码,这对我们后面的二级缓存学习也有帮助。
SqlSession:对外提供了用户和数据库之间交互需要的方法,隐藏了底层细节。默认实现类是 DefaultSqlSession
。
Executor: SqlSession
向用户提供操作数据库的方法,但和数据库操作有关的职责都会委托给 Executor
。
如下图所示, Executor
有若干个实现类,为 Executor
赋予了不同的能力。
在一级缓存源码中,我们主要来看下 BaseExecutor
的内部实现。
BaseExecutor:是一个实现了 Executor
接口的抽象类,定义了若干抽象方法,在执行的时候,把具体的操作委托给子类进行执行。
1 2 3 4 5 6 7
| protected abstract int doUpdate(MappedStatement ms, Object parameter) throws SQLException; protected abstract List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException; protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException; protected abstract <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql) throws SQLException;
|
在 BaseExecutor
中,我们可以看到 Local Cache
是其内部的一个成员变量,如下:
也就到了我们最开始所说的部分, Cache
接口及其实现类。
总体流程如下:
为了执行和数据库交互,首先需要初始化 SqlSession
,通过 DefaultSqlSessionFactory
开启 SqlSession
:
可以看到会用 Configuration
类创建一个全新的 Executor
,作为 DefaultSqlSession
的参数。
创建 Executor
的代码如下:
SqlSession
创建完毕后,根据 Statement
的不同类型,会进入 SqlSession
的不同方法中,如果是 select
语句,最后会执行到 SqlSession
的 selectList
方法:
SqlSession
把具体的查询职责委托给了 Executor
。如果只开启了一级缓存的话,会进入 BaseExecutor
的 query
方法。如下:
上述代码中,会先根据传入的参数生成 CacheKey
,我们来看下:
上述代码中,将 MappedStatement
的 id
、 SQL
的 offset
及 limit
、 SQL
本身以及 SQL
中的参数传入了 CacheKey
这个类。
其 update
方法如下:
它重写了 equals
方法,如下:
我们可以看到除去 hashcode
、 checksum
、 count
的比较外,只要 updatelist
中的元素一一对应相等,那么就认为 CacheKey
相等。
也就是只要两条 SQL
的下面五个值相同,即可以认为是相同 SQL
。
Statement Id + Offset + Limit + Sql + Params
BaseExecutor
的 query
继续往下走,如下:
可以看到如果一级缓存里没有,就会去数据库查询。
在 queryFromDatabase
中,会对 localCache
进行写入。
在上面 query
方法的最后,可以看到会判断一级缓存的级别,如果是 STATEMENT
级别,就会清空缓存,也就是 STATEMENT
级别无法共享一级缓存。
1 2 3 4
| if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // issue #482 clearLocalCache(); }
|
最后,我们再来看下 insert/update/delete
操作,缓存会刷新的原因。
SqlSession
找到 insert/delete
方法,都会走 update
流程。
1 2 3 4 5 6 7 8
| @Override public int insert(String statement, Object parameter) { return update(statement, parameter); } @Override public int delete(String statement, Object parameter) { return update(statement, parameter); }
|
我们来看下 update
方法,它也是委托给了 Executor
执行。
1 2 3 4 5 6 7 8 9 10 11 12
| @Override public int update(String statement, Object parameter) { try { dirty = true; MappedStatement ms = configuration.getMappedStatement(statement); return executor.update(ms, wrapCollection(parameter)); } catch (Exception e) { throw ExceptionFactory.wrapException("Error updating database. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } }
|
找到 BaseExecutor
实现,我们可以看到它每次执行都会清空 Local Cache
。
上面就是 Mybaits
的一级缓存的工作流程原理。
总结
我们来总结下一级缓存的部分内容。
-
Mybatis
一级缓存的生命周期和 SqlSession
一致。 -
Mybatis
一级缓存结构简单,只是个没有容量限定的 HashMap
,在缓存功能上有所缺陷。 -
Mybatis
的一级缓存最大范围是 SqlSession
内部,有多个 SqlSession
或者分布式环境下,数据库写操作可能会引起脏数据问题,建议设定缓存级别为 STATEMENT
。
二级缓存
介绍
上面我们说的一级缓存,其最大共享范围就是一个 SqlSession
的内部,如果多个 SqlSession
需要共享缓存,则需要使用二级缓存。
开启二级缓存后,会使用 CachingExecutor
装饰 Executor
,我们可以在 newExecutor
方法逻辑里发现这段代码:
进入一级缓存的查询流程前,先在 CachingExecutor
进行二级缓存的查询,具体工作流程如下。
二级缓存开启后,同一个 namespace
下所有操作语句,都影响着同一个 Cache
,即二级缓存被多个 SqlSession
共享,是一个全局变量。
当开启缓存后,数据的查询执行流程为: 二级缓存 -> 一级缓存 -> 数据库。
配置
要正确使用二级缓存,需要完成如下配置。
-
Mybatis
配置文件中开启二级缓存。
1
| <setting name="cacheEnabled" value="true"/>
|
或者如果使用的是 springboot
集成,需要如下配置。
1
| mybatis.configuration.cache-enabled=true
|
-
在 Mybatis
的映射 XML
中配置 cache
或者 cache-ref
。
cache
标签用于声明这个 namespace
使用二级缓存,并且可以自定义配置。
- type:
cache
使用的类型,默认是 PerpetualCache
。 - eviction:定义回收的策略,常见的有
FIFO
、 LRU
。 - flushInterval:配置一定时间自动刷新缓存,单位是毫秒。
- size:最多缓存的对象个数。
- readOnly:是否只读,若配置可读写,则需要对应的实体类能够序列化。
- blocking:若缓存中找不到对应的
key
,是否会一直 blocking
,直到有对应的数据进入缓存。
cache-ref
代表引用别的命名空间的 Cache
配置,两个命名空间的操作使用的是同一个 Cache
。
1
| <cache-ref namespace="com.zwt.demo.mapper.StudentMapper"/>
|
测试
我们来看几个二级缓存的测试,来了解下二级缓存的一些特点。
测试一
测试二级缓存效果,不提交事务, sqlSession1
查询完数据后, sqlSession2
相同的查询是否会从缓存中获取数据。
1 2 3 4 5 6 7 8 9 10
| @Test public void cacheTest4() { SqlSession sqlSession1 = sqlSessionFactory.openSession(true); SqlSession sqlSession2 = sqlSessionFactory.openSession(true); StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class); StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); }
|
可以看到,当 sqlSession
没有调用 commit()
方法时,二级缓存并没有起到作用。
测试二
这回我们测试当提交事务后,二级缓存是否可以起到作用。
1 2 3 4 5 6 7 8 9 10 11
| @Test public void cacheTest5() { SqlSession sqlSession1 = sqlSessionFactory.openSession(true); SqlSession sqlSession2 = sqlSessionFactory.openSession(true); StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class); StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); sqlSession1.commit(); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); }
|
可以看到只进行了一次查询, sqlSession2
的查询使用了缓存,缓存命中率为0.5。
PS:为什么是0.5? 因为进行了两次查询,第一次未命中,查询数据库,第二次命中缓存,故命中率是 1/2 = 0.5。
测试三
测试 update
操作是否会刷新该 namespace
下的二级缓存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Test public void cacheTest6() { SqlSession sqlSession1 = sqlSessionFactory.openSession(true); SqlSession sqlSession2 = sqlSessionFactory.openSession(true); SqlSession sqlSession3 = sqlSessionFactory.openSession(true); StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class); StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class); StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); sqlSession1.commit(); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); studentMapper3.updateStudentName("张二二",1); sqlSession3.commit(); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); }
|
可以看到, sqlSession3
更新数据库,提交事务后, sqlSession2
后面的查询走了数据库,没有走 Cache
。
测试四
验证 Mybatis
二级缓存不适用用映射文件中存在多表查询的情况。
通常我们会为每个单表创建单独的映射文件,由于 Mybatis
的二级缓存是基于 namespace
的,多表查询语句所在的 namespace
无法感应到其他 namespace
中的语句对多表查询中涉及的表进行的修改,引发脏数据问题。
这儿我们在引入一张表, score
学生分数表,并创建几条数据。
1 2 3 4 5 6 7 8 9 10 11 12 13
| CREATE TABLE `score` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `stu_no` varchar(20) NOT NULL COMMENT '学号', `cou_no` varchar(20) NOT NULL COMMENT '课程号', `score` decimal(10,0) DEFAULT NULL COMMENT '分数', PRIMARY KEY (`id`), UNIQUE KEY `uq_stu_cou` (`stu_no`,`cou_no`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8; INSERT INTO `score` (`id`, `stu_no`, `cou_no`, `score`) VALUES ('1', '1', 'A', '80'); INSERT INTO `score` (`id`, `stu_no`, `cou_no`, `score`) VALUES ('2', '2', 'B', '90'); INSERT INTO `score` (`id`, `stu_no`, `cou_no`, `score`) VALUES ('3', '2', 'A', '70'); INSERT INTO `score` (`id`, `stu_no`, `cou_no`, `score`) VALUES ('4', '3', 'A', '60');
|
涉及到的 ScoreMapper
不再附上。
我们假设在 StudentMapper
里有个根据学生 id
和课程号查询学生分数的方法, ScoreMapper
里有个更新学生分数的方法。
StudentMapper
中获取学生分数:
1
| int selectStudentScore(@Param("stuNo") String stuNo,@Param("couNo") String couNo);
|
1 2 3
| <select id="selectStudentScore" resultType="java.lang.Integer"> select c.score from student s left join score c on s.stu_no = c.stu_no where s.stu_no = #{stuNo} and c.cou_no = #{couNo}; </select>
|
ScoreMapper
更新分数:
1
| int updateScore(@Param("stuNo") String stuNo,@Param("couNo") String couNo,@Param("score")Integer score);
|
1 2 3
| <update id="updateScore"> update score set score = #{score} where stu_no = #{stuNo} and cou_no = #{couNo} </update>
|
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Test public void cacheTest7() { SqlSession sqlSession1 = sqlSessionFactory.openSession(true); SqlSession sqlSession2 = sqlSessionFactory.openSession(true); SqlSession sqlSession3 = sqlSessionFactory.openSession(true); StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class); StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class); ScoreMapper scoreMapper = sqlSession3.getMapper(ScoreMapper.class); System.out.println("studentMapper1->"+studentMapper1.selectStudentScore("1","A")); sqlSession1.close(); System.out.println("studentMapper2->"+studentMapper2.selectStudentScore("1","A")); scoreMapper.updateScore("1","A",99); sqlSession3.commit(); System.out.println("studentMapper2->"+studentMapper2.selectStudentScore("1","A")); }
|
可以看到, sqlSession1
的 studentMapper1
查询数据库后,二级缓存生效。其保存在 StudentMapper
的 namespace
的 cache
中。当 sqlSession3
的 scoreMapper
更新数据时,其方法不属于 StudentMapper
的 namespace
,无法感应到缓存变化,因此读取了脏数据。
测试五
为了解决测试四出现的问题,我们可以使用 cache-ref
,让 ScoreMapper
引用 StudentMapper
的命名空间,这样两个映射文件对应的 SQL
操作就会使用同一块缓存。
1 2 3 4 5
| ...... <mapper namespace="com.zwt.demo.mapper.ScoreMapper" > ...... <cache-ref namespace="com.zwt.demo.mapper.StudentMapper"></cache-ref> </mapper>
|
继续执行测试四代码:
可以看到,更新后,会重新进行查询,得到99的结果。
PS:不过这样做的后果是,缓存的粒度变粗了,多个 Mapper namespace
下的所有操作都会对缓存的使用造成影响。
源码分析
Mybatis
二级缓存的工作流程和上面提到的一级缓存类似,只是在一级缓存处理前,用 CachingExecutor
装饰了 BaseExecutor
的子类,在委托具体职责给 delegate
之前,实现了二级缓存的查询和写入功能。
我们从 CachingExecutor
的 query
方法看起,来了解下二级缓存的一些内容。
CachingExecutor
的 query
方法,首先会从 MappedStatement
中获得在配置初始化时赋予的 Cache
。
1
| Cache cache = ms.getCache();
|
其也是装饰器模式的使用,装饰链如下:
1
| SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache
|
这些缓存的具体能力我们上面都有讲到。
然后判断是否需要刷新缓存。
1
| flushCacheIfRequired(ms);
|
默认设置中 SELECT
语句不会刷新, INSERT/UPDATE/DELETE
语句会刷新缓存。
1 2 3 4 5 6
| private void flushCacheIfRequired(MappedStatement ms) { Cache cache = ms.getCache(); if (cache != null && ms.isFlushCacheRequired()) { tcm.clear(cache); } }
|
上述代码中我们可以看到 tcm
,它是 TransactionalCacheManager
。
底层是由 TransactionalCache
实现的,作用就是如果事务提交,对缓存的操作才会生效,如果事务回滚或者不提交事务,则不会对缓存产生影响。
在 TransactionalCache
中的 clear
方法,清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存。有以下代码。
1 2 3 4 5
| @Override public void clear() { clearOnCommit = true; entriesToAddOnCommit.clear(); }
|
返回到 CachingExecutor
,我们继续向下看, ensureNoOutParams
方法用来处理存储过程的,暂时不用考虑。
之后会尝试从 tcm
中获取缓存列表。
1
| List<E> list = (List<E>) tcm.getObject(cache, key);
|
getObject
方法中,可以看到未命中会把 key
加入Miss集合,这个主要是为了统计命中率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Override public Object getObject(Object key) { // issue #116 Object object = delegate.getObject(key); if (object == null) { entriesMissedInCache.add(key); } // issue #146 if (clearOnCommit) { return null; } else { return object; } }
|
返回到 CachingExecutor
,我们继续向下看,如果查询到数据,则调用 putObject
方法,向缓存中放入值。
1 2 3 4
| if (list == null) { list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); tcm.putObject(cache, key, list); // issue #578 and #116 }
|
putObject
方法最终调用 tcm.put
方法。
1 2 3 4
| @Override public void putObject(Object key, Object object) { entriesToAddOnCommit.put(key, object); }
|
这儿是将数据放入待提交的 Map
中,而不是直接操作缓存。
上面代码我们可以看到,如果不调用 commit
方法的话,由于 TransactionalCache
的作用,不会对二级缓存造成直接影响。因此我们来看看 SqlSession
的 commit
方法中做了什么。
因为我们使用的是 CachingExecutor
,因此我们来看下其 commit
方法。
1 2 3 4 5
| @Override public void commit(boolean required) throws SQLException { delegate.commit(required); tcm.commit(); }
|
会把具体 commit
的职责委托给包装的 Executor
。而后 tcm.commit
方法如下:
1 2 3 4 5
| public void commit() { for (TransactionalCache txCache : transactionalCaches.values()) { txCache.commit(); } }
|
最终调用了 TransactionalCache
。
1 2 3 4 5 6 7
| public void commit() { if (clearOnCommit) { delegate.clear(); } flushPendingEntries(); reset(); }
|
可以看到真正的清理 Cache
实在这里进行的。具体的清理职责委托给了包装的 Cache
类。
之后进入到 flushPendingEntries()
方法,代码如下:
1 2 3 4 5 6 7 8 9 10
| private void flushPendingEntries() { for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) { delegate.putObject(entry.getKey(), entry.getValue()); } for (Object entry : entriesMissedInCache) { if (!entriesToAddOnCommit.containsKey(entry)) { delegate.putObject(entry, null); } } }
|
可以看到在 flushPendingEntries
中,会将待提交的 Map
进行循环处理,委托给包装的 Cache
类,进行 putObject
操作。
后续的查询操作会重复这套流程。
如果是 INSERT/UPDATE/DELETE
语句的话,会统一进入 CachingExecutor
的 update
方法。
1 2 3 4 5
| @Override public int update(MappedStatement ms, Object parameterObject) throws SQLException { flushCacheIfRequired(ms); return delegate.update(ms, parameterObject); }
|
flushCacheIfRequired
我们上面有提到过。
在二级缓存执行流程后就会进入一级缓存的执行流程,这儿我们就不过多介绍了。
总结
-
Mybatis
的二级缓存相对于一级缓存来说,实现了 SqlSession
之间缓存数据的共享,同时粒度更加细致,能够到 namespace
级别,通过 Cache
接口实现不同的组合,对 Cache
的可控性也更强。 - 二级缓存开启状态下,
Mybatis
在多表查询时,很有可能会出现脏数据,有设计上的缺陷,安全使用二级缓存条件比较苛刻。 - 在分布式环境下,由于默认的
Mybatis Cache
实现都是基于本地的,因此也很可能出现脏数据问题,此时可以使用集中式缓存将 Mybaits
的 Cache
接口实现,但会有一定的开发成本,直接使用 Redis
等分布式缓存成本可能更低,安全性也更好。
自定义缓存实现
我们在正文的缓存实现里说到,我们可以借助一些缓存工具来实现 Mybatis
的自定义缓存,这儿我们使用 Redis
来举例。
首先需要引入 Redis
,我的项目配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/test1?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 spring.datasource.username=root spring.datasource.password=123456 mybatis.mapper-locations=classpath:sqlmap/mapper/*.xml mybatis.type-aliases-package=com.zwt.demo.model mybatis.configuration.local-cache-scope=session mybatis.configuration.cache-enabled=true spring.redis.host=127.0.0.1 spring.redis.database=0 spring.redis.port=6379 spring.redis.timeout=3600ms spring.redis.jedis.pool.max-active=8 spring.redis.jedis.pool.max-wait=-1ms spring.redis.jedis.pool.max-idle=8 spring.redis.jedis.pool.min-idle=0
|
其项目结构如下:
可以看到在 cache
包下的内容就是我对于 Mybatis
二级缓存的自定义实现。
主要有两个类: RedisCache
和 RedisCacheHelp
, RedisCache1
是 RedisCache
的另一种实现。
我们来看下,首先看下 RedisCache
,代码如下:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| public class RedisCache implements Cache{ private static final Logger logger = LoggerFactory.getLogger(RedisCache.class); private static RedisTemplate<String,Object> redisTemplate; private final String id; private final String HASH_KEY = "mybatis_redis_cache"; private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); @Override public ReadWriteLock getReadWriteLock(){ return this.readWriteLock; } public static void setRedisTemplate(RedisTemplate redisTemplate){ RedisCache.redisTemplate = redisTemplate; } public RedisCache(final String id){ if (id == null) { throw new IllegalArgumentException("需要指定id"); } this.id = id; } @Override public String getId(){ return this.id; } @Override public void putObject(Object key, Object value){ logger.info("MybatisRedisCache -> putObject: key=" + key + ",value=" + value); if(null!=value) { redisTemplate.opsForHash().put(HASH_KEY,key.toString(),value); } } @Override public Object getObject(Object key) { logger.info("MybatisRedisCache -> getObject: key="+key); if(null != key) { return redisTemplate.opsForHash().get(HASH_KEY,key.toString()); } return null; } @Override public Object removeObject(Object key){ logger.info("MybatisRedisCache -> removeObject: key="+key); if(null != key) { return redisTemplate.opsForHash().delete(HASH_KEY,key); } return null; } @Override public void clear() { Long size = redisTemplate.opsForHash().size(HASH_KEY); redisTemplate.delete(HASH_KEY); logger.info("MybatisRedisCache -> clear: 清除了" + size + "个对象"); } @Override public int getSize() { Long size = redisTemplate.opsForHash().size(HASH_KEY); return size == null ? 0 : size.intValue(); } }
|
代码非常好理解,就是实现 org.apache.ibatis.cache.Cache
接口,通过 Redis
操作实现缓存的操作,这儿我们使用到了 Hash
表。
关于如何是上述代码中的 redisTemplate
生效,就要用到了 RedisCacheHelp
类,这个类代码如下,主要是实现 redisTemplate
的注入。
1 2 3 4 5 6 7 8
| @Component public class RedisCacheHelp { @Autowired public void setRedisTemplate(RedisTemplate redisTemplate) { RedisCache.setRedisTemplate(redisTemplate); //RedisCache1.setRedisTemplate(redisTemplate); } }
|
上述代码弄好后,我们还需要在具体的 xml
(如StudentMapper.xml)指定缓存类型为我们的 RedisCache
,如下:
1
| <cache type="com.zwt.demo.cache.RedisCache"></cache>
|
以上配置完成,我们就可以使用我们自定义的二级缓存了。
我们运行一下测试代码,比如上面的这一个测试类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Test public void cacheTest6() { SqlSession sqlSession1 = sqlSessionFactory.openSession(true); SqlSession sqlSession2 = sqlSessionFactory.openSession(true); SqlSession sqlSession3 = sqlSessionFactory.openSession(true); StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class); StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class); StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class); System.out.println("studentMapper1->"+studentMapper1.selectByPrimaryKey(1)); sqlSession1.commit(); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); studentMapper3.updateStudentName("张二二",1); sqlSession3.commit(); System.out.println("studentMapper2->"+studentMapper2.selectByPrimaryKey(1)); }
|
可以看到如下输出:
说明已经使用了我们自定义的缓存策略。
大家可以看到我写了 RedisCache
和 RedisCache1
两个类,是这样的,我们可以看到 RedisCache
使用的是 Redis Hash
表结构来实现的二级缓存,其增加、删除、获取、统计数量、清空等操作十分方便,但有一个问题,就是 Hash
表没有办法设置其内部的单个 key
的过期时间,只能指定 Hash
表过期时间。
如果我们使用不当,可能会出现一些问题。
RedisCache1
的代码如下:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| public class RedisCache1 implements Cache{ private static final Logger logger = LoggerFactory.getLogger(RedisCache1.class); private static RedisTemplate<String,Object> redisTemplate; private final String id; private final String KEY_ALL_PREFIX = "mybatis_redis_cache_%s"; private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); @Override public ReadWriteLock getReadWriteLock(){ return this.readWriteLock; } public static void setRedisTemplate(RedisTemplate redisTemplate){ RedisCache1.redisTemplate = redisTemplate; } public RedisCache1(final String id){ if (id == null) { throw new IllegalArgumentException("需要指定id"); } this.id = id; } @Override public String getId(){ return this.id; } @Override public void putObject(Object key, Object value){ logger.info("MybatisRedisCache -> putObject: key=" + key + ",value=" + value); if(null!=value) { redisTemplate.opsForValue().set(String.format(KEY_ALL_PREFIX,key.toString()),value,60, TimeUnit.SECONDS); } } @Override public Object getObject(Object key) { logger.info("MybatisRedisCache -> getObject: key="+key); if(null != key) { return redisTemplate.opsForValue().get(String.format(KEY_ALL_PREFIX,key.toString())); } return null; } @Override public Object removeObject(Object key){ logger.info("MybatisRedisCache -> removeObject: key="+key); if(null != key) { return redisTemplate.delete(String.format(KEY_ALL_PREFIX,key.toString())); } return null; } @Override public void clear() { Set<String> keys = redisTemplate.keys(String.format(KEY_ALL_PREFIX,"*")); redisTemplate.delete(keys); logger.info("MybatisRedisCache -> clear: 清除了" + keys.size() + "个对象"); } @Override public int getSize() { Set<String> keys = redisTemplate.keys(String.format(KEY_ALL_PREFIX,"*")); return keys.size(); } }
|
可以看到这个类主要是利用 Redis key
前缀来处理缓存内容,其 K,V
都是单个存储的,其增加、删除、获取是非常方便的,而且可以指定 key
的过期时间,防止出现问题,但其清空、统计数量操作需要查出全部前缀数据的 Set
集合,在进行处理。
以上就是我们关于实现自定义 Mybatis
缓存的一些代码。
这儿需要说的一点是:我们上述这种使用Redis实现 Mybatis
二级缓存的方式,在分布式环境下,可以将各个单机缓存归一,避免了一些查库操作,但实际上我们不使用二级缓存,只使用Redis也是可以解决的。
另外这种形式的二级缓存,出现(测试四)映射文件中存在多表查询的情况,如果不使用cache-ref,也会有脏数据问题。
总结
本文通过对 Mybaits
一二级缓存的介绍,并从实验及源码角度分析了 Mybaits
缓存的特点、机制及可能产生的问题,并对缓存机制做了一定的总结。
而后我们又借助 Redis
实现了一个自定义的二级缓存处理器,加深对 Mybatis
缓存的理解。
在实际生产中,我们一般建议关闭 Mybatis
缓存特性,单纯将其作为一个 ORM
框架来使用。
参考资料