> 文档中心 > 《从面试题来看源码》,项目中使用 Mybatis 缓存吗?为什么项目中不用 Mybatis 的二级缓存?

《从面试题来看源码》,项目中使用 Mybatis 缓存吗?为什么项目中不用 Mybatis 的二级缓存?

 

为什么项目中不用 Mybatis 的二级缓存

答:MyBatis 的二级缓存相对于一级缓存来说,实现了 SqlSession 之间缓存数据的共享,同时粒度更加的细,能够到 namespace 级别,通过 Cache 接口实现类不同的组合,对 Cache 的可控性也更强。
但 MyBatis 在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
在分布式环境下,由于默认的 MyBatis Cache 实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将 MyBatis 的 Cache 接口实现,有一定的开发成本,直接使用 Redis,Memcached 等分布式缓存可能成本更低,安全性也更高。

源码分析

二级缓存开启后,同一个 namespace 下的所有操作语句,都影响着同一个 Cache,即二级缓存被多个 SqlSession 共享,是一个全局的变量。
当开启缓存后,数据的查询执行的流程就是 二级缓存 -> 一级缓存 -> 数据库。

二级缓存配置
要正确的使用二级缓存,需完成如下配置的。

在 MyBatis 的配置文件中开启二级缓存。

在 MyBatis 的映射 XML 中配置 cache 或者 cache-ref 。
cache 标签用于声明这个 namespace 使用二级缓存,并且可以自定义配置。

  • type:cache 使用的类型,默认是 PerpetualCache,这在一级缓存中提到过。

  • eviction: 定义回收的策略,常见的有 FIFO,LRU。

  • flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。

  • size: 最多缓存对象的个数。

  • readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。

  • blocking: 若缓存中找不到对应的 key,是否会一直 blocking,直到有对应的数据进入缓存。

cache-ref: 代表引用别的命名空间的 Cache 配置,两个命名空间的操作使用的是同一个 Cache。

二级缓存实验

接下来我们通过实验,了解 MyBatis 二级缓存在使用上的一些特点。
在本实验中,id 为 1 的学生名称初始化为点点。

实验 1

测试二级缓存效果,不提交事务,sqlSession1 查询完数据后,sqlSession2 相同的查询是否会从缓存中获取数据。

@Testpublic void testCacheWithoutCommitOrClose() throws Exception {        SqlSession sqlSession1 = factory.openSession(true);         SqlSession sqlSession2 = factory.openSession(true);         StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));}

我们可以看到,当 sqlsession 没有调用 commit () 方法时,二级缓存并没有起到作用。

实验 2

测试二级缓存效果,当提交事务时,sqlSession1 查询完数据后,sqlSession2 相同的查询是否会从缓存中获取数据。

@Testpublic void testCacheWithCommitOrClose() throws Exception {        SqlSession sqlSession1 = factory.openSession(true);         SqlSession sqlSession2 = factory.openSession(true);         StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));        sqlSession1.commit();        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));}

sqlsession2 的查询,使用了缓存,缓存的命中率是 0.5。

实验 3

测试 update 操作是否会刷新该 namespace 下的二级缓存。

@Testpublic void testCacheWithUpdate() throws Exception {        SqlSession sqlSession1 = factory.openSession(true);         SqlSession sqlSession2 = factory.openSession(true);         SqlSession sqlSession3 = factory.openSession(true);         StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);        StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));        sqlSession1.commit();        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));        studentMapper3.updateStudentName("方方",1);        sqlSession3.commit();        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));}

在 sqlSession3 更新数据库,并提交事务后,sqlsession2 的 StudentMapper namespace 下的查询走了数据库,没有走 Cache。

实验 4

验证 MyBatis 的二级缓存不适应用于映射文件中存在多表查询的情况。
通常我们会为每个单表创建单独的映射文件,由于 MyBatis 的二级缓存是基于 namespace 的,多表查询语句所在的 namspace 无法感应到其他 namespace 中的语句对多表查询中涉及的表进行的修改,引发脏数据问题。

@Testpublic void testCacheWithDiffererntNamespace() throws Exception {        SqlSession sqlSession1 = factory.openSession(true);         SqlSession sqlSession2 = factory.openSession(true);         SqlSession sqlSession3 = factory.openSession(true);         StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);        ClassMapper classMapper = sqlSession3.getMapper(ClassMapper.class);        System.out.println("studentMapper读取数据: " + studentMapper.getStudentByIdWithClassInfo(1));        sqlSession1.close();        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));        classMapper.updateClassName("特色一班",1);        sqlSession3.commit();        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));}

在这个实验中,我们引入了两张新的表,一张 class,一张 classroom。
class 中保存了班级的 id 和班级名,classroom 中保存了班级 id 和学生 id。
我们在 StudentMapper 中增加了一个查询方法 getStudentByIdWithClassInfo,用于查询学生所在的班级,涉及到多表查询。
在 ClassMapper 中添加了 updateClassName,根据班级 id 更新班级名的操作。
当 sqlsession1 的 studentmapper 查询数据后,二级缓存生效。
保存在 StudentMapper 的 namespace 下的 cache 中。
当 sqlSession3 的 classMapper 的 updateClassName 方法对 class 表进行更新时,updateClassName 不属于 StudentMapper 的 namespace,所以 StudentMapper 下的 cache 没有感应到变化,没有刷新缓存。
当 StudentMapper 中同样的查询再次发起时,从缓存中读取了脏数据。

实验 5

为了解决实验 4 的问题呢,可以使用 Cache ref,让 ClassMapper 引用 StudenMapper 命名空间,这样两个映射文件对应的 Sql 操作都使用的是同一块缓存了。

不过这样做的后果是,缓存的粒度变粗了,多个 Mapper namespace 下的所有操作都会对缓存使用造成影响。

源码分析

源码分析从 CachingExecutor 的 query 方法展开,源代码走读过程中涉及到的知识点较多,不能一一详细讲解,读者朋友可以自行查询相关资料来学习。
CachingExecutor 的 query 方法,首先会从 MappedStatement 中获得在配置初始化时赋予的 Cache。
Cache cache = ms.getCache();

本质上是装饰器模式的使用,具体的装饰链是
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。

以下是具体这些 Cache 实现类的介绍,他们的组合为 Cache 赋予了不同的能力。

  • SynchronizedCache: 同步 Cache,实现比较简单,直接使用 synchronized 修饰方法。

  • LoggingCache: 日志功能,装饰类,用于记录缓存的命中率,如果开启了 DEBUG 模式,则会输出命中率日志。

  • SerializedCache: 序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的 Copy,用于保存线程安全。

  • LruCache: 采用了 Lru 算法的 Cache 实现,移除最近最少使用的 key/value。

  • PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了 HashMap。

然后是判断是否需要刷新缓存,代码如下所示:
flushCacheIfRequired(ms);
在默认的设置中 SELECT 语句不会刷新缓存,insert/update/delte 会刷新缓存。进入该方法。代码如下所示:

private void flushCacheIfRequired(MappedStatement ms) {    Cache cache = ms.getCache();    if (cache != null && ms.isFlushCacheRequired()) {            tcm.clear(cache);    }}

MyBatis 的 CachingExecutor 持有了 TransactionalCacheManager,即上述代码中的 tcm。
TransactionalCacheManager 中持有了一个 Map,代码如下所示:
private Map transactionalCaches = new HashMap();

这个 Map 保存了 Cache 和用 TransactionalCache 包装后的 Cache 的映射关系。
TransactionalCache 实现了 Cache 接口,CachingExecutor 会默认使用他包装初始生成的 Cache,作用是如果事务提交,对缓存的操作才会生效,如果事务回滚或者不提交事务,则不对缓存产生影响。
在 TransactionalCache 的 clear,有以下两句。清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存,代码如下所示:

@Overridepublic void clear() {    clearOnCommit = true;    entriesToAddOnCommit.clear();}

CachingExecutor 继续往下走,ensureNoOutParams 主要是用来处理存储过程的,暂时不用考虑。

if (ms.isUseCache() && resultHandler == null) {    ensureNoOutParams(ms, parameterObject, boundSql);

之后会尝试从 tcm 中获取缓存的列表。
List list = (List) tcm.getObject(cache, key);

在 getObject 方法中,会把获取值的职责一路传递,最终到 PerpetualCache。如果没有查到,会把 key 加入 Miss 集合,这个主要是为了统计命中率。

Object object = delegate.getObject(key);if (object == null) {    entriesMissedInCache.add(key);}

CachingExecutor 继续往下走,如果查询到数据,则调用 tcm.putObject 方法,往缓存中放入值。

if (list == null) {    list = delegate. query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);    tcm.putObject(cache, key, list); // issue #578 and #116}

tcm 的 put 方法也不是直接操作缓存,只是在把这次的数据和 key 放入待提交的 Map 中。

@Overridepublic void putObject(Object key, Object object) {    entriesToAddOnCommit.put(key, object);}

从以上的代码分析中,我们可以明白,如果不调用 commit 方法的话,由于 TranscationalCache 的作用,并不会对二级缓存造成直接的影响。因此我们看看 Sqlsession 的 commit 方法中做了什么。代码如下所示:

@Overridepublic void commit(boolean force) {    try {      executor.commit(isCommitOrRollbackRequired(force));

因为我们使用了 CachingExecutor,首先会进入 CachingExecutor 实现的 commit 方法。

@Overridepublic void commit(boolean required) throws SQLException {    delegate.commit(required);    tcm.commit();}

会把具体 commit 的职责委托给包装的 Executor。主要是看下 tcm.commit (),tcm 最终又会调用到 TrancationalCache。

public void commit() {    if (clearOnCommit) {      delegate.clear();    }    flushPendingEntries();    reset();}

看到这里的 clearOnCommit 就想起刚才 TrancationalCache 的 clear 方法设置的标志位,真正的清理 Cache 是放到这里来进行的。具体清理的职责委托给了包装的 Cache 类。之后进入 flushPendingEntries 方法。代码如下所示:

private void flushPendingEntries() {    for (Map.Entry entry : entriesToAddOnCommit.entrySet()) {      delegate.putObject(entry.getKey(), entry.getValue());    }    ................}

在 flushPendingEntries 中,将待提交的 Map 进行循环处理,委托给包装的 Cache 类,进行 putObject 的操作。
后续的查询操作会重复执行这套流程。如果是 insert|update|delete 的话,会统一进入 CachingExecutor 的 update 方法,其中调用了这个函数,代码如下所示:
private void flushCacheIfRequired(MappedStatement ms)
在二级缓存执行流程后就会进入一级缓存的执行流程,因此不再赘述。

以上就是对该面试题的源码分析。

关注我,给你看更多面试分析 

 

英语听力