在我们平时的业务开发中,经常会使用“半自动化”的ORM框架Mybatis解决程序对数据库操作问题。MyBatis是一个Java持久化框架,它通过XML描述符或注解把对象与存储过程或SQL语句关联起来。MyBatis是在Apache许可证2.0下分发的自由软件,是iBATIS 3.0的分支版本。2001年开始开发的,是“internet”和“abtis(障碍物)”两个单词的组合。2004年捐赠给Apache,2010年更名为MyBatis。
对于MyBatis在java程序中的使用想必大家一定都比较清楚了,这里主要说说它的工作流程、架构分层与模块划分以及缓存机制。
一、MyBatis的工作流程 1.1 解析配置文件(Configuration) mybatis启动的时候需要解析配置文件,包括全局配置文件和映射器配置文件,我们会把它们解析成一个Configuration对象。它包含了控制mybatis的行为以及对数据库下达的指令(SQL操作)。
1.2 提供操作接口(SqlSession) 应用程序与数据库进行连接是通过SqlSession 对象完成的,如果需要获取一个会话,则需要通过会话工厂SqlSessionFactory 接口来获取。
通过建造者模式SqlSessionFactoryBuilder 来创建一个工厂类,它包含所有配置文件的配置信息。
SqlSession 只是提供了一个接口,它还不是真正的操作数据库的SQL执行对象。
1.3 执行SQL操作 Executor 接口用来封装对数据库的操作。调用其中query和update接口会创建一系列的对象,来处理参数、执行SQL、处理结果集,把它简化成一个对象接口就是StatementHandler 。
简要的画一下MyBatis的工作流程图:
二、MyBatis的架构分层与模块划分 我们打开Mybatis的package,发现类似下面的结构:
按照不同的功能职责,也可以分成不同的工作层次。
三、MyBatis的缓存 3.1 缓存体系结构 Mybatis缓存的默认实现是PerpetualCache 类,它是基于HashMap实现的。
PerpetualCache 在Mybatis是基础缓存,但是缓存有额外的功能,比如策略回收、日志记录、定时刷新等等,如果需要使用这些功能,那么需要在基础缓存的基础上进行添加,需要的时候添加,不需要即可不用添加。在缓存cache包下,有很多装饰器模式的类实现了Cache接口,通过这些实现类可以实现很多缓存额外的功能。
所有的缓存实现总体上可以分为三大类:基本缓存、淘汰算法缓存、装饰器缓存。
3.2 一级缓存(Local Cache) Mybatis的一级缓存是存放在会话(SqlSession )层面的,一级缓存是默认开启的,不需要额外的配置,关闭的话设置localCacheScope 的值为STATEMENT 。源码的位置在BaseExecutor 中,如下图:
如果需要在同一个会话共享一级缓存的话,那么最好的办法是在SqlSession内创建会话对象,让其成为SqlSession的一个属性,这样的话就很方便的操作一级缓存了。在同一个会话里多次执行相同的SQL语句,会直接从内存拿到缓存的结果集,不会再去数据库进行操作。如果在不同的会话中,即使SQL语句一模一样,也不会使用一级缓存的。
一级缓存的验证方式
判断是否命中缓存?如果第二次发送SQL并且到数据库中执行,则说明没有命中缓存;如果直接打印对象,则说明是从内存中获取到的结果。
测试一级缓存需要先关闭二级缓存,将LocalCacheScope 设置为SESSION 。
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 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)会导致一级缓存被清空。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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共享)
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 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(); } }
一级缓存的不足之处
一级缓存不能跨会话共享,不同的会话之间对于相同的数据可能有不同的缓存。在分布式环境(多会话)下,会存在查询到过时的数据的情况。如果有解决这个问题,那么需要引进工作范围更为广发的二级缓存。
3.3 二级缓存 二级缓存的生命周期和应用同步,它是用来解决一级缓存不能跨会话共享数据的问题,范围是namespace级别的,可以被多个会话共享(只要是同一个接口的相同方法,都可以进行共享)。
二级缓存的流程图:
一级缓存是默认开始的,二级缓存如何开启呢? 1、在mybatis-config.xml中配置(默认是true)
1 2 <!-- 控制全局缓存(二级缓存),默认 true--> <setting name="cacheEnabled" value="true"/>
只要没有显式地设置cacheEnabled为false,都会使用CachingExector装饰基本的执行器(SIMPLE、REUSE、BATCH)。二级缓存总是默认开启的,但是每个Mapper的二级开关是默认关闭的。
2、在Mapper中配置cache标签
1 2 3 4 5 6 <!-- 声明这个namespace使用二级缓存 --> <cache type="org.apache.ibatis.cache.impl.PerpetualCache" size="1024"<!-- 最多缓存对象个数,默认是1024 --> eviction="LRU"<!-- 缓存策略 --> flushInterval="120000"<!-- 自动刷新时间ms,未配置是只有调用时刷新 --> readOnly="false"/><!-- 默认是false(安全),改为true可读写时,对象必须支持序列化 -->
Cache属性详解:
默认的回收内存策略是 LRU。可用的内存回收策略有:
LRU – 最近最少使用:移除最长时间不被使用的对象。
FIFO – 先进先出:按对象进入缓存的顺序来移除它们。
SOFT – 软引用:基于垃圾回收器状态和软引用规则移除对象。
WEAK – 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。
Mapper.xml 配置了cache之后,select()会被缓存。update()、delete()、insert()会刷新缓存。:如果cacheEnabled=true,Mapper.xml 没有配置标签,还有二级缓存吗?(没有)还会出现CachingExecutor 包装对象吗?(会)
只要cacheEnabled=true基本执行器就会被装饰。有没有配置cache,决定了在启动的时候会不会创建这个mapper的Cache对象,只是最终会影响到CachingExecutorquery 方法里面的判断。如果某些查询方法对数据的实时性要求很高,不需要二级缓存,怎么办?我们可以在单个Statement ID 上显式关闭二级缓存(默认是true):
1 <select id="selectBlog" resultMap="BaseResultMap" useCache="false">
二级缓存的验证方式
1、事务不提交,二级缓存会写入吗?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 ()的时候被调用的。
1 2 3 4 5 6 7 8 public void commit() { if (clearOnCommit) { delegate.clear(); } // 真正写入二级缓存 flushPendingEntries(); reset(); }
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); } } }
在其它的会话中执行增删改操作,验证缓存被刷新
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 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。
1 2 3 4 5 6 7 8 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配置来解决:
1 <cache-ref namespace="com.sy.crud.dao.DepartmentMapper" />
cache-ref 代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。在关联的表比较少,或者按照业务可以对表进行分组的时候可以使用。
注意:在这种情况下,多个Mapper的操作都会引起缓存刷新,缓存的意义已经不大了
第三方缓存做二级缓存
除了MyBatis 自带的二级缓存之外,我们也可以通过实现Cache 接口来自定义二级缓存。MyBatis官方提供了一些第三方缓存集成方式,比如ehcache 和redis:
https://github.com/mybatis/redis-cache
当然,我们也可以使用独立的缓存服务,不使用MyBatis 自带的二级缓存。
pom文件引入的依赖:
1 2 3 4 5 <dependency> <groupId>org.mybatis.caches</groupId> <artifactId>mybatis-redis</artifactId> <version>1.0.0-beta2</version> </dependency>
mapper.xml配置文件的内容:
1 2 3 <!-- 使用Redis作为二级缓存 --> <cache type="org.mybatis.caches.redis.RedisCache" eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
redis.properties配置文件内容:
1 2 3 4 5 host=localhost port=6379 connectionTimeout=5000 soTimeout=5000 database=0
当然,我们在分布式的环境中,也可以使用独立的缓存服务,不使用MyBatis自带的二级缓存。