在我们平时的业务开发中,经常会使用“半自动化”的ORM框架Mybatis解决程序对数据库操作问题。MyBatis是一个Java持久化框架,它通过XML描述符或注解把对象与存储过程或SQL语句关联起来。MyBatis是在Apache许可证2.0下分发的自由软件,是iBATIS 3.0的分支版本。2001年开始开发的,是“internet”和“abtis(障碍物)”两个单词的组合。2004年捐赠给Apache,2010年更名为MyBatis。
对于MyBatis在java程序中的使用想必大家一定都比较清楚了,这里主要说说它的工作流程、架构分层与模块划分以及缓存机制。
mybatis启动的时候需要解析配置文件,包括全局配置文件和映射器配置文件,我们会把它们解析成一个Configuration对象。它包含了控制mybatis的行为以及对数据库下达的指令(SQL操作)。
应用程序与数据库进行连接是通过 SqlSession 对象完成的,如果需要获取一个会话,则需要通过会话工厂 SqlSessionFactory 接口来获取。
通过建造者模式 SqlSessionFactoryBuilder 来创建一个工厂类,它包含所有配置文件的配置信息。
SqlSession只是提供了一个接口,它还不是真正的操作数据库的SQL执行对象。
Executor接口用来封装对数据库的操作。调用其中query和update接口会创建一系列的对象,来处理参数、执行SQL、处理结果集,把它简化成一个对象接口就是 StatementHandler 。
简要的画一下MyBatis的工作流程图:
我们打开Mybatis的package,发现类似下面的结构:
按照不同的功能职责,也可以分成不同的工作层次。
Mybatis缓存的默认实现是 PerpetualCache 类,它是基于HashMap实现的。
PerpetualCache在Mybatis是基础缓存,但是缓存有额外的功能,比如策略回收、日志记录、定时刷新等等,如果需要使用这些功能,那么需要在基础缓存的基础上进行添加,需要的时候添加,不需要即可不用添加。在缓存cache包下,有很多装饰器模式的类实现了Cache接口,通过这些实现类可以实现很多缓存额外的功能。
所有的缓存实现总体上可以分为三大类:基本缓存、淘汰算法缓存、装饰器缓存。
Mybatis的一级缓存是存放在会话( SqlSession )层面的,一级缓存是默认开启的,不需要额外的配置,关闭的话设置 localCacheScope 的值为 STATEMENT 。源码的位置在 BaseExecutor 中,如下图:
如果需要在同一个会话共享一级缓存的话,那么最好的办法是在SqlSession内创建会话对象,让其成为SqlSession的一个属性,这样的话就很方便的操作一级缓存了。在同一个会话里多次执行相同的SQL语句,会直接从内存拿到缓存的结果集,不会再去数据库进行操作。如果在不同的会话中,即使SQL语句一模一样,也不会使用一级缓存的。
判断是否命中缓存?如果第二次发送SQL并且到数据库中执行,则说明没有命中缓存;如果直接打印对象,则说明是从内存中获取到的结果。
测试一级缓存需要先关闭二级缓存,将 LocalCacheScope 设置为 SESSION 。
public void testCache() throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session1 = sqlSessionFactory.openSession(); SqlSession session2 = sqlSessionFactory.openSession(); try { //在同一个session中共享 BlogMapper mapper0 = session1.getMapper(BlogMapper.class); BlogMapper mapper1 = session1.getMapper(BlogMapper.class); Blog blog = mapper0.selectBlogById(1); System.out.println(blog); System.out.println("第二次查询,相同会话,获取到缓存了吗?"); System.out.println(mapper1.selectBlogById(1)); //不同的session不能共享 System.out.println("第三次查询,不同会话,获取到缓存了吗?"); BlogMapper mapper2 = session2.getMapper(BlogMapper.class); System.out.println(mapper2.selectBlogById(1)); } finally { session1.close(); } } 复制代码
一级缓存在什么时候被清空失效的呢? 在同一个session中update(包括delete)会导致一级缓存被清空。
public void testCacheInvalid() throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session = sqlSessionFactory.openSession(); try { BlogMapper mapper = session.getMapper(BlogMapper.class); System.out.println(mapper.selectBlogById(1)); Blog blog = new Blog(); blog.setBid(1); blog.setName("after modified 666"); mapper.updateByPrimaryKey(blog); session.commit(); // 相同会话执行了更新操作,缓存是否被清空? System.out.println("在[同一个会话]执行更新操作之后,是否命中缓存?"); System.out.println(mapper.selectBlogById(1)); } finally { session.close(); } } 复制代码
一级缓存的工作范围是一个session中,如果跨session会出现什么问题呢? 如果其它的session更新了数据,会导致读取到过时的数据(一级缓存不能跨session共享)
public void testDirtyRead() throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session1 = sqlSessionFactory.openSession(); SqlSession session2 = sqlSessionFactory.openSession(); try { BlogMapper mapper1 = session1.getMapper(BlogMapper.class); System.out.println(mapper1.selectBlogById(1)); // 会话2更新了数据,会话2的一级缓存更新 Blog blog = new Blog(); blog.setBid(1); blog.setName("after modified 333333333333333333"); BlogMapper mapper2 = session2.getMapper(BlogMapper.class); mapper2.updateByPrimaryKey(blog); session2.commit(); // 其他会话更新了数据,本会话的一级缓存还在么? System.out.println("会话1查到最新的数据了吗?"); System.out.println(mapper1.selectBlogById(1)); } finally { session1.close(); session2.close(); } } 复制代码
一级缓存不能跨会话共享,不同的会话之间对于相同的数据可能有不同的缓存。在分布式环境(多会话)下,会存在查询到过时的数据的情况。如果有解决这个问题,那么需要引进工作范围更为广发的二级缓存。
二级缓存的生命周期和应用同步,它是用来解决一级缓存不能跨会话共享数据的问题,范围是namespace级别的,可以被多个会话共享(只要是同一个接口的相同方法,都可以进行共享)。
一级缓存是默认开始的,二级缓存如何开启呢? 1、在mybatis-config.xml中配置(默认是true)
<!-- 控制全局缓存(二级缓存),默认 true--> <setting name="cacheEnabled" value="true"/> 复制代码
只要没有显式地设置cacheEnabled为false,都会使用CachingExector装饰基本的执行器(SIMPLE、REUSE、BATCH)。 二级缓存总是默认开启的,但是每个Mapper的二级开关是默认关闭的。
2、在Mapper中配置cache标签
<!-- 声明这个namespace使用二级缓存 --> <cache type="org.apache.ibatis.cache.impl.PerpetualCache" size="1024"<!-- 最多缓存对象个数,默认是1024 --> eviction="LRU"<!-- 缓存策略 --> flushInterval="120000"<!-- 自动刷新时间ms,未配置是只有调用时刷新 --> readOnly="false"/><!-- 默认是false(安全),改为true可读写时,对象必须支持序列化 --> 复制代码
默认的回收内存策略是 LRU。可用的内存回收策略有:
Mapper.xml 配置了cache之后,select()会被缓存。update()、delete()、insert()会刷新缓存。:如果cacheEnabled=true,Mapper.xml 没有配置标签,还有二级缓存吗?(没有)还会出现CachingExecutor 包装对象吗?(会)
只要cacheEnabled=true基本执行器就会被装饰。有没有配置cache,决定了在启动的时候会不会创建这个mapper的Cache对象,只是最终会影响到CachingExecutorquery 方法里面的判断。如果某些查询方法对数据的实时性要求很高,不需要二级缓存,怎么办?我们可以在单个Statement ID 上显式关闭二级缓存(默认是true):
<select id="selectBlog" resultMap="BaseResultMap" useCache="false"> 复制代码
1、事务不提交,二级缓存会写入吗?
public void testCache() throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session1 = sqlSessionFactory.openSession(); SqlSession session2 = sqlSessionFactory.openSession(); try { BlogMapper mapper1 = session1.getMapper(BlogMapper.class); System.out.println(mapper1.selectBlogById(1)); // 事务不提交的情况下,二级缓存会写入吗?显然不会,为什么呢? session1.commit(); System.out.println("第二次查询"); BlogMapper mapper2 = session2.getMapper(BlogMapper.class); System.out.println(mapper2.selectBlogById(1)); } finally { session1.close(); } } 复制代码
为什么事务不提交,二级缓存不生效呢?因为二级缓存使用 TransactionalCacheManager (TCM)来管理,最后又调用了TransactionalCache 的getObject()、putObject和commit()方法,TransactionalCache里面又持有了真正的Cache对象,比如是经过层层装饰的 PerpetualCache 。在putObject 的时候,只是添加到了entriesToAddOnCommit里面, 只有它的commit()方法被调用的时候才会调用flushPendingEntries()真正写入缓存 。它就是在 DefaultSqlSession 调用 commit ()的时候被调用的。
public void commit() { if (clearOnCommit) { delegate.clear(); } // 真正写入二级缓存 flushPendingEntries(); reset(); } 复制代码
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); } } } 复制代码
在其它的会话中执行增删改操作,验证缓存被刷新
public void testCacheInvalid() throws IOException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session1 = sqlSessionFactory.openSession(); SqlSession session2 = sqlSessionFactory.openSession(); SqlSession session3 = sqlSessionFactory.openSession(); try { BlogMapper mapper1 = session1.getMapper(BlogMapper.class); BlogMapper mapper2 = session2.getMapper(BlogMapper.class); BlogMapper mapper3 = session3.getMapper(BlogMapper.class); System.out.println(mapper1.selectBlogById(1)); session1.commit(); // 是否命中二级缓存 System.out.println("是否命中二级缓存?"); System.out.println(mapper2.selectBlogById(1)); Blog blog = new Blog(); blog.setBid(1); blog.setName("2020年5月13日15:03:38"); mapper3.updateByPrimaryKey(blog); session3.commit(); System.out.println("更新后再次查询,是否命中二级缓存?"); // 在其他会话中执行了更新操作,二级缓存是否被清空? System.out.println(mapper2.selectBlogById(1)); } finally { session1.close(); session2.close(); session3.close(); } } 复制代码
为什么增删改操作会清空缓存?在 CachingExecutor 的update()方法里面会调用flushCacheIfRequired(ms), isFlushCacheRequired 就是从标签里面渠道的flushCache 的值。而增删改操作的 flushCache 属性默认为true。
private void flushCacheIfRequired(MappedStatement ms) { Cache cache = ms.getCache(); // 增删改查的标签上有属性:flushCache="true" (select语句默认是false) // 一级二级缓存都会被清理 if (cache != null && ms.isFlushCacheRequired()) { tcm.clear(cache); } } 复制代码
一级缓存默认是打开的,二级缓存需要配置才可以开启。那么我们必须思考一个问题,在什么情况下才有必要去开启二级缓存?
因为所有的增删改都会刷新二级缓存,导致二级缓存失效,所以适合在查询为主的应用中使用,比如历史交易、历史订单的查询。否则缓存就失去了意义。如果多个namespace 中有针对于同一个表的操作,如果在一个namespace中刷新了缓存,另一个namespace中没有刷新,就会出现读到脏数据的情况。所以,推荐在一个Mapper 里面只操作单表的情况使用。如果要让多个namespace共享一个二级缓存,应该怎么做?跨namespace的缓存共享的问题,可以使用cache-ref配置来解决:
<cache-ref namespace="com.sy.crud.dao.DepartmentMapper" /> 复制代码
cache-ref 代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。在关联的表比较少,或者按照业务可以对表进行分组的时候可以使用。
注意:在这种情况下,多个Mapper的操作都会引起缓存刷新,缓存的意义已经不大了
除了MyBatis 自带的二级缓存之外,我们也可以通过实现Cache 接口来自定义二级缓存。MyBatis官方提供了一些第三方缓存集成方式,比如ehcache 和redis:
github.com/mybatis/red…
当然,我们也可以使用独立的缓存服务,不使用MyBatis 自带的二级缓存。
pom文件引入的依赖:
<dependency> <groupId>org.mybatis.caches</groupId> <artifactId>mybatis-redis</artifactId> <version>1.0.0-beta2</version> </dependency> 复制代码
mapper.xml配置文件的内容:
<!-- 使用Redis作为二级缓存 --> <cache type="org.mybatis.caches.redis.RedisCache" eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/> 复制代码
redis.properties配置文件内容:
host=localhost port=6379 connectionTimeout=5000 soTimeout=5000 database=0 复制代码
当然,我们在分布式的环境中,也可以使用独立的缓存服务,不使用MyBatis自带的二级缓存。