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

2022-12-02 16:41:53 浏览数 (1)

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

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

源码分析

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

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

在 MyBatis 的配置文件中开启二级缓存。 <setting name="cacheEnabled" value="true"/>

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

  • type:cache 使用的类型,默认是 PerpetualCache,这在一级缓存中提到过。
  • eviction: 定义回收的策略,常见的有 FIFO,LRU。
  • flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
  • size: 最多缓存对象的个数。
  • readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
  • blocking: 若缓存中找不到对应的 key,是否会一直 blocking,直到有对应的数据进入缓存。

cache-ref: 代表引用别的命名空间的 Cache 配置,两个命名空间的操作使用的是同一个 Cache。 <cache-ref namespace="mapper.StudentMapper"/>

二级缓存实验

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

实验 1

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

代码语言:javascript复制
@Test
public 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 相同的查询是否会从缓存中获取数据。

代码语言:javascript复制
@Test
public 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 下的二级缓存。

代码语言:javascript复制
@Test
public 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 中的语句对多表查询中涉及的表进行的修改,引发脏数据问题。

代码语言:javascript复制
@Test
public 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 会刷新缓存。进入该方法。代码如下所示:

代码语言:javascript复制
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<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

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

代码语言:javascript复制
@Override
public void clear() {
    clearOnCommit = true;
    entriesToAddOnCommit.clear();
}

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

代码语言:javascript复制
if (ms.isUseCache() && resultHandler == null) {
    ensureNoOutParams(ms, parameterObject, boundSql);

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

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

代码语言:javascript复制
Object object = delegate.getObject(key);
if (object == null) {
    entriesMissedInCache.add(key);
}

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

代码语言:javascript复制
if (list == null) {
    list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
    tcm.putObject(cache, key, list); // issue #578 and #116
}

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

代码语言:javascript复制
@Override
public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
}

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

代码语言:javascript复制
@Override
public void commit(boolean force) {
    try {
      executor.commit(isCommitOrRollbackRequired(force));

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

代码语言:javascript复制
@Override
public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    tcm.commit();
}

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

代码语言:javascript复制
public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
}

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

代码语言:javascript复制
private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    ................
}

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

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

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

0 人点赞