MyBatis 缓存原理——到底什么是Mapper级别的缓存

329 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 6 天,点击查看活动详情

在网上大部分文章都说MyBatis一级缓存是SqlSession级别的,二级缓存是Mapper级别的(或者说是namespace级别的)。这个结论没什么问题。可是!!!!对于初学者、想要快速的理解mybatis的缓存机制的人来说,什么是Mapper级别呢?什么又是SqlSession级别呢? 接下来将从如下几个方面并结合源码进行分析mybatis的缓存。

  1. 什么数据会被缓存?
  2. 数据何时加入到缓存中?
  3. 缓存中的数据何时销毁?(或者说缓存中的数据在何时有用)
  4. 到底什么是Mapper级别?(什么是namespace级别);什么又是SqlSession级别?

文章分为两部分:一级缓存原理和二级缓存原理。这两部分互不影响,读者可自行选择章节进行查阅
mybatis版本:3.5.12

一级缓存

默认情况下一级缓存是开启的,并且无法关闭。一级缓存实际上就是BaseExecutor中的这个属性:PerpetualCache

public abstract class BaseExecutor implements Executor {
  // 一级缓存对象,其中有一个Map<Object,Object>,key值就是根据SQL计算得到的一个对象。value是缓存的结果
  protected PerpetualCache localCache;
}

PerpetualCache就是常说的MyBatis一级缓存,PerpetualCache中维护了一个Map<Object,Object>,这个Map真正的在内存中缓存了我们从数据库查询得到的结果集数据。这个Map中的value值就是数据库查询的结果集数据。这个Map存储的key是SQL根据一定的算法得到的对象(其实是一个CacheKey对象,但便于理解现在不必深究)。这个Key有这样一个特性,同样的SQL通过算法得到的key值是相同的。

要想了解一级缓存的原理,必须知道在MyBatis中SqlSession执行SQL查询的大致逻辑,通常通过SqlSession.selectList()方法就可以执行SQL语句查询数据库并返回结果。其底层的原理是SqlSession把查询委托给Executor(执行器)对象。

  • Executor在查询数据库前,先检查以下一级缓存中是否有改Sql对应的结果,如果有则直接从缓存中返回数据,而无需再查询结果集。
  • 如果Executor的一级缓存中没有数据,则再进行数据库查询,查询完毕后把结果存入缓存中。

在源码中对应的逻辑在BaseExecutor#queryFromDatabase方法中(省略非核心代码)

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
  List<E> list;
  list = localCache.getObject(key);
  if (list == null) {
    list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
  }
  return list;
}

// 从数据库中查询,并存入一级缓存
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
  List<E> list;
  localCache.putObject(key, EXECUTION_PLACEHOLDER);
  try {
    list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
  } finally {
    localCache.removeObject(key);
  }
  localCache.putObject(key, list);
  return list;
}

源码就算不标注释都能看的出其执行逻辑和上述描述的一样——先查询一级缓存,如果一级缓存为空,则查询数据库并把返回的结果集放入二级缓存。

总结:一级缓存

现在再回过头来问自己这4个问题

  1. 什么数据会被缓存?

    通过SqlSession查询数据库得到的数据,都会被缓存到Executor中的PerpetualCache对象中

  2. 数据何时加入到缓存中?

    当查询缓存数据不存在,查询数据库得到结果集后,数据会被存入缓存。

  3. 缓存中的数据何时销毁?(或者说缓存中的数据在何时有用)

    既然缓存都存在了Executor中,而Executor又是SqlSession的一个属性。那么只要SqlSession销毁了,缓存也就没了。也就是说同一个SqlSession执行相同的SQL语句时,除了第一次会查数据库,其他的都会查询缓存。

    遗憾的是SqlSession通常使用过后就被销毁了。例如我们通常这样编程(只是示例,平时都是用Mapper开发)

    public void test() throws Exception {
      SqlSession sqlSession = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("sqlMapConfig.xml"));
      sqlSession.selectList("queryUserById",1)
      // 其他处理逻辑
    }
    

    在test方法执行完之后,局部变量就会被GC回收掉。SqlSession对象自然也就被销毁了。

    就算是Mapper代理方式,底层也是在方法中调用SqlSession对象执行数据库查询,所以销毁时机是一样的。

  4. 什么又是SqlSession级别?

    解决了前三个问题,这个问题应该能明白吧,一级缓存还是很好理解的。SqlSession级别就是指在SqlSession的生命周期内,缓存是有效的。当SqlSession对象销毁时,缓存也随之消失。

接下来是重头戏咯,来看看二级缓存吧!

二级缓存

首先二级缓存在mybatis中默认是关闭的,可以通过全局配置修改,或者通过在Mapper.xml文件中加入<cache/>标签开启指定Mapper的二级缓存

在剖析二级缓存的源码之前,先来了解下mybatis是如何处理XxxMapper.xml文件的,以及什么是MappedStatement。先来看一张图

image.png

这里简单介绍一下,MyBatis中的XxxMapper.xml文件会被解析为若干个组件存储在程序的内存中。其中select update insert delete这4种节点(后面只说select了),每个节点都会被解析成一个MappedStatement对象,其实我们只需要把MappedStatement对象当做是要给SQL包装对象就行了,MappedStatement中存储了select节点的SQL信息,命名空间(就是namespace属性值)SQL执行后的返回结果集对应的Java类型映射信息等,

MappedStatement还存储了一个有关二级缓存的属性就是Cache。但它并不存储数据,他只是作为一个标识(后面详细分析)标识哪些MappedStatement共用的一个缓存。换种方式理解,如果两个MappedStatement中的Cache属性相同,那么他们就是公用的一个缓存。这也就是Mapper级别的缓存所代表的含义,也可以说是namespace级别的缓存。

所以,我们再看看下XxxMapper.xml文件解析的成果

  • 每个XxxMapper.xml会被解析为多个MappedStatement对象,并且这些MappedStatement中的Cache属性相同
  • 不同的XxxMapper.xml解析出来的MappedStatement对象的Cache属性是不同的。除非使用<cache-ref/>生命两个mapper文件使用同一个缓存空间。
  • 被解析出来的MappedStatement对象保存在Configuration对象中(这是mybatis的全局配置对象)只有程序关闭时,它才会销毁,否则它会一直存在与程序当中!

怎么样,看到这里,虽然还没分析到源码,但我相信你已经理解了什么是mapper级别(同namespace级别)

接下来,我们就该参考源码分析二级缓存了

源码分析

前文说到,MappedStatement中的Cache属性并不存储数据,而只是作为Mapper的一个标识。那么二级缓存存储在哪里呢?

其实二级缓存真正的归宿是通过TransactionalCacheManager对象完成对二级缓存的增删操作。它是CachingExecutor的一个属性

public class CachingExecutor {
  private final TransactionalCacheManager tcm = new TransactionalCacheManager();
}

public class TransactionalCacheManager {
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
}

public class TransactionalCache implements Cache {
  private final Cache delegate;
  private boolean clearOnCommit;
  // 二级缓存存储数据的地方!!!!终于找到了
  private final Map<Object, Object> entriesToAddOnCommit;
  // 事务提交前,查询的cache标识存在这个集合里。
  private final Set<Object> entriesMissedInCache;
}

TransactionalCacheManager中有个Map数据结构的属性transactionalCaches,这个Map的Key就是MappedStatement的cache对象!value是领域给封装好的Cache对象。 其中数据就存储在TransactionalCache中

数据何时存入二级缓存

首先要保证开启了二级缓存。

二级缓存相关代码在CachingExecutor#query方法中,源码如下

private final TransactionalCacheManager tcm = new TransactionalCacheManager();

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) {
      List<E> list = (List<E>) tcm.getObject(cache, key);
      if (list == null) {
        list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
        tcm.putObject(cache, key, list); // issue #578 and #116
      }
      return list;
    }
  }
  return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

先来说下这段代码的逻辑

  1. 获取MappedStatement的Cache标识,(如果没开启二级缓存,就会得到null)
  2. 如果Cache标识存在,则刷新二级缓存(有些查询是需要在查询语句前刷新缓存的,防止其他线程已经修改了数据但没有存入数据的情况。默认不刷新)
  3. 从缓存中获取对象
  4. 如果缓存的返回值不为空直接返回,否则查询数据库
  5. 把查询的结果集数据放入二级缓存

其实逻辑是和一级缓存一模一样的,都是先查缓存,缓存中没有就查数据库。但是不同与一级缓存的是,二级缓存并非是要给Map那么简单直接put(key,value)就可以了,二级缓存是通过TransactionalCacheManager来管理的。来看下TransactionalCacheManager的重要属性

public class TransactionalCacheManager {
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
}

transactionalCaches这个Map的Key就是MappedStatement的cache对象!value是领域给封装好的Cache对象。 如果只停留在这一层,其实就可以断定,数据一定存储在TransactionalCache对象当中了。但我们不妨深入下TransactionalCache的重要属性如下

public class TransactionalCache implements Cache {
  // 二级缓存存储数据的地方!!!!终于找到了
  private final Cache delegate; // 装饰者模式
  // 事务提交前,数据都在这里缓存
  private final Map<Object, Object> entriesToAddOnCommit;
  // 事务提交前,如果查询二级缓存会未命中,就把MS对象的Cache标识存储在这个集合中
  private final Set<Object> entriesMissedInCache;
}

看到了呗,其实底层的底层还是Map对象和Cache对象(当然了,Cache底层也是Map)。

下面我们再来分析下tcm是如何把数据存入缓存和如何从缓存中取出数据的

tcm把数据存储到二级缓存

事务提交前

tcm在程序查询完数据后,先把数据存储到TransactionalCache的Map中,在事务提交后才会把数据真正的存到二级缓存。

提交事务前,把数据存TransactionalCache就是对应的TransactionalCacheManager#putObject方法

public class TransactionalCacheManager {
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();

  public void putObject(Cache cache, CacheKey key, Object value) {
    getTransactionalCache(cache).putObject(key, value);
  }
}

putObject的方法很简单,首先通过Cache标识(还记得吗,这个标识是从MS对象中来的哦,它并不存数据)获取TransactionalCache对象,然后嗲用TransactionalCacheputObject方法。TransactionalCache#putObject源码如下

public class TransactionalCache implements Cache {
  // 二级缓存存储数据的地方!!!!终于找到了
  private final Cache delegate; // 装饰者模式
  // 事务提交前,数据都在这里缓存
  private final Map<Object, Object> entriesToAddOnCommit;
  // 事务提交前,如果查询二级缓存会未命中,就把MS对象的Cache标识存储在这个集合中
  private final Set<Object> entriesMissedInCache;

  public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
  }
}

最终就是把CackeKey作为key,查询数据库得到的数据作为value存入到了Map中。(CacheKey上文有说过哈,可以就粗暴的认为他是SQL的唯一标识)

但是查询二级缓存是从delegate这个属性中查的,也就是说entriesToAddOnCommit中存储的数据都只是事务提交前的数据,如果事务回滚,或发生异常,数据都会丢失。只有在执行commit方法提交事务的时候才会把数据都存储到Cache delegate这个属性中

事务提交后

public void commit() {
  flushPendingEntries();
  reset();
}

private void flushPendingEntries() {
  for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
    delegate.putObject(entry.getKey(), entry.getValue());
  }
}

只有事务提交后,才会把数据存入到Cache对象中。

tcm从二级缓存读数据

tcm读取二级缓存的时候就没有那么多门门道道了,就是对应的TransactionalCacheManager#getObject方法

public class TransactionalCacheManager {
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();

  public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
  }
}

第一步也很简单,通过Cache标识符获取TransactionalCache对象,然后调用TransactionalCache对象的getObject方法,下面就来看TransactionalCache#getObject

public Object getObject(Object key) {
  Object object = delegate.getObject(key);
  if (object == null) {
    entriesMissedInCache.add(key);
  }
  if (clearOnCommit) {
    return null;
  } else {
    return object;
  }
}

上一小节分析,只有事务提交后,数据才会被存入Cache对象。如果事务未提交,这里第一步delegate.getObject(key);返回的一定是null值。并别会把key加入到entriesMissedInCache集合。如果事务提交了,自然在此处也能获取到二级缓存中的值了。

总结:二级缓存

  • 默认二级缓存关闭,需手动开启
  • 二级缓存在事务提交后才能生效
  • 之所以二级缓存被叫做Mapper级别(namespace级别)是因为一个xml文件对应一个缓存,这样,同一个xml中的所有语句查询的就是要给缓存了。

二级缓存的一些坑

返回的对象必须可序列化

如果查询返回的结果被封装成一个POJO,这个POJO必须实现Serializable,接口,否则在tcm.commit时程序会报错

org.apache.ibatis.exceptions.PersistenceException: 
### Error committing transaction.  Cause: org.apache.ibatis.cache.CacheException: Error serializing object.  Cause: java.io.NotSerializableException: org.apache.ibatis.amy.pojo.Order
### Cause: org.apache.ibatis.cache.CacheException: Error serializing object.  Cause: java.io.NotSerializableException: org.apache.ibatis.amy.pojo.Order

原因是TransactionalCache中的代理Cache对象中间代理了一层SerializedCache,而SerializedCache中的putObject必须要求对象实现Serializable接口

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

TransactionalCache代理如下

image.png

SqlSession事务不生效

SqlSession sqlSession = factory.openSession(true);

通过该代码获取的SqlSession只对增删改的操作自动提交事务,对于select查询不会自动提交事务。所以测试二级缓存时需要手动提交事务。否则会出现二级缓存失效的情况