Mybatis 实现原理之 一二级缓存

6 篇文章 0 订阅
6 篇文章 0 订阅

引言

对Mybatis一直都没有做实质的记录。 现记录Mybatis的一些实现细节。组成一个系列。

本片文章讲述的是Mybatis是如何无感知的让用户使用到一二级缓存,以及一二级缓存的实现细节和实现原理。

系列文章:Mybatis 实现原理之 JDK动态代理和XML语句执行

结论通常意义上的Mybatis一级缓存, 指的是SqlSession级别缓存,即在同一个SqlSession下,可以共享的缓存,该缓存默认被开启(可通过每行CRUD标签<flushCache>关闭),依据查询使用的Mapper和方法名、传参等组成查询Key,复用SqlSession的时候可以直接从缓存里面查询;

Mybatis二级缓存,指的是Mapper(namespace)级别的缓存,是全局的,需要指定cacheEnabled字段(默认为true)以及每个CRUD的<useCache>字段,以及每个Mapper的<cache>标签。即每个Mapper下相同的查询条件,会直接从缓存中获取结果。

2019年04月10日19:01:37 更新:
关于一级缓存中的SqlSession,它是如何实现全局的SqlSession,又实现一级缓存的效果的呢~ 详见 Mybatis的一级缓存 – 基于SqlSession


SqlSession的生命周期

本博文只谈及Spring环境下的SqlSession

在博文Mybatis 实现原理之 JDK动态代理和XML语句执行中有提到,Spring环境中的SqlSession实现者是SqlSessionTemplate, 但同时,它使用到了包装器模式。实际包装的, 依然是DefaultSqlSession

包装器中的DefaultSqlSession在应用程序的运行中, 会频繁的创建和销毁。
Spring管理的SqlSessionTemplate会贯穿整个Application。


SqlSession的创建 – 动态代理和包装器

Mybatis的Mapper的执行,依托于MapperProxy这个代理类。在博文Mybatis 实现原理之 JDK动态代理和XML语句执行介绍了Mapper代理的创建和执行过程。

SqlSessionTemplate 的使用非常巧妙,它委托给Spring管理, 生命周期为整个Application。但是实际的执行者并不是它。而是它的一个类元素SqlSessionTemplate#sqlSessionProxy这个代理类,同样,代理类的生命周期也是贯穿整个Application。它会频繁的创造DefaultSqlSession用于SQL的执行。

那么, 它是怎么实现SqlSession创建和事务内复用的呢?

sqlSessionProxy, 顾名思义, 是一个代理类。实际的代理类是SqlSessionTemplate.SqlSessionInterceptor这个InvocationHandler。它会根据当前的事务环境重新创建或者复用SqlSession。创建时序图如下:

执行Mapper方法 MapperProxy代理 执行SqlSession SqlSession getSqlSession() 真正的执行 调用invoke方法动态代理 执行SqlSession的CRUD方法 SqlSessionTemplate的元素sqlSessionProxy是被代理的 根据当前的事务情况,创建或者返回复用的SqlSession 这里面可能还涉及到一二级缓存 执行Mapper方法 MapperProxy代理 执行SqlSession SqlSession getSqlSession() 真正的执行

此处重点分析下SqlSessionUtils#getSqlSession()方法

SqlSessionUtils创建SqlSession的方式即是SessionFactory#openSession()(DefaultSqlSessionFactory)。然后它会将创建好的SqlSession(DefaultSqlSession)放到TransactionSynchronizationManager中。存储依赖于ThreadLocal,以及当前程序运行的事务环境(基于AOP)。因此:

  • 可以很好的做到线程隔离。
  • 一个线程下,一个SessionFactory创建的SqlSession在整个线程周期的可传递的事务中复用。
  • 自然,跨线程的一级缓存无从谈起,没有事务也没有一级缓存。但是还是有可能用上二级缓存。
  • 只支持Spring管理的事务,才会复用SqlSession(启用一级缓存)。

在非Spring环境中,源于Mybatis的各项构造相对麻烦, 一般写法也是通过DefaultSqlSessionFactory#openSession()去创建SqlSession。且一般都是即用即创建。每次创建完毕之后便等待垃圾回收器去回收。不建议全局复用一个SqlSession


Mybatis的一级缓存 – 基于SqlSession

Mybatis的一级缓存默认开启,虽然Mybatis的一级缓存是SqlSession缓存,但是它储存位置并不是在SqlSession, 而是Executor中。是一个HashMap(PerpetualCache持有)。

在博文Mybatis 实现原理之 JDK动态代理和XML语句执行有讲到, SqlSession的CRUD基于Executor。它的实现类都实现了一级缓存:优先从localCache获取,获取不到的情况下再从数据库中查询

具体逻辑可以参考如下代码片段(org.apache.ibatis.executor.BaseExecutor#query):

      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      if (list != null) {
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
        // 从数据库里面查询
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }

需要注意的是缓存的Key, 参见#createCacheKey方法,另参见CacheKey#hashCode()CacheKey#equals(Object)

2019年04月10日更新
如上文 SqlSession的创建 – 动态代理和包装器 描述, Spring环境中, SqlSessionTemplate 生命周期贯穿整个Application,而一级缓存又是基于SqlSession、当前线程。 这其中是不是有什么矛盾?
*
实际不矛盾⬇️⬇️⬇️ 此SqlSession并非Spring的SqlSessionTemplate

SqlSessionTemplate的CRUD依托于SqlSessionTemplate.SqlSessionInterceptor这个InvocationHandler(SqlSessionTemplate本身不执行任何CRUD)。
动态代理会依据当前的线程运行情况依据全局的DefaultSqlSessionFactory创建或者复用SqlSession。并会在任意命令之后完毕之后选择关闭创建的SqlSession


Mybatis的一级缓存的可行性分析

Spring环境中,对于缓存的使用,最起码的条件就是:

分支条件实现方案是否满足
同SQL Query,同结果CacheKey/localCache满足
数据有Update,不再查询缓存SqlSession,所有查询都执行clear()方法满足
只在事务中生效当前线程、当前事务共享SqlSession满足
不同的事务隔离级别下,缓存生效与否区分不同的传播行为满足

理论上的一级缓存,只应该存在于事务之中,且无关具体数据源的事务隔离级别。
Spring环境中, Mybatis的SqlSession,在开启了事务、同一个线程、同一个SqlSessionFactory中,就会共享。这就意味着一级缓存也会共享。
Mybatis通过相同的Mapper+Method+Parameter等参数定位具体缓存。
每次的Update(insert/delete)语句,都会导致缓存被清空。
无关数据库事务隔离级别配置, 在Spring默认配置下, Mybatis的一级缓存可行度非常高。
需要注意的事情是,Mybatis的一级缓存只是用的HashMap,未做容量限制。如果查询出来的结果集特别大,最好还是默认关闭一级缓存

Mybatis的一级缓存的作用域 – 当前线程,当前事务

Mybatis的一级缓存是可行的, 如通过源码分析来展示它的作用域。

Spring环境中Mybatis的SqlSession(一级缓存) 需要基于事务。在事务中才会获取到当前事务、当前线程共享的SqlSession

基于当前线程
TransactionSynchronizationManager#doGetResource(Object)
这里的actualKey是SqlSessionFactoryresources是一个ThreadLocal。
返回的value是SqlSessionHolder

	private static Object doGetResource(Object actualKey) {
		Map<Object, Object> map = resources.get();
		if (map == null) {
			return null;
		}
		Object value = map.get(actualKey);
		// OTHER
		return value;
	}

基于事务
在获取SqlSession之后, 当前线程会尝试着将其缓存。
通过 TransactionSynchronizationManager.isSynchronizationActive() 判断是不是要缓存这个SqlSession
它依据一个类元素TheadLocal是否存放了值来判断 true/false。
而这个TheadLocal的set方法, 必须在启用了事务之后才会被调用。
只要启动了事务,且事务隔离级别满足需要。几乎都会缓存SqlSession
见: AbstractPlatformTransactionManager#getTransaction(TransactionDefinition)
在层级调用之后, 在TransactionSynchronizationManager#initSynchronization()里面塞了默认值。

else if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||
    definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||
    definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED) 

如上, Spring环境中Mybatis的SqlSession的缓存, 几乎只基于事务传播类别。而缓存的SqlSession, 放在ThreadLocal中。自然, 一级缓存作用域也就是 当前线程,当前事务

Mybatis的二级缓存 – 基于Mapper(namespace)

在Mybatis中, 使用到了大量的包装器模式、动态代理。二级缓存也如此。

Mybatis的二级缓存 – 依赖CachingExecutor

在博文Mybatis 实现原理之 JDK动态代理和XML语句执行中有提到,Mybatis的SqlSession的执行, 实质上依赖于Executor的执行。

Executor
|_BaseExecutor
   |_SimpleExecutor
|_CachingExecutor

而二级缓存, 依赖的就是类:org.apache.ibatis.executor.CachingExecutor。 其获取方式如下Configuration#newExecutor(Transaction, ExecutorType):

  public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
      executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
      executor = new ReuseExecutor(this, transaction);
    } else {
      executor = new SimpleExecutor(this, transaction);
    }
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
  }

这里的cacheEnabled字段是默认为True的。

在开启(默认不开启)了二级缓存之后, 会优先从二级缓存里面取。 如下代码片段:

  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();
    if (cache != null) {
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, parameterObject, boundSql);
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
          list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

如上优先从HashMap中取值, 实现二级缓存。Key还是一级缓存的那个Key.
同时也不难发现, 二级缓存的优先级是高于一级缓存的。

Mybatis的二级缓存 – 如何开启关闭

从之前的代码片段中可以发现, Mybatis的查询默认使用的是CachingExecutor(cacheEnabled默认 true开启)。 但是它还有三个控件:

  • <select> SQL语句XML标签上的 flushCache 字段。默认 false开启。可控制一级缓存和二级缓存的开启(true为关闭)
  • <select> SQL语句XML标签上的 useCache 字段。默认 true开启
  • XML Mapper的文件上增加<cache></cache>标签。默认 无关闭

也就是说Mybatis的二级缓存默认是被关闭的。默认添加标签就可以开启。 至于关闭, 从上述四个控件着手即可:

  • cacheEnabled全局关闭, 查询都使用SimpleExecutor
  • useCache单条关闭,只影响当前SQL
  • flushCache不建议使用,它的工作原理是在查询缓存前清空缓存。数据还是会缓存,包括一级和二级缓存。
  • 不要写 <cache></cache>标签

对于上述的二级缓存开启/关闭方法中, 除了 <cache></cache> 之外, 还可以使用 @CacheNamespace 在 Mapper上。
@CacheNamespace与<cache></cache> 冲突, 只能选一个。
在使用的时候往往发现它不生效, 是因为XML标签只与XML标签搭配。注解只与注解搭配。
如: <cache></cache> 开启即可生效。
或者:@CacheNamespace 必须搭配 @Select("select a from b")

Mybatis的二级缓存 – 实现源码解析

二级缓存是被存放在MappedStatement#cache中(每条SQL语句), 他们持有的是当前Mapper(namespace)的缓存的引用, 也就是说二级缓存是一个namespace级别的缓存。

通过之前的代码片段可以看到, 缓存的获取通过Mybatis的一个工具TransactionalCacheManager来取出。实际的缓存K/V存放数据结构非常复杂。Key与一级缓存的Key一致, 参见CacheKey, V层次较深, 也大量使用到了包装器模式, 包装层次为:

TransactionalCache -> SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache -> HashMap

在开启了二级缓存之后, 便会给相应的namespace创建二级缓存Cache, MappedStatement#cache才会有值。如下代码片段才会走 if 语句(CachingExecutor#query):

    Cache cache = ms.getCache();
    if (cache != null) {
        // 查缓存
    }

这个缓存的创建也是非常繁杂。可参见代码XMLMapperBuilder#cacheElement(XNode)(使用注解创建的二级缓存代码类似)。

缓存的创建方法可参见: CacheBuilder#build()跟二级缓存相关的参数, 如缓存大小、缓存刷新时间等相关的参数, 也可以在此类内被阅读(当然也可看出, Mybatis是多喜欢包装器模式)


Mybatis的二级缓存作用域 – Namespace/跨事务

二级缓存被区分不同的namespace创建之后, 还有一个存储过程。 它是跨事务的。
同时, 因为二级缓存被MappedStatement持有, MappedStatement的生命周期为Application, 因此二级缓存的生命周期也延续整个Application

为什么说二级缓存是Namespace级别的缓存?

Mybatis源码中的查询大致时序:

执行Mapper方法 MapperProxy代理 执行SqlSession Executor 执行 调用invoke方法动态代理,寻找MapperMethod 执行SqlSession的CRUD方法 MappedStatement是具体接口->>方法的映射 通过查询缓存/查询数据库 执行Mapper方法 MapperProxy代理 执行SqlSession Executor 执行

这里的查缓存,就是CachingExecutorMappedStatement里面的二级缓存。而这个二级缓存,是在装配XML获取到XML的Mapper映射时, 根据<cache></cache>这个标签所建立的。MappedStatement持有的,与其对应的namespace是同一个对象。

二级缓存是跨事务的, 在当前事务中只能享受一级缓存, 无法使用到二级缓存。
毕竟在第一次事务中,已经可以直接从以及缓存取值了。
那么,二级缓存是怎么从一级缓存中跑到二级缓存中去的呢?

当一个Spring管理的事务执行完毕, 对应的事务则需要commit。毕竟实际的SQL执行者还是Executor,自然是委托给Executor#commit(boolean)。在对数据库发完毕commit命令的时候,实现类CachingExecutor也会对TransactionalCacheManager执行commit。 也就是把当前事务查询到的缓存,提交到二级缓存里面去。

我理解这么做大致有这么些好处:
1.缓存一致性。只有被提交的事务,它的查询才有意义被提交成为二级缓存。
2.事务中本身已经能查一级缓存,没必要在事务中将没有完毕的事务数据提交到二级缓存(二级缓存可以被其它线程/事务访问)。


Mybatis的二级缓存清理 – update/依赖LRU等

清理策略一:update(insert / update / delete 语句)操作。
清理策略二:完全依赖这几个缓存类:TransactionalCache -> SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache -> HashMap他们需要是有序的,但是不一定全都有。他们的用处分别是:

类型描述作用
TransactionalCache事务中缓存由这个管理,缓存事务管理事务提交时将缓存提交
SynchronizedCache都有,同步器,在所有方法前加上synchronized多线程抢占
LoggingCache记录日志在DEBUG级别下记录缓存命中率
SerializedCache序列化到IO以ObjectOutputStream存Value
LruCacheLinkedHashMap存最远最少访问(还有一个FIFO Cache)超过配置Size(不是字节大小)后删除最老的Value
PerpetualCache和一级缓存一样, 包装器包装HashMap
HashMap实际存储存<CacheKey, 数据>

对于更新时候的清理, 则是在所有的update语句执行的时候,将缓存清理掉。 这个的机理类似于XML里面的flushCache这个字段。 只不过在<select> 语句中它默认是false, 在<update>/<insert>/<delete>语句中, 它默认是true。参见:

  @Override
  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
  }
  
  private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {      
      tcm.clear(cache);
    }
  }

为什么任何的update SQL都可以清理掉整个namespace的缓存呢?

  • 一个namespace(Mapper)下的所有的SQL语句共享一个二级缓存(xml还可以写成多份)。
  • SQL语句约等于MappedStatement。它们都持有namespace的二级缓存的引用。
  • SELECT默认的flushCachefalse, UPDATE默认true。它们操作同一份。
  • 一旦有UPDATE执行,二级缓存就清空了。

从上面的描述中也可以得出,不同的namespace(Mapper)下的SQL, 持有的二级缓存是不一致的。也就是说,对于同样的数据库表, 另一个namespace(Mapper)下的UPDATE SQL, 无法清空当前namespace(Mapper)下的二级缓存。

也就是说,同样的表的CRUD, 如果启用了二级缓存, 得写在一块。否则会出现缓存脏读


总结

Mybatis的一级缓存基于SqlSession, 在一个线程、一次事务上(这样才能获得同一个SqlSession)才生效。 默认开启, 通过flushCache这个标签关闭(不建议)。 每次更新语句执行之后就会被清空。


Mybatis的二级缓存基于namespace(Mapper)

  • 开启比较麻烦
    • 需要使用XML里面的<cache>\</cache>标签。
    • 或者使用@CacheNamespace注解。
    • 上两者不能共同使用。
    • @CacheNamespace注解只能搭配@select使用。不能搭配XML语句。
  • 生命周期很长, 整个Application
    • 一个namespace下所有SQL共享。
    • 遇见UPDATE语句之后就会被清空。
    • 只有在事务提交之后才会被提交到二级缓存中更新原有缓存。
    • 不开启事务也可以用。每次Query都会提交到二级缓存。
  • 优先级高于一级缓存。
  • 使用到二级缓存,需要保证CRUD在一个namespace(Mapper)下。
  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值