3.[源码]mybatis二级缓存源码分析(二)----二级缓存是如何实现的

上一篇我们介绍了mybatis的二级缓存作用范围, 二级缓存与一级缓存的结构关系, 今天就来介绍二级缓存本身是如何实现的~ 友情提示: 搭配 [源码]mybatis二级缓存源码分析(一)----一级缓存与二级缓存的结构关系 食用更香。

NO.1 |如何开启二级缓存

开启二级缓存的方式也比较简单, 如下:

第一步: MyBatis 配置文件中配置<settings>      <setting name = "cacheEnabled" value = "true" /></settings>第二步: 在Mapper.xml文件中配置<cache/>标签, 一个Mapper.xml文件拥有唯一的namespace(命名空间)<cache type="org.apache.ibatis.cache.impl.PerpetualCache" size="1024"  eviction="LRU"  flushInterval="120000"  readOnly="false" />也可以配置<cache-ref/>,<cache-ref/>标签是为了引用其他的命名空间,那么当前命名空间将与引用的命名空间使用同一个缓存(对于同一命名空间下的多表查询可借助该标签避免脏读问题)

1.标签属性含义

在开启二级缓存的第二步中, 要在Mapper.xml文件中配置标签, 同时也可以为标签拥有的属性赋值, 那标签的属性们的含义都是什么?

type -代表着缓存的默认实现;
size -代表缓存容量;
eviction-代表溢出淘汰策略;
flushInterval-代表缓存有效期;
readOnly- 是否只读,若配置可读写,则需要对应的实体类能够序列化;
blocking- 若缓存中找不到对应的key, 是否一直阻塞, 直到有对应的数据放入缓存;

2.产生的效果

做了如上配置后产生的效果如下

a.映射语句文件中的所有 select 操作的结果将会被缓存。
b.映射语句文件中的所有 update操作( insert 、update 和 delete )会刷新缓存。
c.缓存会使用最近最少使用(LRU, Least Recently Used)算法来淘汰不需要的缓存。
d.缓存会间隔120000ms后清空一次缓存。
e.缓存会保存列表或对象(无论查询方法返回哪种)的 1024 个引用。
f.缓存会被视为读写缓存, 需要查询出来要被缓存的实体类实现Serializable接口。  这意味着获取到的对象并不是共享的,可以安全地被调用者修改, 而不干扰其他调用者或线程 。

关于readOnly="false"为何需要查询出来的缓存实体类实现序列化接口:

这是因为二级缓存为了保证读写安全, 开启了序列化功能, 缓存中保存的不再是查询出的对象本身, 而是查询出的对象进行序列化后的字节序列, 在获取数据的时候, 又会把存好的字节序列进行反序列化, 克隆出新对象, 进行返回。

所以对从二级缓存中得到数据做任何写操作, 都不会影响到缓存中原有的对象, 也就不会影响到其他来获取数据的调用者或线程。

tips: Java序列化就是指把Java对象转换为字节序列的过程。Java反序列化就是指把字节序列恢复为Java对象的过程。而在反序列化的时候会根据字节序列中保存的对象状态及描述信息, 重建对象。

NO.2 |二级缓存组件结构

从以上的描述中我们看出, Mybatis的二级缓存要实现的功能更加复杂, 比如: 线程安全, 过期清理, 命中率统计, 序列化….

Mybatis为了尽可能的职责分明的实现这些复杂逻辑, 在这里使用了一种设计模式: 装饰者+ 责任链(变种), 对二级缓存的功能组件进行设计。至于为什么说是一个责任链变种, 我们需要先了解一下经典责任链的定义。

tips: 责任链: (经典定义) 是一个请求有多个对象来处理,这些对象是一条链,但具体由哪个对象来处理,根据条件判断来确定,如果不能处理会传递给该链中的下一个对象,直到有对象处理它为止。

而责任链中的链, 是如何形成的呢? 举一个栗子, 比如我们的链式结构是a对象->b对象->c对象, 那我们就让a对象持有b对象, b对象持有c对象。从a对象开始, a对象的方法中可以调用b对象的方法, 而b对象的方法中也可以调用c对象的方法, 通过这样的方式, 便形成了一条责任链。

经典责任链的方式, 要根据条件判断, 虽然也许会经过链条上的很多对象, 但最终只有一个对象真正对请求进行了处理, 其他对象仅仅完成了向下传递。Mybatis的二级缓存使用的责任链模式则不同, 每一个链条上的对象不仅要调用自身持有的对象的方法, 完成了责任链的向下传递, 也要完成自己的功能实现。所以说Mybatis使用的是责任链的变种形式。

二级缓存的组件结构如下图所示:

image

二级缓存组件的顶级接口是Cache, 定义了二级缓存的api, 比如设置缓存, 取出缓存。Cache下方有很多实现类, 正是这些实现类形成责任链, 组成了二级缓存。
实际上的结构是否如此呢, 在获取二级缓存的时候, 对二级缓存进行Debug, 就可以印证我们刚才的说法了。 image

可以看出, 最上层是SyncronizedCache, 持有了一个名为delegate的LoggingCache类型对象, 以此类推, 直到链条上的最后一个Cache的实现类—PerpetualCache。而PerpetualCache本身持有了一个HashMap, 这才是二级缓存数据的真正存放地(缓存区)。

以查询为例,在调用二级缓存的getObject()方法的时候, 就会从链条的起始端, 比如SynchronizedCache, 开始调用SynchronizedCache的getObject()方法, 在getObject()方法里面, 每个实现类都有两部分的事情要做, 一个是完成自己特有的职能, 另一个是调用链条上的下一个Cache实现类的getObject()方法, 直到链条的尾端, 比如PerpetualCache。调用链虽然复杂, 但是每个实现类都是完成自己特有的附加功能, 而最终真正完成数据存储工作的只有PerpetualCache这个类。

先来看下PerpetualCache这个类的源码, 在这个类中的getObject方法, 仅仅是从map中取出数据

public class PerpetualCache implements Cache:
private Map<Object, Object> cache = new HashMap<>();
@Overridepublic Object getObject(Object key) {  
  return cache.get(key);
}

而链条上的其他的Cache实现类是不是按照之前介绍的那样, 做自己的功能并调用自己持有的链条上的下一个实现类的方法呢, 我们也可以以几个实现类的源码为例来论证。比如: SynchronizedCache(负责线程安全)和 LoggingCache(负责命中率统计)。

在查看SynchronizedCache类的源码的时候, 不要忽略getObject方法上的synchronized关键字,这个方法在负责线程安全的问题的后, 便调用了责任链上的下一个对象的getObject()方法。

public class SynchronizedCache implements Cache:
private final Cache delegate;

@Override
public synchronized Object getObject(Object key) {  
	// 注意:看这里!!委派给下一个缓存实现类执行getObject()方法  
	return delegate.getObject(key);
}

LoggingCache的getObject方法中,除了调用链条上的下一个对象的方法外,还会统计请求的次数和命中的次数,以此计算打印命中率。

public class LoggingCache implements Cache:

private final Cache delegate;
@Override
public Object getObject(Object key){
  	requests++; //请求次数  
	// 注意:看这里!! 委派给下一个缓存实现类执行getObject()方法  
	final Object value = delegate.getObject(key);  
	if (value != null) {
    	hits++;  // 命中次数  
	} 
    if (log.isDebugEnabled()) {
        log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());  
  	}  
    return value;
}

NO.3 |事务缓存管理器

1.结构

我们都知道一个会话中的事务在未提交之前, 其他会话是不允许读到它未提交的数据的。在未加入二级缓存之前, 会话之间的都是如下图所示的样子, 各自为政, 互不干扰。

image

通过上一篇的学习可以了解到, 二级缓存是可以跨会话的。那么这里我们要思考一下, 如果我们加入了二级缓存, 并且按照缓存的一贯思路(进行查询操作的时候先查缓存, 如果缓存中没有命中即查询数据库, 并且把查到的结果缓存到二级缓存中)来做, 会不会破坏原本的隔离性, 产生脏读? 来看下面一张图。
image

会话1首先进行了修改操作, 然后进行了查询操作, 并且查询后就把查到的结果放入缓存中, 而此时会话2也进行了查询操作, 就会查到缓存中的结果直接返回, 尴尬的是会话1最终没有提交事务, 选择了回滚。这样就造成了会话2读到的数据不准确, 读到了会话1未提交的数据, 产生了脏读。

所以Mybatis的二级缓存在设计时针对这样的情况, 引入了事务缓存管理器。在事务缓存管理器中, 维护了一个本地暂存区(会话范围内可见), 本地暂存区又指向真正的缓存区(跨会话)。在进行查询操作的时候, 会到缓存区中查看是否命中。如果没有命中, 查询数据库得到数据后, 仅仅把查询的结果放入暂存区, 在提交事务的时候才要把暂存区中的数据刷新到缓存区。如果发生了回滚, 则清空本地暂存区缓存的数据, 不会刷新到缓存区, 这样一来就避免了脏读的产生。

接下来我们先来通过部分源码了解一下事务管理器的结构:

从以下代码可以看出每个CachingExecutor对应一个事务缓存管理器, 通过前面的学习我们知道, 每个会话中持有一个CachingExecutor(缓存执行器)。所以每个会话都有自己单独的事务缓存管理器

CachingExecutor:
private final Executor delegate;
private final TransactionalCacheManager tcm = new TransactionalCacheManager();

从以下代码我们得知, 在事务缓存管理器中维护了一个HashMap, 这个HashMap便是暂存区的集合, 而且这个map的key是cache(缓存区), 所以每一个缓存区都有对应的暂存区(TransactionalCache), 放在map中作为键值对被事务缓存管理器所维护, 因为每个会话都有自己单独的事务缓存管理器, 作为管理器属性集合中的一个对象—暂存区也只是会话可见的。

TransactionalCacheManager:

private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();

接下来看一下代表着暂存区的TransactionalCache, 可以看见其中也维护了一个Map, 这个map是暂存区真正用来暂存数据的地方, 而delegate属性, 代表的便是真正的缓存区(刚刚介绍过的, Cache的实现类组成的责任链, 完成了缓存区的维护), 有了与缓存区之间的关联, 在提交事务的时候, 就可以方便的把暂存区的数据刷新到缓存区了。

public class TransactionalCache implements Cache :

private final Cache delegate;//指向缓存区
private boolean clearOnCommit;
private final Map<Object, Object> entriesToAddOnCommit;//暂存区

介绍完事务管理器, 暂存区, 缓存区之间的结构关系, 我们来通过源码看下二级缓存进行查询和更新的过程。

2.查询

从之前文章的学习我们已经知道, 如果使用到二级缓存, 在查询时, 会调用二级缓存的query方法。这里主要看其中的**tcm.getObject(cache, key)tcm.putObject(cache, key, list)**方法, 一个是通过事务缓存管理器取数据的方法, 一个是通过事务管理器放入数据的方法。

CachingExecutor:
private final Executor delegate;
private final TransactionalCacheManager tcm = new TransactionalCacheManager();
@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, boundSql);
        // 从二级缓存中查询数据
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);
        // 如果二级缓存中没有查询到数据,则查询数据库
        if (list == null) {
          // 委托给BaseExecutor执行
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    // 委托给BaseExecutor执行
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }
(1)tcm.getObject(cache, key)—>取出数据

在CachingExecutor的query()方法中, 先是调用了事务缓存管理器的getObject(cache, key)方法。可以看见TransactionalCacheManager在处理getObject()的时候先调用了getTransactionalCache(), 从map集合中取出当前缓存区对应的TransactionalCache(暂存区), 暂存区如果不存在, 则创建一个新的暂存区对象存入map, 然后调用获得的TransactionalCache的getObject()方法。

TransactionalCacheManager:
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
}
private TransactionalCache getTransactionalCache(Cache cache) {
    return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
}

在TransactionalCache的getObject()方法中, 直接调用了其指向的缓存区的getObject()方法, 说明二级缓存在获取数据的时候会直接去缓存区(跨会话)取数据

而在clearOnCommit这个布尔值为true的时候, 即使缓存区命中数据也只能返回null, 这是因为, 只有在有更新操作且未提交的时候clearOnCommit才是true, 这种状态对于当前会话当前事务来说, 缓存区的数据已经不准确了, 所以最好的选择是重新查询数据库。

public class TransactionalCache implements Cache :
private final Cache delegate;//指向缓存区(链条式的Cache实现类)
private boolean clearOnCommit;//执行更新后clearOnCommit将变为true
private final Map<Object, Object> entriesToAddOnCommit;//本地暂存//获取缓存数据, 从缓存区去查询
@Override
public Object getObject(Object key) {
  Object object = delegate.getObject(key); 
  if (object == null) {
    entriesMissedInCache.add(key);
   } 
  if (clearOnCommit) {//如果更新了数据, 缓存区就算有数据也要返回空, 要去数据库中取数据
        return null;  
   } else {
    return object;  
   }
}
(2)tcm.putObject(cache, key, list)—>放入数据

在query()方法中, 没有从缓存区中取到数据, 而重新查询了数据的情况下, 就要调用tcm.putObject(), 通过事务管理器设置数据到缓存。与getObject()一样, TransactionalCacheManager的putObject()方法也要先调用getTransactionalCache()获得TransactionalCache(暂存区), 然后调用TransactionalCache的putObject()方法。

TransactionalCacheManager:
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void putObject(Cache cache, CacheKey key, Object value) {
  getTransactionalCache(cache).putObject(key, value);
}
private TransactionalCache getTransactionalCache(Cache cache) {
  return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
}

如果我们继续查看, 就会发现在TransactionalCache的putObject()方法中, 数据仅被存到了暂存区中

public class TransactionalCache implements Cache:
@Overridepublic void putObject(Object key, Object object) {
  entriesToAddOnCommit.put(key, object); // 存数据, 存到暂存区
}
3.提交

在提交的方法中, 我们会把暂存区中的所有内容刷新到缓存区中

在我们调用sqlSession.commit()方法的时候, 也会调用当前会话持有的缓存执行器的commit()方法, 缓存执行器会执行事务缓存管理器的commit()方法。看一下事务缓存管理器的提交的源码, 在事务缓存管理器的commit()方法中, 会调用事务缓存管理器所有暂存区(TransactionalCache)的commit()方法。

TransactionalCacheManager:
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void commit() {
  for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
  }
}

在TransactionalCache的commit()方法中, 如果有未提交的更新操作(clearOnCommit为true), 则要清空缓存区, 因为更新后, 缓存区的数据便是不准确的了。随后调用flushPendingEntries()和reset()两个方法, flushPendingEntries()方法负责把所有暂存区的内容刷新到缓存中。而reset()方法则负责把本地暂存区清空, 同时把clearOnCommit 置为false。

public class TransactionalCache implements Cache:
private final Cache delegate;//指向缓存区(链条式的Cache实现类)
private boolean clearOnCommit;//执行更新后clearOnCommit将变为true
private final Map<Object, Object> entriesToAddOnCommit;//本地暂存
public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
}
private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    for (Object entry : entriesMissedInCache) {
      if (!entriesToAddOnCommit.containsKey(entry)) {
        delegate.putObject(entry, null);
      }
    }
}
private void reset() {
    clearOnCommit = false;
    entriesToAddOnCommit.clear();
    entriesMissedInCache.clear();
}
4.更新

在缓存执行器调用更新操作的时候, 会调用flushCacheIfRequired(), 这个方法中会先判断ms.isFlushCacheRequired(), 为true并且二级缓存存在就会执行事务缓存执行器的clear()方法, 而isFlushCacheRequired()就是从标签里面取到的flushCache的值。而增删改操作的flushCache属性默认为true。所以进行更新的时候, 也会调用事务缓存管理器的clear方法。

public class CachingExecutor implements Executor:
private final Executor delegate;
private final TransactionalCacheManager tcm = new TransactionalCacheManager();  
@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);
    }
}

在TransactionalCacheManager 的clear方法中。依然是先获取暂存区, 并调用暂存区的clear()方法。

TransactionalCacheManager:
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void clear(Cache cache) {
  getTransactionalCache(cache).clear();
}

TransactionalCache的clear()方法中, clearOnCommit属性被置为了true, 并清空了暂存区。清空暂存区不难理解, 因为如果存在更新操作, 则暂存区中暂存起来的数据则有可能不再准确了。并且缓存区也定然出现了不一致的情况, 所以在TransactionalCache的commit方法中, 会去判断clearOnCommit是否为true(即是否进行过更新操作), 如果是, 缓存区的数据也会被clear()掉。而在清除执行完成后, reset()方法中会把clearOnCommit重新置为false。

NO.4 |总结

Mybatis使用了装饰者+责任链(变种)的模式构建了二级缓存的组件, 每一个功能都有相应的Cache实现类来完成, 同时这些实现类也会调用自己持有的Cache实现类, 完成责任链。最终被调用的类是PerpetualCache ,它就是最终负责数据存储的类。

而为了解决二级缓存跨会话使用可能引起的脏读问题, mybatis引入了事务缓存管理器, 每一个会话持有一个事务缓存管理器, 每个事务缓存管理器维护着多个缓存区(每个namespace都有对应的缓存区)对应的暂存区, 暂存区中维护本地暂存数据, 并指向它所属的缓存区。

通过事务缓存管理器查询的时候, 直接去查缓存区, 但是如果没有命中, 重新查询出的数据仅放入暂存区, 直到进行提交, 才把数据刷新到缓存区。这是为了防止其他会话查到当前会话中的事务未提交的数据。而在执行更新操作的时候, 会先清空对应的暂存区数据, 在提交事务的时候, 也会把对应的缓存区数据清空。

结合我们之前讲的两篇文章, 所有关于mybatis多级缓存的事情就交代清楚了。image

这里除了编程知识分享,同时也是我成长脚印的记录, 期待与您一起学习和进步, 长按下方二维码关注公众号: 程序媛swag。如果您觉得这篇文章帮助到了您, 请帮忙点个在看 !

image

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值