前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >MyBatis源码阅读(十) --- 一级缓存、二级缓存工作原理

MyBatis源码阅读(十) --- 一级缓存、二级缓存工作原理

作者头像
终有救赎
发布2024-01-30 09:03:53
1450
发布2024-01-30 09:03:53
举报
文章被收录于专栏:多线程多线程
一、概述

缓存,相信大家应该不陌生,在工作中,我们也接触过一些缓存中间件,比如Redis等。缓存的作用就是为了提供查询的效率,减少访问数据库的次数,从而提供性能。同样的,Mybatis里面也提供了缓存功能,包括一级缓存和二级缓存。本篇文章我们将总结Mybatis的一级缓存、二级缓存怎么用的以及分析它们的作用域、实现原理等。

MyBatis中的缓存相关类都在cache包下面,而且定义了一个顶级接口Cache,Cache定义了缓存的基本操作,比如设置缓存,获取缓存的方法。Cache类的源码如下:

代码语言:javascript
复制
public interface Cache {
 
  /**
   * 缓存的唯一标识
   */
  String getId();
 
  /**
   * 设置缓存,key-value键值对方式
   */
  void putObject(Object key, Object value);
 
  /**
   * 根据key获取对应的缓存
   */
  Object getObject(Object key);
 
  /**
   * 根据key移除对应的缓存
   */
  Object removeObject(Object key);
 
  /**
   * 清空所有的缓存
   */
  void clear();
 
  /**
   * 缓存中元素总数
   */
  int getSize();
 
  /**
   * 读写锁
   */
  ReadWriteLock getReadWriteLock();
 
}

Cache类的实现子类主要有PerpetualCache、BlockingCache、LruCache、SerializedCache、FifoCache等等。这里我们介绍一种比较简单的缓存类,同时也是默认的缓存实现子类PerpetualCache:

PerpetualCache里面持有一个HashMap结构的cache成员属性,用于存放缓存对应的数据,key-value形式存储,对缓存的操作其实就是对cache hashmap的操作。

代码语言:javascript
复制
public class PerpetualCache implements Cache {
  
  private final String id;
 
  // 以map存储缓存
  private final Map<Object, Object> cache = new HashMap<>();
 
  public PerpetualCache(String id) {
    this.id = id;
  }
 
  @Override
  public String getId() {
    return id;
  }
 
  @Override
  public int getSize() {
    return cache.size();
  }
 
  //存放缓存,实际上就是往map里面放入一条数据
  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }
 
  @Override
  public Object getObject(Object key) {
    return cache.get(key);
  }
 
  @Override
  public Object removeObject(Object key) {
    return cache.remove(key);
  }
 
  @Override
  public void clear() {
    cache.clear();
  }
 
  @Override
  public boolean equals(Object o) {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    if (this == o) {
      return true;
    }
    if (!(o instanceof Cache)) {
      return false;
    }
 
    Cache otherCache = (Cache) o;
    return getId().equals(otherCache.getId());
  }
 
  @Override
  public int hashCode() {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    return getId().hashCode();
  }
 
}
二、一级缓存的使用

首先先通过一个示例来看看一级缓存到底是个什么东西。

一级缓存也叫本地缓存,在MyBatis中,一级缓存是SqlSession级别的缓存,在同一个会话中,如果执行两次相同的sql,第一次会执行查询打印sql,第二次则是直接从一级缓存中获取,不会从数据库查询,所以不会打印sql。

代码语言:javascript
复制
@SpringBootTest
class MybatisLocalCacheDemoApplicationTests {
 
    public static void main(String[] args) {
        //1、读取配置文件
        String resource = "mybatis-config.xml";
        InputStream inputStream;
        SqlSession sqlSession = null;
        try {
            inputStream = Resources.getResourceAsStream(resource);
            //2、初始化mybatis,创建SqlSessionFactory类实例
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
            System.out.println(sqlSessionFactory);
            //3、创建Session实例
            sqlSession = sqlSessionFactory.openSession();
            //4、获取Mapper接口
            UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
            //5、执行SQL操作
            //在同一个会话中,执行两次相同的SQL
            User user = userMapper.getById(1L);
            User user2 = userMapper.getById(1L);
            System.out.println(user);
            System.out.println(user2);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            //6、关闭sqlSession会话
            if (null != sqlSession) {
                sqlSession.close();
            }
        }
    }
 
}

启动单元测试,观察后台日志:

代码语言:javascript
复制
Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@eb21112]
==>  Preparing: select * from user where id = ? 
==> Parameters: 1(Long)
<==    Columns: id, username
<==        Row: 1, 张三
<==      Total: 1
User{id='1', username='张三'}
User{id='1', username='张三'}

可以看到,控制台只输出了一条SQL语句,说明只有第一次查询了数据库,然后第二次查询是从一级缓存中获取的,不会发送SQL。

如果不同的SqlSession级别的执行两条相同的sql,是否只会发送一条SQL?稍微修改一下代码:

代码语言:javascript
复制
@SpringBootTest
class MybatisLocalCacheDemoApplicationTests {
 
    public static void main(String[] args) {
        //1、读取配置文件
        String resource = "mybatis-config.xml";
        InputStream inputStream;
        SqlSession sqlSession = null;
        SqlSession sqlSession2 = null;
        try {
            inputStream = Resources.getResourceAsStream(resource);
            //2、初始化mybatis,创建SqlSessionFactory类实例
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
            System.out.println(sqlSessionFactory);
            //3、创建Session实例
            /*********************第一个会话***********************/
            sqlSession = sqlSessionFactory.openSession();
            //4、获取Mapper接口
            UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
            //5、执行SQL操作
            User user = userMapper.getById(1L);
            System.out.println(user);
 
            /*********************第二个会话***********************/
            sqlSession2 = sqlSessionFactory.openSession();
            UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
            User user2 = userMapper2.getById(1L);
            System.out.println(user2);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            //6、关闭sqlSession会话
            if (null != sqlSession) {
                sqlSession.close();
            }
        }
    }
 
}

后台日志输入如下:

代码语言:javascript
复制
Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@eb21112]
==>  Preparing: select * from user where id = ? 
==> Parameters: 1(Long)
<==    Columns: id, username
<==        Row: 1, 张三
<==      Total: 1
User{id='1', username='张三'}
Opening JDBC Connection
Created connection 1381128261.
Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@52525845]
==>  Preparing: select * from user where id = ? 
==> Parameters: 1(Long)
<==    Columns: id, username
<==        Row: 1, 张三
<==      Total: 1
User{id='1', username='张三'}

我们看到,控制台输出了两次SQL,说明Mybatis的一级缓存是SqlSession会话级别的,如果在两个不同的会话执行相同的两条SQL,同样会查询数据库两次,这一点需要注意一下。

三、一级缓存的关闭

前面已经介绍了Mybatis的一级缓存怎么使用的,默认一级缓存是开启的,无需手动开启,并且一级缓存的作用范围是SqlSession级别的,不同的SqlSession不能共享一级缓存。

如果要关闭一级缓存的功能,我们可以在mybatis-config.xml中的settings标签中将这个配置设置成Statement类型的:localCacheScope默认是SESSION的。

代码语言:javascript
复制
//有两种取值:SESSION和STATEMENT,默认是SESSION
//Configuration类成员属性: protected LocalCacheScope localCacheScope = LocalCacheScope.SESSION;
<setting name="localCacheScope" value="STATEMENT"/>

如果某个select标签查询不需要缓存,在select标签加上flushCache="true" 也可以设置单个查询关闭缓存。

代码语言:javascript
复制
<!--flushCache="true": 强制刷新缓存,每次都从数据库查询 -->
<select id="getById" flushCache="true" resultType="com.wsh.mybatis.mybatisdemo.entity.User">
    select * from user where id = #{id}
</select>
四、一级缓存的工作原理

Mybatis的缓存是在执行器Executor中进行维护的。

代码语言:javascript
复制
public abstract class BaseExecutor implements Executor {
 
  private static final Log log = LogFactory.getLog(BaseExecutor.class);
 
  protected Transaction transaction;
  protected Executor wrapper;
 
  protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
  //localCache就是一级缓存,类型是PerpetualCache,里面就是用的一个map保存的缓存
  protected PerpetualCache localCache;
  protected PerpetualCache localOutputParameterCache;
  protected Configuration configuration;
 
  protected int queryStack;
  private boolean closed;
 
  protected BaseExecutor(Configuration configuration, Transaction transaction) {
    this.transaction = transaction;
    this.deferredLoads = new ConcurrentLinkedQueue<>();
    //在构造方法中初始化localCache,构建了一个PerpetualCache,默认缓存的唯一标识就是LocalCache
    this.localCache = new PerpetualCache("LocalCache");
    this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
    this.closed = false;
    this.configuration = configuration;
    this.wrapper = this;
  }
}

首先,我们可以猜一下一级缓存存放在哪里比较合适?SqlSession?Executor?Configuration?还是其他?

因为一级缓存是SqlSession会话级别的,自然存放在SqlSession最合适不过了,来看看SqlSession的唯一实现类DefaultSqlSession:

代码语言:javascript
复制
public class DefaultSqlSession implements SqlSession {
 
  private final Configuration configuration;
  private final Executor executor;
 
  private final boolean autoCommit;
  private boolean dirty;
  private List<Cursor<?>> cursorList;
      //...........
}

DefaultSqlSession中只有5个成员属性:

  • autoCommit:是否自动提交;
  • dirty:标识位;
  • cursorList:存放游标相关信息;

这三个看名字都能猜到,不可能用来存储缓存。

  • configuration:全局配置对象,也不适合存储一级缓存;

那么就只剩下Executor最有可能,然后联想到,sqlSession执行查询的时候,实际上都是交给executor去执行的,所以Executor比较合适用来存储缓存。

那么我们看看Executor的实现类BaseExecutor:

代码语言:javascript
复制
public abstract class BaseExecutor implements Executor {
 
  private static final Log log = LogFactory.getLog(BaseExecutor.class);
 
  protected Transaction transaction;
  protected Executor wrapper;
 
  protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
  protected PerpetualCache localCache;
  protected PerpetualCache localOutputParameterCache;
  protected Configuration configuration;
 
  protected int queryStack;
  private boolean closed;
 
  protected BaseExecutor(Configuration configuration, Transaction transaction) {
    this.transaction = transaction;
    this.deferredLoads = new ConcurrentLinkedQueue<>();
    this.localCache = new PerpetualCache("LocalCache");
    this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
    this.closed = false;
    this.configuration = configuration;
    this.wrapper = this;
  }
    //............
}  
  • protected PerpetualCache localCache;

看名字,本地缓存,没错,它就是用来存放一级缓存的,PerpetualCache内缓存是用一个HashMap来存储缓存。

首先我们先看看缓存的key是如何生成的:

代码语言:javascript
复制
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
  if (closed) {
    throw new ExecutorException("Executor was closed.");
  }
  //拼接缓存Key
  CacheKey cacheKey = new CacheKey();
  cacheKey.update(ms.getId());
  cacheKey.update(rowBounds.getOffset());
  cacheKey.update(rowBounds.getLimit());
  cacheKey.update(boundSql.getSql());
  List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
  TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
  // mimic DefaultParameterHandler logic
  for (ParameterMapping parameterMapping : parameterMappings) {
    if (parameterMapping.getMode() != ParameterMode.OUT) {
      Object value;
      String propertyName = parameterMapping.getProperty();
      if (boundSql.hasAdditionalParameter(propertyName)) {
        value = boundSql.getAdditionalParameter(propertyName);
      } else if (parameterObject == null) {
        value = null;
      } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
        value = parameterObject;
      } else {
        MetaObject metaObject = configuration.newMetaObject(parameterObject);
        value = metaObject.getValue(propertyName);
      }
      cacheKey.update(value);
    }
  }
  if (configuration.getEnvironment() != null) {
    // issue #176
    cacheKey.update(configuration.getEnvironment().getId());
  }
  return cacheKey;
}

总结一下CacheKey的组成,CacheKey主要是由以下6部分组成

  • 1、将Statement中的id添加到CacheKey对象中的updateList属性;
  • 2、将offset(分页偏移量)添加到CacheKey对象中的updateList属性(如果没有分页则默认0);
  • 3、将limit(每页显示的条数)添加到CacheKey对象中的updateList属性(如果没有分页则默认Integer.MAX_VALUE);
  • 4、将sql语句(包括占位符?)添加到CacheKey对象中的updateList属性;
  • 5、循环用户传入的参数,并将每个参数添加到CacheKey对象中的updateList属性;
  • 6、如果有配置Environment,则将Environment中的id添加到CacheKey对象中的updateList属性;

接下来就需要看看BaseExecutor的query方法是如何使用一级缓存的。

代码语言:javascript
复制
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());
  if (closed) {
    throw new ExecutorException("Executor was closed.");
  }
  //select标签是否配置了flushCache=true
  //如果需要强制刷新的话,这里会清空一级缓存
  if (queryStack == 0 && ms.isFlushCacheRequired()) {
    clearLocalCache();
  }
  List<E> list;
  try {
    queryStack++;
    //通过localCache.getObject(key)去一级缓存中查询有没有对应的缓存
    //有的话直接取,没有的话执行queryFromDatabase数据库查询
    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);
    }
  } finally {
    queryStack--;
  }
  if (queryStack == 0) {
    for (DeferredLoad deferredLoad : deferredLoads) {
      deferredLoad.load();
    }
    // issue #601
    deferredLoads.clear();
    if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
      // 如果关闭了一级缓存,查询完后清除一级缓存
      clearLocalCache();
    }
  }
  return list;
}

Debug一下源码,第一次执行getById的时候,很显然缓存中肯定没有对应的数据,所以第一次会从数据库查询。如下图:

图片.png
图片.png

第二次执行getById:第二次查询的时候,因为第一次从数据库查询出结果之后,会将结果存入一级缓存,所以这里判断一级缓存中存在对应的数据,直接从缓存中取出,并返回。

图片.png
图片.png

下面我们说一下一级缓存的清除,在执行update,commit,或者rollback操作的时候都会进行清除缓存操作,所有的缓存都将失效。例如update方法:

代码语言:javascript
复制
//org.apache.ibatis.executor.BaseExecutor#update
  public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    //清空一级缓存
    clearLocalCache();
    //清空一级缓存,再执行更新操作
    return doUpdate(ms, parameter);
  }

可以看到clearLocalCache()方法就用清空本地缓存的意思。

五、二级缓存的使用

一级缓存因为只能在同一个SqlSession中共享,所以会存在一个问题,在分布式或者多线程的环境下,不同会话之间对于相同的数据可能会产生不同的结果,因为跨会话修改了数据是不能互相感知的,所以就有可能存在脏数据的问题,正因为一级缓存存在这种不足,所以我们需要一种作用域更大的缓存,这就是二级缓存。

同样,猜一下二级缓存应该存储在哪里合适?

在MyBatis中,二级缓存其实是用了一个装饰器来维护,就是CachingExecutor,后面详细介绍。

接下来我们通过一个例子来验证一下二级缓存的使用。

第一步,首先我们需要手动开启二级缓存,开启方法就是在mybatis-config.xml加入如下设置:

代码语言:javascript
复制
<setting name="cacheEnabled" value="true"/>

第二步,在mapper.xml中加入二级缓存的配置:

代码语言:javascript
复制
<!--flushCache="true": 强制刷新缓存,每次都从数据库查询
    useCache="true" : 开启二级缓存
 -->
<select id="getById" useCache="true" resultType="com.wsh.mybatis.mybatisdemo.entity.User">
    select * from user where id = #{id}
</select>
 
<cache type="org.apache.ibatis.cache.impl.PerpetualCache"
       size="1024"
       eviction="LRU"
       flushInterval="120000"
       readOnly="false"/>

第三步:编写单元测试,验证不同SqlSession会话间执行两条相同的SQL,是否能够共享缓存。

代码语言:javascript
复制
@SpringBootTest
class MybatisSecondCacheDemoApplicationTests {
 
    public static void main(String[] args) {
        //1、读取配置文件
        String resource = "mybatis-config.xml";
        InputStream inputStream;
        SqlSession sqlSession = null;
        SqlSession sqlSession2 = null;
        try {
            inputStream = Resources.getResourceAsStream(resource);
            //2、初始化mybatis,创建SqlSessionFactory类实例
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
            System.out.println(sqlSessionFactory);
            //3、创建Session实例
            /*********************第一个会话***********************/
            sqlSession = sqlSessionFactory.openSession();
            //4、获取Mapper接口
            UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
            //5、执行SQL操作
            User user = userMapper.getById(1L);
            System.out.println(user);
            sqlSession.close();
 
            /********************第二个会话**********************/
            sqlSession2 = sqlSessionFactory.openSession();
            UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
            User user2 = userMapper2.getById(1L);
            System.out.println(user2);
            sqlSession2.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
 
}

观察后台日志:

代码语言:javascript
复制
Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@704921a5]
==>  Preparing: select * from user where id = ? 
==> Parameters: 1(Long)
<==    Columns: id, username
<==        Row: 1, 张三
<==      Total: 1
User{id='1', username='张三'}
Cache Hit Ratio [com.wsh.mybatis.mybatisdemo.mapper.UserMapper]: 0.0
Opening JDBC Connection
Created connection 1270144618.
Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@4bb4de6a]
==>  Preparing: select * from user where id = ? 
==> Parameters: 1(Long)
<==    Columns: id, username
<==        Row: 1, 张三
<==      Total: 1
User{id='1', username='张三'}

我们看到,还是打印了2次sql,说明缓存没生效,配置也都配置正确了,那会是什么问题呢?

造成这个的原因其实是因为Mybatis的二级缓存存储的时候,是先保存在临时属性中,等事务提交的时候再保存到真实的二级缓存。

我们知道Mybatis的缓存管理都是由Executor来进行管理,要看缓存相关代码,肯定也是在Executor执行器中。看一下CachingExecutor类的query方法:

代码语言:javascript
复制
//org.apache.ibatis.executor.CachingExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql)
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")
      //根据key从二级缓存中查找对应的数据
      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);
}

再看看CachingExecutor的commit方法,在commit的时候才会将缓存放到真正的缓存中,这样做的目的就是为了防止不同SqlSession间的脏读,一个SqlSession读取了另一个SqlSession还未提交的数据。

代码语言:javascript
复制
public void commit(boolean required) throws SQLException {
  delegate.commit(required);
  //TransactionalCacheManager tcm = new TransactionalCacheManager()
  //真正提交二级缓存,会将临时中间变量entriesToAddOnCommit的值刷新到二级缓存中
  tcm.commit();
}

所以很显然,我们的单元测试少了sqlSession.commit();提交这一步骤,下面我们将它加上:

代码语言:javascript
复制
@SpringBootTest
class MybatisSecondCacheDemoApplicationTests {
 
    public static void main(String[] args) {
        //1、读取配置文件
        String resource = "mybatis-config.xml";
        InputStream inputStream;
        SqlSession sqlSession = null;
        SqlSession sqlSession2 = null;
        try {
            inputStream = Resources.getResourceAsStream(resource);
            //2、初始化mybatis,创建SqlSessionFactory类实例
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
            System.out.println(sqlSessionFactory);
            //3、创建Session实例
            /*********************第一个会话***********************/
            sqlSession = sqlSessionFactory.openSession();
            //4、获取Mapper接口
            UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
            //5、执行SQL操作
            User user = userMapper.getById(1L);
            System.out.println(user);
            sqlSession.commit();
            sqlSession.close();
 
            /********************第二个会话**********************/
            sqlSession2 = sqlSessionFactory.openSession();
            UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
            User user2 = userMapper2.getById(1L);
            System.out.println(user2);
            sqlSession2.commit();
            sqlSession2.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
 
}

重新启动单元测试,观察后台日志:

代码语言:javascript
复制
Setting autocommit to false on JDBC Connection [com.mysql.jdbc.JDBC4Connection@704921a5]
==>  Preparing: select * from user where id = ? 
==> Parameters: 1(Long)
<==    Columns: id, username
<==        Row: 1, 张三
<==      Total: 1
User{id='1', username='张三'}
Cache Hit Ratio [com.wsh.mybatis.mybatisdemo.mapper.UserMapper]: 0.5
User{id='1', username='张三'}

可以看到,这次只输出了一次SQL,说明第二次查询是从二级缓存中获取的,第一次查询提交了事务后,第二次直接命中了缓存,也印证了事务提交才会将查询结果放到缓存中。

六、二级缓存的工作原理

前面已经介绍了Mybatis的二级缓存怎么使用的,我们都知道一级缓存的作用范围是SqlSession级别的,但是SqlSession是单线程的,不同线程间的操作会有一些脏数据的问题。二级缓存的范围更大,是Mapper级别的缓存,因此不同sqlSession间可以共享缓存。

Mybatis的二级缓存默认是开启的,可以在全局配置文件中进行配置:

代码语言:javascript
复制
<setting name="cacheEnabled" value="true"/>

cacheEnabled默认值就是true,下图是从Mybatis官网看到的:

图片.png
图片.png

注意:如果cacheEnabled配成false,其余各个Mapper XML文件配成支持cache也没用。

并且在需要进行开启二级缓存的mapper中新增cache配置,cache配置有很多属性:

代码语言:javascript
复制
<!--
    type : 缓存实现类,默认是PerpetualCache,也可以是第三方缓存的实现,比如ehcache, oscache 等等;
    size:最多缓存对象的个数;
    eviction:缓存回收策略,默认是LRU最近最少使用策略;
        LRU:最近最少使用策略,回收最长时间不被使用的缓存;
        FIFO:先进先出策略,回收最新进入的缓存;
        SOFT - 软引用:移除基于垃圾回收器状态和软引用规则的对象;
        WEAK - 弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象;
    flushInterval:缓存刷新的间隔时间,默认是不刷新的;
   readOnly : 是否只读,true 只会进行读取操作,修改操作交由用户处理; false 可以进行读取操作,也可以进行修改操作;
-->
<cache type="org.apache.ibatis.cache.impl.PerpetualCache"
       size="1024"
       eviction="LRU"
       flushInterval="120000"
       readOnly="false"/>

二级缓存是Mapper接口级别的缓存,在前面几篇文章介绍Executor的时候,细心的小伙伴肯定会发现,在创建Executor的时候,如果开启了二级缓存,Mybatis使用了装饰者模式对executor进行包装成CachingExecutor。具体源码如下:

代码语言:javascript
复制
//org.apache.ibatis.session.Configuration#newExecutor(org.apache.ibatis.transaction.Transaction, org.apache.ibatis.session.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);
  }
  //如果开启了二级缓存,Mybatis使用了装饰者模式对executor进行包装成CachingExecutor
  if (cacheEnabled) {
    executor = new CachingExecutor(executor);
  }
  executor = (Executor) interceptorChain.pluginAll(executor);
  return executor;
}

点开CachingExecutor 类的源码,可以看到CachingExecutor 中只有两个成员变量,其中一个就是TransactionalCacheManager用来管理缓存,还有一个delegate则是被包装的执行器。

代码语言:javascript
复制
//被包装的执行器
  private final Executor delegate;
   //缓存管理类,用来管理TransactionalCache
  private final TransactionalCacheManager tcm = new TransactionalCacheManager();

继续看一下缓存管理类TransactionalCacheManager的代码:

代码语言:javascript
复制
public class TransactionalCacheManager {
 
  //内部维护着一个HashMap缓存,其中TransactionalCache实现了Cache接口
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
 
  public void clear(Cache cache) {
    getTransactionalCache(cache).clear();
  }
 
  public Object getObject(Cache cache, CacheKey key) {
    return getTransactionalCache(cache).getObject(key);
  }
 
  public void putObject(Cache cache, CacheKey key, Object value) {
    getTransactionalCache(cache).putObject(key, value);
  }
 
  //提交
  public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
    }
  }
 
  //回滚
  public void rollback() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.rollback();
    }
  }
 
  //获取对应的缓存
  private TransactionalCache getTransactionalCache(Cache cache) {
    return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
  }
 
}

TransactionalCache类的代码也比较简单,主要属性如下:

代码语言:javascript
复制
public class TransactionalCache implements Cache {
 
  private static final Log log = LogFactory.getLog(TransactionalCache.class);
  //二级缓存对象  
  private final Cache delegate;
  //ture表示提交事务时,清空缓存
  private boolean clearOnCommit;
  //存放临时缓存,当commit的时候,才会加到二级缓存中
  //为了解决不同sqlSession之间可能会产生脏读现象
  private final Map<Object, Object> entriesToAddOnCommit;
 //存放没有命中缓存的key值 
 private final Set<Object> entriesMissedInCache;
 
  public TransactionalCache(Cache delegate) {
    this.delegate = delegate;
    this.clearOnCommit = false;
    this.entriesToAddOnCommit = new HashMap<>();
    this.entriesMissedInCache = new HashSet<>();
  }
  //..........
}  

为什么二级缓存需要先放到一个临时中间变量entriesToAddOnCommit?

原因其实是为了解决不同sqlSession之间可能会产生脏读现象,假如有两个线程,线程A在一个事务中修改了数据,然后另外一个线程此时去查询数据,直接放入缓存,那么假如线程A此时回滚了,那么其实缓存中的数据就是不正确的,所以Mybatis采用了先临时存储一下来解决脏读。

接下来看看Mybatis的二级缓存是如何工作的。来看CachingExecutor的query方法:

代码语言:javascript
复制
// org.apache.ibatis.executor.CachingExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql)
@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) {
      //select标签是否配置了flushCache属性
      flushCacheIfRequired(ms);
      //如果select标签配置了useCache属性
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        //根据cacheKey获取二级缓存  
        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);
  }

与一级缓存一样,在执行更新、删除等操作时,会清空二级缓存中的数据,如下:

代码语言:javascript
复制
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
  flushCacheIfRequired(ms);
  return delegate.update(ms, parameterObject);
}
 
//org.apache.ibatis.executor.CachingExecutor#flushCacheIfRequired
private void flushCacheIfRequired(MappedStatement ms) {
  Cache cache = ms.getCache();
  if (cache != null && ms.isFlushCacheRequired()) {
    //清除缓存  
    tcm.clear(cache);
  }
}

最后,我们Debug一下源码,看下二级缓存怎么工作的。

直接在org.apache.ibatis.executor.CachingExecutor#query方法打一个断点。

第一次执行查询:

图片.png
图片.png

如上,第一次执行的时候,二级缓存中没有对应的数据,所以会执行普通查询,后续就会进行一级缓存的查询。

在CachingExecutor#commit方法打一个断点,这时候第一次查询提交事务sqlSession.commit(),我们查看此时TransactionalCacheManager中的缓存如下图:

图片.png
图片.png

真正提交缓存如下图:

图片.png
图片.png

第二次执行:

图片.png
图片.png

如上可以看到,当第二次查询时,从二级缓存中获取到了对应的数据,那么就不会执行普通查询了,直接返回缓存中获取的数据。以上就是关于一级缓存和二级缓存的详细使用和工作原理分析。

七、总结

MyBatis的缓存有两种:一级缓存和二级缓存,一级缓存的作用范围是SqlSession级别的,同一个会话中执行相同的SQL语句只会发送一条SQL查询数据库;而二级缓存是Mapper接口级别的,在不同的会话中执行相同的SQL只会发送一条SQL查询数据库。

二级缓存注意点:

  • 注意只有提交事务之后才会将二级缓存真正保存,没提交是暂存在中间变量中,避免了不同会话之间发生脏读。
  • 如果使用的是默认缓存,那么结果集对象需要实现序列化接口(Serializable)

如果不实现序列化接口则会报如下错误【由此也证明了二级缓存实际上就是通过序列化与反序列化实现的】:

图片.png
图片.png
  • 一级缓存、二级缓存毕竟都是将数据缓存到内存中,在分布式环境中,可能都会出现脏读现象,这时候我们可能会需要自定义缓存,比如利用redis来存储缓存,而不是存储在本地内存当中,感兴趣的小伙伴可以去了解一下如何自定义缓存。

MyBatis 中的一级缓存和二级缓存都是默认开启的,不过二级缓存还要额外在mapper和statement中配置缓存属性。一级缓存和二级缓存适用于读多写少的场景,如果频繁的更新数据,将降低查询性能。鉴于笔者水平有限,如果文章有什么错误或者需要补充的,希望小伙伴们指出来,希望这篇文章对大家有帮助。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2024-01-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、概述
  • 二、一级缓存的使用
  • 三、一级缓存的关闭
  • 四、一级缓存的工作原理
  • 五、二级缓存的使用
  • 六、二级缓存的工作原理
  • 七、总结
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档