大聪明教你学Java | 深入浅出聊 Mybatis 的一级缓存和二级缓存

1,069 阅读10分钟

前言

“我正在参加「掘金·启航计划」”

🍊作者简介: 不肯过江东丶,一个来自二线城市的程序员,致力于用“猥琐”办法解决繁琐问题,让复杂的问题变得通俗易懂。

🍊支持作者: 点赞👍、关注💖、留言💌~

前言

在计算机的世界中,缓存无处不在,操作系统有操作系统的缓存,数据库也会有数据库的缓存,我们还可以利用中间件(例如 Redis)来充当缓存。 MyBatis 作为一款优秀的 ORM 框架,也用到了缓存,那么今天咱们就一起来聊一聊 Mybatis 的一级缓存和二级缓存。

Mybatis 的一级缓存

首先我们先来看一张图片👇

在这里插入图片描述 我们在开发项目的过程中,如果我们开启了 Mybatis 的 SQL 语句打印,我们就会经常看到这句话:Creating a new SqlSession,其实这就是我们常说的 Mybatis 的一级缓存。

Mybatis 的一级缓存也就是在执行一次 SQL 查询或者 SQL 更新之后,这条 SQL 语句并不会消失,而是被 MyBatis 缓存起来,当再次执行相同SQL语句的时候,就会直接从缓存中进行提取,而不是再次执行SQL命令。一级缓存又被称为 SqlSession 级别的缓存,在操作数据库时需要构造 SqlSession 对象,在对象中有一个数据结构(HashMap)用于存储缓存数据。不同的 SqlSession 之间的缓存数据区(HashMap)是互相不影响的。

在我们的应用系统的运行期间,我们可能在一次数据库会话中,执行多次查询条件相同的 SQL 语句,那么针对此情况,你来设计的话你会如何考虑呢?没错,就是加缓存,MyBatis 也是这样去处理的,如果是相同的 SQL 语句,会优先命中一级缓存,避免直接对数据库进行查询,造成数据库的压力,以提高性能。具体执行过程如下图所示👇

在这里插入图片描述

SqlSession 是一个接口,提供了一些 CRUD 的方法,而 SqlSession 的默认实现类是 DefaultSqlSession,DefaultSqlSession 类持有 Executor 接口对象,而 Executor 的默认实现是 BaseExecutor 对象,每个 BaseExecutor 对象都有一个 PerpetualCache 缓存,也就是上图的 Local Cache。当用户发起查询时,MyBatis 根据当前执行的语句生成 MappedStatement,在 Local Cache 进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入 Local Cache,最后返回结果给用户。这时候可能有小伙伴要说了:我还在控制台上见到了“Closing non transactional SqlSession ”这句话,那我每次创建的 SqlSession 到最后都被关闭了,那我还缓存个毛线了 😥

在这里插入图片描述 事请当然不会像我们想象的那样,我们继续往下看👇

🍊 getSqlSession 源码

public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType, PersistenceExceptionTranslator exceptionTranslator) {
    Assert.notNull(sessionFactory, "No SqlSessionFactory specified");
    Assert.notNull(executorType, "No ExecutorType specified");
    // 如果当前我们开启了事物,那就从 ThreadLocal 里面获取 session
    SqlSessionHolder holder = (SqlSessionHolder)TransactionSynchronizationManager.getResource(sessionFactory);
    SqlSession session = sessionHolder(executorType, holder);
    if (session != null) {
        return session;
    } else {
        LOGGER.debug(() -> {
            return "Creating a new SqlSession";
        });
        // 没有获取到 session,创建一个 session
        session = sessionFactory.openSession(executorType);
        // 如果当前开启了事物,就把这个session注册到当前线程的 ThreadLocal 里面去
        registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);
        return session;
    }
}

🍊 closeSqlSession 源码

public static void closeSqlSession(SqlSession session, SqlSessionFactory sessionFactory) {
    Assert.notNull(session, "No SqlSession specified");
    Assert.notNull(sessionFactory, "No SqlSessionFactory specified");
    SqlSessionHolder holder = (SqlSessionHolder)TransactionSynchronizationManager.getResource(sessionFactory);
    if (holder != null && holder.getSqlSession() == session) {
        LOGGER.debug(() -> {
            return "Releasing transactional SqlSession [" + session + "]";
        });
        holder.released();
    } else {
        LOGGER.debug(() -> {
            return "Closing non transactional SqlSession [" + session + "]";
        });
        session.close();
    }

}

我们使用官方的解释来说 closeSqlSession 方法就是:检查作为参数传递的 SqlSession 是否由 Spring TransactionSynchronizationManage 管理。如果不是,则关闭它,否则它只更新引用计数器,并在托管事务结束时让 Spring 调用关闭回调。简单点来说就是“如果我们方法是开启事物的,则当前事物内是获取的同一个 sqlSession,否则每次都是获取不同的 sqlSession”,所以我们也并不需要担心无法获取到对应的缓存。这时候有些小伙伴可能又有疑问了:Mybatis 的一级缓存什么情况下会过期呢?各位稍安勿躁,我们接着往下看👇

我们一开始就说了,Mybatis 的一级缓存是存在 sqlSession 里面的,毫无疑问当 sqlSession 被清空或者关闭的时候缓存就没了(在不开启事物的情况下,每次都会关闭 sqlSession);除此之外,在执行 insert、update、delete 的时候也会清空缓存。我们通过源码可以发现 sqlSession 的 insert 和 delete 方法的本质都是执行的 update 方法 👇

在这里插入图片描述 在这里插入图片描述

在这里插入图片描述 我们再来看看 update 的源码👇

public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (this.closed) {
        throw new ExecutorException("Executor was closed.");
    } else {
        this.clearLocalCache();
        return this.doUpdate(ms, parameter);
    }
}

执行到 this.clearLocalCache(); 的时候,缓存就已经被清理掉了,也就是说此时 Mybatis 的一级缓存就过期了🧐

我们说了这么多,相信各位小伙伴也了解到了 MyBatis 一级缓存的相关内容,不过 MyBatis 的一级缓存最大的共享范围就是一个 SqlSession 内部,那么如果多个 SqlSession 需要共享缓存该怎么办呢?没错!这时候就需要 MyBatis 的二级缓存登场了 😎

Mybatis 的二级缓存

如果需要多个 SqlSession 共享缓存,则需要我们开启二级缓存。开启二级缓存后,会使用 CachingExecutor 装饰 Executor,进入一级缓存的查询流程前,先在 CachingExecutor 进行二级缓存的查询,具体的工作流程如下所示👇 在这里插入图片描述 当二级缓存开启后,同一个命名空间(namespace)所有的操作语句,都影响着一个共同的 cache,也就是二级缓存被多个 SqlSession 共享,我们可以将其理解成一个全局变量。当开启二级缓存后,数据的查询执行流程就变为了:二级缓存 → 一级缓存 → 数据库。关于查询的执行流程,我们可以通过源码加以佐证,在 CachingExecutor 文件下的 query 方法很容易就看到了,如果开启二级缓存那就走二级缓存,否则就走一级缓存,如下图所示👇

在这里插入图片描述 Mybatis 的二级缓存不像一级缓存默认就是开启的,我们需要在对应的 Mapper 文件里面加上 cache 标签,手动开启 Mybatis 的二级缓存👇

在这里插入图片描述 我们可以看到 cache 标签有多个属性,我们先来一起看一下这些属性都分别代表了什么含义:

  • type:指定自定义缓存的全类名(一般我们可以使用该 Mapper 文件的全路径作为 type 值)。
  • readOnly:是否只读。true 只读,MyBatis 认为所有从缓存中获取数据的操作都是只读操作,不会修改数据,同时 MyBatis 为了加快获取数据的速度,直接就会将数据在缓存中的引用交给用户,虽然速度快变快了,但是安全性却降低了。如果不设置该属性的话,则默认为读写。
  • size:缓存存放多少个元素。
  • blocking:若缓存中找不到对应的key,是否会一直阻塞(blocking),直到有对应的数据进入缓存。
  • flushinterval:缓存刷新间隔,缓存多长时间刷新一次,默认不刷新。
  • eviction: 缓存回收策略,回收策略共有以下四种

LRU:最近最少回收,移除最长时间不被使用的对象(默认值) FIFO:先进先出,按照缓存进入的顺序来移除它们 SOFT:软引用,移除基于垃圾回收器状态和软引用规则的对象 WEAK:弱引用,更积极的移除基于垃圾收集器和弱引用规则的对象

🍊 解析 cache 标签的 cacheElement 方法源码

private void cacheElement(XNode context) {
    if (context != null) {
        String type = context.getStringAttribute("type", "PERPETUAL");
        Class<? extends Cache> typeClass = this.typeAliasRegistry.resolveAlias(type);
        String eviction = context.getStringAttribute("eviction", "LRU");
        Class<? extends Cache> evictionClass = this.typeAliasRegistry.resolveAlias(eviction);
        Long flushInterval = context.getLongAttribute("flushInterval");
        Integer size = context.getIntAttribute("size");
        boolean readWrite = !context.getBooleanAttribute("readOnly", false);
        boolean blocking = context.getBooleanAttribute("blocking", false);
        Properties props = context.getChildrenAsProperties();
        this.builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }

}

不知道各位小伙伴知不知道 Mybatis 的二级缓存应用了什么设计模式呢?其中最明显的就是应用了装饰器模式~

public Cache build() {
	// 设置默认的缓存实现类和默认的装饰器(PerpetualCache 和 LruCache)
    this.setDefaultImplementations();
    // 创建基本的缓存
    Cache cache = this.newBaseCacheInstance(this.implementation, this.id);
    // 设置自定义的参数
    this.setCacheProperties((Cache)cache);
    // 如果是PerpetualCache 的缓存,将进一步进行处理
    if (PerpetualCache.class.equals(cache.getClass())) {
        Iterator var2 = this.decorators.iterator();

        while(var2.hasNext()) {
            Class<? extends Cache> decorator = (Class)var2.next();
            // 进行最基本的装饰
            cache = this.newCacheDecoratorInstance(decorator, (Cache)cache);
            // 设置自定义的参数
            this.setCacheProperties((Cache)cache);
        }
		// 创建标准的缓存,也就是根据配置来进行不同的装饰
        cache = this.setStandardDecorators((Cache)cache);
    } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
    	// 如果是自定义的缓存实现,这里只进行日志装饰器
        cache = new LoggingCache((Cache)cache);
    }

    return (Cache)cache;
}

既然是装饰器模式,那肯定不止一两种装饰器😄 Mybatis 的源码中一共提供了多种装饰器,比如LruCache、ScheduledCache、LoggingCache 等等,我们通过类名就大概能猜到他们的作用👇

在这里插入图片描述

这里有一点是需要注意的:其实他们并不是 cache 的实现类,真正的实现类只有 PerpetualCache ,红框里面的类都是对 PerpetualCache 的包装。

我们了解了缓存装饰器,我们再来看看设置标准装饰器的源码👇

private Cache setStandardDecorators(Cache cache) {
    try {
    	// 获取当前 cache的参数
        MetaObject metaCache = SystemMetaObject.forObject(cache);
        if (this.size != null && metaCache.hasSetter("size")) {
            metaCache.setValue("size", this.size);
        }
		// 如果设置了缓存刷新时间,就进行ScheduledCache 装饰
        if (this.clearInterval != null) {
            cache = new ScheduledCache((Cache)cache);
            ((ScheduledCache)cache).setClearInterval(this.clearInterval);
        }
		// 如果缓存可读可写,就需要进行序列化 默认就是 true,这也是为什么我们的二级缓存的需要实现序列化(即对应实体类必须实现序列化接口)
        if (this.readWrite) {
            cache = new SerializedCache((Cache)cache);
        }
		// 默认都装饰 日志和同步
        Cache cache = new LoggingCache((Cache)cache);
        cache = new SynchronizedCache(cache);
        // 如果开启了阻塞就装配阻塞
        if (this.blocking) {
            cache = new BlockingCache((Cache)cache);
        }

        return (Cache)cache;
    } catch (Exception var3) {
        throw new CacheException("Error building standard cache decorators.  Cause: " + var3, var3);
    }
}

看完这块代码,心理就是一个字:爽!! 能把装饰器模式用的如此精妙,也真是没谁了。该说不说,只要能把这块源码理解通透,那装饰器模式就真的完全掌握了😉

通过上面的源码,我们知道 Mybatis 的二级缓存默认就是可读可写的缓存,它会用 SynchronizedCache 进行装饰,我们来看来SynchronizedCache 的 putObject 方法👇

public void putObject(Object key, Object object) {
    if (object != null && !(object instanceof Serializable)) {
        throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
    } else {
        this.delegate.putObject(key, this.serialize((Serializable)object));
    }
}

这也就是为什么二级缓存的实体一定要实现序列化接口的原因了,当然如果将二级缓存设置为只读的缓存,那么也就不需要实现序列化接口了。

最后我们回归实际,在分布式架构盛行的当下,我们该如何选择使用哪种缓存呢?其实答案也很简单:除非对性能要求特别高,否则一级缓存和二级缓存都不建议使用,Mybatis 的一级缓存和二级缓存都是基于本地的,分布式环境下必然会出现脏读

虽然 Mybatis 的二级缓存可以通过实现 Cache 接口集中管理缓存,避免出现脏读的情况,但是有一定的开发成本,并且在多表查询时,使用不当极有可能会出现脏数据~

小结

本人经验有限,有些地方可能讲的没有特别到位,如果您在阅读的时候想到了什么问题,欢迎在评论区留言,我们后续再一一探讨🙇‍

希望各位小伙伴动动自己可爱的小手,来一波点赞+关注 (✿◡‿◡) 让更多小伙伴看到这篇文章~ 蟹蟹呦(●'◡'●)

如果文章中有错误,欢迎大家留言指正;若您有更好、更独到的理解,欢迎您在留言区留下您的宝贵想法。

爱你所爱 行你所行 听从你心 无问东西