前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《从Java面试题来看源码》,项目中使用 Mybatis 缓存吗?为什么项目中不用 Mybatis 的二级缓存?

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

作者头像
阿提说说
发布2022-12-02 16:41:53
3930
发布2022-12-02 16:41:53
举报
文章被收录于专栏:Java技术进阶Java技术进阶

为什么项目中不用 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) 在二级缓存执行流程后就会进入一级缓存的执行流程,因此不再赘述。

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

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-06-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 源码分析
  • 二级缓存实验
    • 实验 1
      • 实验 2
        • 实验 3
          • 实验 4
            • 实验 5
            • 源码分析
            相关产品与服务
            数据库
            云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档