原来如此!MyBatis缓存原理竟然是这样的

MyBatis缓存详解

文章目录

      • MyBatis缓存详解
      • 缓存
        • BaseExecutor一级缓存(会话级缓存)
        • ReuseExecutor中的Statement缓存
        • 为什么整合Spring框架一级缓存会失效
        • 二级缓存
        • 设计二级缓存的扩展性需求
        • MyBatis中二级缓存的底层架构
        • 二级缓存命中条件
        • 二级缓存的配置参数
        • 二级缓存中的事物缓存管理器

缓存

BaseExecutor一级缓存(会话级缓存)

代码演示:

一、运行时参数相关

  1. sql语句相同,参数相同。

    1
    2
    3
    4
    5
    6
    7
    8
    @Test
    public void baseCacheTest(){
        SqlSession session = factory.openSession(true);
        UserDao mapper = session.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        User user2 = mapper.findById(4);
        System.out.println(user1 == user2);
    }

    运行结果:

    true

  2. 不同的mappedStatementId不能命中缓存,即使sql与参数一致

    1
    2
    3
    4
    5
    6
    <select id="findById" resultType="com.lxy.entity.User" parameterType="Integer">
        SELECT * FROM user WHERE id=#{id}
    </select>
    <select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer">
        SELECT * FROM user WHERE id=#{id}
    </select>
    1
    2
    3
    4
    5
    6
    7
    8
    @Test
    public void baseCacheTest(){
        SqlSession session = factory.openSession(true);
        UserDao mapper = session.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        User user2 = mapper.findById2(4);
        System.out.println(user1 == user2);
    }

    运行结果:

    false

  3. RowBounds必须相同,否则不会命中缓存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    public void baseCacheTest(){
        SqlSession session = factory.openSession(true);
        UserDao mapper = session.getMapper(UserDao.class);
        RowBounds rowBounds = new RowBounds(0,10);
        User user1 = mapper.findById(4);
        List<User> list = session.selectList("com.lxy.dao.UserDao.findById",4, rowBounds);
        System.out.println(user1 == list.get(0));
    }

    运行结果:false;

  4. 不同session不会命中缓存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Test
    public void baseCacheTest() {
        SqlSession session = factory.openSession(true);
        UserDao mapper = session.getMapper(UserDao.class);
        SqlSession session2 = factory.openSession(true);
        UserDao mapper2 = session2.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        User user2 = mapper2.findById(4);
        System.out.println(user1 == user2);
    }

    运行结果:false

二、操作与配置相关

  1. 进行第一次操作后清除了本地缓存后不能命中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    public void baseCacheTest2() {
        SqlSession session = factory.openSession(true);
        UserDao mapper = session.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        session.clearCache();
        User user2 = mapper.findById(4);
        System.out.println(user1 == user2);
    }

    运行结果:false;

  2. 在接口方法使用注解@Options(flushCache = Options.FlushCachePolicy.TRUE),注意,此方式为前置清空缓存,也就是说在每次执行sql之前清空一级缓存

    1
    2
    @Options(flushCache = Options.FlushCachePolicy.TRUE)
    User findById(int id);
    1
    2
    3
    4
    5
    6
    7
    8
    @Test
    public void baseCacheTest2() {
        SqlSession session = factory.openSession();
        UserDao mapper = session.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        User user2 = mapper.findById(4);
        System.out.println(user1 == user2);
    }

    运行结果为:false;

  3. 在查询后执行修改也会清空一级缓存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    public void baseCacheTest3() {
        SqlSession session = factory.openSession();
        UserDao mapper = session.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        mapper.update("mybatis",4);
        User user2 = mapper.findById(4);
        System.out.println(user1 == user2);
    }

    运行结果:false;

  4. 将缓存作用于降低至STATEMENT不会命中

    1
    2
    3
    <settings>
        <setting name="localCacheScope" value="STATEMENT"/>
    </settings>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    //降低缓存作用域
    @Test
    public void baseCacheTest4() {
        SqlSession session = factory.openSession();
        UserDao mapper = session.getMapper(UserDao.class);
        User user1 = mapper.findById(4);
        User user2 = mapper.findById(4);
        System.out.println(user1 == user2);
    }

    运行结果:false;注意:此处一级缓存并没有完全关闭,对于嵌套查询还是起作用的。此清空方式为后置清空,在每个查询及其子查询结束后清空缓存,其目的是控制缓存的作用范围,让一级缓存只在每个查询及其子查询中生效。

  5. 执行提交或回滚也会清空缓存

ReuseExecutor中的Statement缓存

可重用执行器中维护的是JDBC的Statement缓存,并不是一级缓存,这点要区分开来。在ReuseExecutor内部使用的是JDBC的PreparedStatement,它的特点上面已经介绍过了,就是相同的sql语句可以复用,可重用执行器的内部也是根据sql语句是否一致来判断该sql是否存在Statement缓存中的。下面来看几个例子。

一、mappedStatementsId相同,参数不相同,不同的实体类

1
2
3
4
5
6
7
8
@Test
public void reuseCacheTest(){
    SqlSession session = factory.openSession(ExecutorType.REUSE,true);
    UserDao mapper = session.getMapper(UserDao.class);
    mapper.findById(4);
    mapper.findById(2);
   
}

执行结果:

==> Preparing: SELECT * FROM user WHERE id=?
> Parameters: 4(Integer)
<
Total: 1

二:不同的mappedStatementsId但是sql语句相同,参数不相同

1
2
3
4
5
6
<select id="findById" resultType="com.lxy.entity.User" parameterType="Integer">
    SELECT * FROM user WHERE id=#{id}
</select>
<select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer">
    SELECT * FROM user WHERE id=#{id}
</select>
1
2
3
4
5
6
7
@Test
public void reuseCacheTest(){
    SqlSession session = factory.openSession(ExecutorType.REUSE,true);
    UserDao mapper = session.getMapper(UserDao.class);
    mapper.findById(4);
    mapper.findById2(2);
}

==> Preparing: SELECT * FROM user WHERE id=?
> Parameters: 4(Integer)
<
Total: 1
> Parameters: 4(Integer)
<
Total: 1

三、不同的mappedStatementsId,sql语句不相同,参数不相同

1
2
3
<select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer">
    SELECT * FROM user WHERE id=#{id} and 1=1
</select>
1
2
3
4
5
6
7
@Test
public void reuseCacheTest(){
    SqlSession session = factory.openSession(ExecutorType.REUSE,true);
    UserDao mapper = session.getMapper(UserDao.class);
    mapper.findById(4);
    mapper.findById2(2);
}

==> Preparing: SELECT * FROM user WHERE id=?
> Parameters: 4(Integer)
<
Total: 1
==> Preparing: SELECT * FROM user WHERE id=? and 1=1
> Parameters: 2(Integer)
<
Total: 1

为什么整合Spring框架一级缓存会失效

因为整合Spring之后,每次执行调用,spring都会创建一个新的会话,所以不会命中缓存。

二级缓存

二级缓存也叫应用级缓存,与一级缓存不同的是,它的作用范围是整个应用,并且支持跨线程使用。相较于一级缓存只在单个会话中生效,二级缓存有着更高的命中率。

设计二级缓存的扩展性需求

一:存储

因为内存速度快,效率高,通常会将缓存放到内存中,但是内存中的数据并不是持久化的,程序关闭、服务器断电都会导致数据丢失,我们会考虑使用硬盘持久化的方式来避免这种情况。

二:淘汰策略

只往里面一个劲的扔数据肯定是不行的,我们的内存有限,并且缓存中的某些数据一段时间可能都用不上,那么这就涉及到淘汰策略了。

第一种:FIFO先进先出策略

第二种:LRU策略,淘汰掉最近最少使用的

三:其他

例如过期清理、线程安全、命中率统计、序列化等等。

MyBatis中二级缓存的底层架构

从最顶层说起

Cache是MyBatis中的一个接口,提供了一些最基本的方法,例如设置缓存、移除缓存、获取缓存等。

底下的这一排就是二级缓存的具体实现了,在这里,采用了装饰器+责任链的设计模式,优点也已经列举出来了。接下来,我们通过代码来验证一下二级缓存的流程是否符合上图所述。

1
2
3
4
5
6
7
@Test
public void cacheTest(){
    Cache cache = configuration.getCache("com.lxy.dao.UserDao");
    User user = new User();
    cache.putObject("daoyou",user);
    Object daoyou2 = cache.getObject("daoyou");
}

在这里插入图片描述

注意看括号,除了ScheduledCache和BlockingCahe都一一对应上了,因为没有配置,所以没有这两个。这幅图也很好的说明了MyBatis设计二级缓存采用的设计模式。

讲到这里,我们按照调用顺序来看一下源码。

SynchronizedCache.java

1
2
3
4
5
//使用synchronized锁来达到线程安全的目的
@Override
public synchronized void putObject(Object key, Object object) {
  delegate.putObject(key, object);
}

LoggingCache.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//表面上getObject是获取缓存,但其实这里并没有真正获取,而是交给了下一个
@Override
public Object getObject(Object key) {
  //请求数加一,后面计算命中率会用到
  requests++;
  //交给责任链的下一个去获取
  final Object value = delegate.getObject(key);
  //获取到的值不为空,命中数加一
  if (value != null) {
    hits++;
  }
  if (log.isDebugEnabled()) {
    //getHitRatio()就是计算命中率的方法,我们再看一下这个。
    log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
  }
  return value;
}
1
2
3
4
private double getHitRatio() {
  //就是做了一个除法,很简单
  return (double) hits / (double) requests;
}

下一步来到SerializedCache.java

1
2
3
4
5
6
7
@Override
public Object getObject(Object key) {
  //同样,交给下一个
  Object object = delegate.getObject(key);
  //对获取到的数据反序列化
  return object == null ? null : deserialize((byte[]) object);
}

LruCache.java下面会展开讲MyBatis的二级缓存淘汰策略

1
2
3
4
5
6
7
@Override
public Object getObject(Object key) {
  //从集合中获取数据
  keyMap.get(key); //touch
  //交给下一个执行。
  return delegate.getObject(key);
}

最后来到PerpetualCache.java

1
2
3
4
5
@Override
public Object getObject(Object key) {
  //根据key获取缓存
  return cache.get(key);
}

上面提到了LruCache,在MyBatis中,对二级缓存提供了淘汰策略,这里主要讲Fifo和Lru,只填不删那是肯定要出问题的,内存空间相比硬盘少的可怜,那么MyBatis中是怎么实现这两种策略的呢?

注意:在使用二级缓存之前要确保你的实体类继承了序列化接口Serializable

第一种:FIFO

一提到FIFO肯定就想到数据结构中的队列结构先进先出,MyBatis中也给我们提供了一个类FifoCache.java

1
2
3
4
5
6
7
8
9
10
11
private final Cache delegate;
//使用队列这种数据结构
private final Deque<Object> keyList;
private int size;

public FifoCache(Cache delegate) {
  this.delegate = delegate;
  //队列的实现使用了链表
  this.keyList = new LinkedList<Object>();
  this.size = 1024;
}

在MyBatis中,把它抽象成了环形结构,每次插入缓存时都是采用的尾插法,将新来的作为尾部,当队列满了,就将队头移除掉。

1
2
3
4
5
6
@Override
public void putObject(Object key, Object value) {
  //添加时会调用此方法
  cycleKeyList(key);
  delegate.putObject(key, value);
}
1
2
3
4
5
6
7
8
9
10
private void cycleKeyList(Object key) {
  //每次都插到尾部
  keyList.addLast(key);
  //如果队列的大小超过我们配置时指定的大小,就将队头移除
  if (keyList.size() > size) {
    //移除掉队头
    Object oldestKey = keyList.removeFirst();
    delegate.removeObject(oldestKey);
  }
}

接下来我们做个例子,我会将FifoCache缓存的大小设为5,并向里面存放6个数据,来看一下效果。

1
2
3
4
5
6
7
8
9
@Test
public void FIFOTest(){
    Cache cache = configuration.getCache("com.lxy.dao.UserDao");
    User user = new User();
    for (int i = 0; i < 6; i++) {
        cache.putObject("Coder"+i,user);
    }
    System.out.println(cache);
}

会发现,Coder0不见了,这也验证了FifoCache的淘汰策略,淘汰时会移除队头缓存。

另一种淘汰策略,LruCache,MyBatis默认采用的淘汰策略,相比于FifoCache它显得就比较智能了,它会淘汰缓存队列中最少使用的那一个,就像你下载了100部小电影,但是总有几部你是经常不看的,不删又占用着硬盘又下不了新的电影,那就把不经常看的删除掉。

LruCache.java

1
2
3
4
5
6
7
8
private final Cache delegate;
private Map<Object, Object> keyMap;
private Object eldestKey;

public LruCache(Cache delegate) {
  this.delegate = delegate;
  setSize(1024);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void setSize(final int size) {
  //使用链表形式LinkedHashMap这种数据结构,在增加和删除时性能十分优秀
  keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
    private static final long serialVersionUID = 4267176411845948333L;

    @Override
    protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
      boolean tooBig = size() > size;
      if (tooBig) {
        eldestKey = eldest.getKey();
      }
      return tooBig;
    }
  };
}
1
2
3
4
5
6
@Override
public void putObject(Object key, Object value) {
  //放置缓存
  delegate.putObject(key, value);
  cycleKeyList(key);
}

如果我们没有访问缓存,只是添加的话,我们的缓存存放顺序和使用FifoCache的顺序是一样的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UjrJLnxA-1593595502882)(https://i.loli.net/2020/06/06/3xsDnwHcleMhCyW.png)]

同样的,Coder0已经被移除掉了,这个时候我希望不要删除掉Coder1缓存,那么怎么办呢,我们要调用一下getObject()方法。

1
2
3
4
5
@Override
public Object getObject(Object key) {
  keyMap.get(key); //touch
  return delegate.getObject(key);
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BfUQIxgT-1593595502882)(https://i.loli.net/2020/06/06/7KoLCBIP3Jk5D8j.png)]

看,此时的Coder1就被放到了链表尾部,那么这时候就有小伙伴问了,你说了这么多,也没讲它是怎么实现将访问过的缓存放到链表尾的啊,我在getObject()方法中也没看到啊,小伙伴们注意了,还记得我们刚开始将Lru的时候,它的数据结构使用的是LinkedHashMap吗,奥秘就在这里,我们在setSize里初始化LinkedHashmap时将accessOrder参数设为了true,它很关键。

在getObject方法中的keyMap.get()方法,调用的就是LinkedHashMap中的get方法,这个方法有一个特性,如果accessOrder为true,就会将该节点放到链表尾部。

1
2
3
4
5
6
7
8
9
public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        //放到链表尾部
        afterNodeAccess(e);
    return e.value;
}

这就是LruCache的如何实现删除最近最少使用的缓存的原理了,它每次删除的都是处在链表头的缓存。

在设置了缓存过期时间后,是如何清除的呢?看一下ScheduledCache

1
2
3
4
5
6
7
8
9
10
11
private final Cache delegate;
//过期时间
protected long clearInterval;
//最后一次清理的时间
protected long lastClear;

public ScheduledCache(Cache delegate) {
  this.delegate = delegate;
  this.clearInterval = 60 * 60 * 1000; // 1 hour
  this.lastClear = System.currentTimeMillis();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public void putObject(Object key, Object object) {
  //存放缓存时去检查一遍队列中有没有过期的缓存
  clearWhenStale();
  delegate.putObject(key, object);
}

@Override
public Object getObject(Object key) {
  //获取时如果缓存过期了返回null否则返回缓存
  return clearWhenStale() ? null : delegate.getObject(key);
}

@Override
public Object removeObject(Object key) {
  //删除时也检查一遍
  clearWhenStale();
  return delegate.removeObject(key);
}
1
2
3
4
5
6
7
8
private boolean clearWhenStale() {
  //现在的时间减去最后一次清理的时间如果大于缓存的过期时间,就清空全部二级缓存
  if (System.currentTimeMillis() - lastClear > clearInterval) {
    clear();
    return true;
  }
  return false;
}

二级缓存命中条件

这里我们重点讲一下第一个。

看一下第一个条件,没有提交会话,无法命中缓存。

1
2
3
4
5
6
7
8
9
10
@Test
public void HitCache(){
    SqlSession session=factory.openSession();
    UserDao userDao = session.getMapper(UserDao.class);
    userDao.findById(4);
   
    SqlSession session1=factory.openSession();
    UserDao userDao2 = session1.getMapper(UserDao.class);
    userDao2.findById(4);
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pQYVf2u4-1593595502884)(/Users/liuxingyu/Library/Application Support/typora-user-images/image-20200606194442735.png)]

执行了两次查询,缓存命中率为0

2.提交会话后可以命中

1
2
3
4
5
6
7
8
9
10
@Test
public void HitCache(){
    SqlSession session=factory.openSession();
    UserDao userDao = session.getMapper(UserDao.class);
    userDao.findById(4);
    session.commit();
    SqlSession session1=factory.openSession();
    UserDao userDao2 = session1.getMapper(UserDao.class);
    userDao2.findById(4);
}

命中率为0.5,但是出现了一个问题,为什么提交会话只进行了一次查询,而前面的没提交却进行了两次呢。这是因为命中缓存之后不需要再去数据库查询了,所以只查询了一次。

注意,只有手动提交后,二级缓存才会被命中,开启自动提交并不会命中缓存。

二级缓存的配置参数

第一个,cacheEnabled全局缓存开关,这个没什么好讲的。

第二个,useCache,代表当前的statement是否进行二级缓存,配置为false会失效。

1
2
3
4
5
6
7
8
9
10
@Test
public void HitCache(){
    SqlSession session=factory.openSession();
    UserDao userDao = session.getMapper(UserDao.class);
    userDao.findById(4);
    session.commit();
    SqlSession session1=factory.openSession();
    UserDao userDao2 = session1.getMapper(UserDao.class);
    userDao2.findById(4);
}

此时,第二条查询语句并不会命中缓存。

第三个:flushCache,语句执行前清空所有二级缓存,还是上面的代码,结果也是没有命中。这里有一点我搞错了,专门记下来免得自己忘了。最开始的想法,我以为缓存的命中仅仅取决于控制台里的输出日志Hit Ratio这个信息(年轻了),但实际并不是,如果命中了二级缓存后,例如查询,第二次查询命中缓存后就直接返回结果了,不会再进行查询了,所以控制台只有一条查询语句,控制台仍旧有命中率日志输出是因为select语句默认useCache为true。

在insert、update、delete语句时,flushCache默认为true,如果是false,那么本次的修改对后续的查询不可见,查询的数据还是老的数据,这里也是保证了数据的一致性。

第四个,声明缓存空间,在接口上加注解@CacheNamespace或对应接口配置文件中使用标签,注意,两者不能共存。它们两个不是同一个东西。如果在接口中只是使用注解,二级缓存的配置是不会生效的,需要在接口映射文件中配置标签,引用命名空间

第五个,引用缓存空间,也就是两个接口中的方法共用一个缓存空间,当一个方法执行清空操作时,两个接口的二级缓存会被全部清空,因为是共用嘛。

二级缓存中的事物缓存管理器

这张图就是MyBatis中会话与缓存之间的关系了,会话并不和二级缓存空间直接打交道,而是通过了一个事物缓存管理器,他俩的关系是1:1的,事务缓存管理器中又有着多个暂存区,是1:n的,相同的暂存区都对应着同一个缓存空间,是n:1的。来看代码演示。

这里我使用了一个session,进行了两个不同mapper的查询。

1
2
3
4
5
6
7
@Test
public void TxManager(){
    SqlSession session=factory.openSession();
    session.getMapper(UserDao.class).findById(4);
    session.getMapper(CustomersDao.class).findCustomersById(2);
    System.out.println(session);
}

可以看到,在session会话中的tcm事务缓存管理器中有两个暂存区,分别对应着User和Customer,暂存区中的value才指向二级缓存空间,所有的操作都会保存在暂存区,只有commit后才会提交到二级缓存空间。关于暂存区的理解,看下面这张图。

在这里插入图片描述

暂存区的作用,主要体现在修改上,大家都知道,进行修改操作后(这里指增、删、改)后会清空二级缓存,但假设没有暂存区这个概念而是直接和二级缓存打交道,我在进行修改操作后后悔了,想要回滚,但是它已经把二级缓存清空了,这就完全没必要了。暂存区的作用,此时就体现出来了,修改之后,会在TransactionalCache中有一个标记位clearOnCommit,会被置为true,表面是清理了,但实际并没有,只有当你决定要进行提交操作了,它才会真正的清理。在进行修改后再次进行查询,即便没提交会话,也会返回null。接下来我们通过代码,看一下其中的奥秘

TransactionalCache.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
private final Cache delegate;
//主角就是它
private boolean clearOnCommit;
private final Map<Object, Object> entriesToAddOnCommit;
private final Set<Object> entriesMissedInCache;

public TransactionalCache(Cache delegate) {
  this.delegate = delegate;
  //默认是false
  this.clearOnCommit = false;
  this.entriesToAddOnCommit = new HashMap<Object, Object>();
  this.entriesMissedInCache = new HashSet<Object>();
}
//部分方法代码
@Override
  public void clear() {
    //修改后会将clearOnCommit设为true,表面清理,其实并没有
    clearOnCommit = true;
    entriesToAddOnCommit.clear();
  }

@Override
  public Object getObject(Object key) {
    // issue #116
    Object object = delegate.getObject(key);
    if (object == null) {
      entriesMissedInCache.add(key);
    }
    // issue #146
    //会话还没提交时,进行了一次查询,但已经进行了修改操作,就不会让查询命中缓存,否则可能会出现脏读。
    if (clearOnCommit) {
      return null;
    } else {
      return object;
    }
  }

  public void commit() {
    //提交时,只有clearOnCommit为true才会清空二级缓存,这个不难理解
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
  }

  public void rollback() {
    unlockMissedEntries();
    //回滚操作,重置标记位
    reset();
  }

  private void reset() {
    //清空后要将标记位置为false
    clearOnCommit = false;
    entriesToAddOnCommit.clear();
    entriesMissedInCache.clear();
  }