面试官口中的Mybatis,工作流程、架构分层与模块划分以及缓存机制

在我们平时的业务开发中,经常会使用“半自动化”的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自带的二级缓存。