Mybatis缓存机制

前言

MyBatis是常见的Java数据库访问层框架。在日常工作中,开发人员多数情况下是使用MyBatis的默认缓存配置,但是MyBatis缓存机制有一些不足之处,在使用中容易引起脏数据,形成一些潜在的隐患。

一级缓存

默认情况下,MyBatis只开启一级缓存。一级缓存只是相对于同一个SqlSession而言的,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。

每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。

一般只需在MyBatis的配置文件中,添加如下语句,就可以使用一级缓存。共有两个选项,SESSION或者STATEMENT,默认是SESSION级别,即在一个MyBatis会话中执行的所有语句,都会共享这一个缓存。一种是STATEMENT级别,可以理解为缓存只对当前执行的这一个Statement有效。

<cache eviction="LRU" flushInterval="120000" size="1024" readOnly="true"/>

前面我们提到,一级缓存只是相对于同一个SqlSession而言的,如果是相同的SQL语句,会优先命中一级缓存,那我们来验证一下,

public static void main(String[] args) throws IOException{
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("spring/SqlMapConfig.xml"));

        SqlSession session1 = sqlSessionFactory.openSession(true);

        UserDao dao = session1.getMapper(UserDao.class);

        logger.debug("开始查询用户信息");

        logger.info(dao.findUserById("A001").toString());

        logger.debug("再次查询用户信息");

        logger.info(dao.findUserById("A001").toString());

    }

上述代码很简单,在同一个SqlSession内根据相同的id连续查询同一个用户信息,运行结果如下图:
这里写图片描述

那我们在未开启二级缓存的前提下,使用不同的SqlSession查询信息,代码如下,

public static void main(String[] args) throws IOException{
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("spring/SqlMapConfig.xml"));

        SqlSession session1 = sqlSessionFactory.openSession(true);

        UserDao dao = session1.getMapper(UserDao.class);

        logger.debug("开始查询用户信息");

        logger.info(dao.findUserById("A001").toString());

        SqlSession session2 = sqlSessionFactory.openSession(true);

        dao = session2.getMapper(UserDao.class);

        logger.debug("再次查询用户信息");

        logger.info(dao.findUserById("A001").toString());

    }

运行结果如下图:
这里写图片描述

上述两次运行结果确实验证了一级缓存的作用范围为SqlSession,那我们如果在SqlSession内对同一份数据有修改操作,运行结果如何呢?代码如下:

public static void main(String[] args) throws IOException{
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("spring/SqlMapConfig.xml"));

        SqlSession session1 = sqlSessionFactory.openSession(true);

        UserDao dao = session1.getMapper(UserDao.class);

        logger.debug("开始查询用户信息");

        logger.info(dao.findUserById("A001").toString());

        logger.debug("新增用户信息");

        dao.addUser(new User("A002","Cathy",24));

        logger.debug("再次查询用户信息");

        logger.info(dao.findUserById("A001").toString());

    }

上述代码在两次查询操作之间,新增了一条记录,看一下运行结果:
这里写图片描述

由此可见,在一次数据库会话中,如果对数据库发生了修改操作,一级缓存会失效。那如果在不同的数据库会话中,情况又有何不一样?代码如下:

public static void main(String[] args) throws IOException{
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("spring/SqlMapConfig.xml"));

        SqlSession session1 = sqlSessionFactory.openSession(true);

        UserDao dao1 = session1.getMapper(UserDao.class);

        logger.debug("开始查询用户信息");

        logger.info(dao1.findUserById("A001").toString());

        logger.debug("新启回话,修改用户信息");

        SqlSession session2 = sqlSessionFactory.openSession(true);

        UserDao dao2 = session2.getMapper(UserDao.class);

        dao2.updateUserById(new User("A001","Alex",30));

        logger.debug("再次查询用户信息");

        logger.info(dao1.findUserById("A001").toString());

    }

上述代码在连续两次查询之间新启一个数据库会话修改相应数据,运行结果如下:
这里写图片描述

数据库中的信息已经有所变化,但是第二次的查询结果却没有体现,出现了脏读的现象,所以也从侧面证明了一级缓存只在数据库会话内部共享。

二级缓存

二级缓存是SqlSessionFactory层面的,各个SqlSession共享二级缓存。要正确的使用二级缓存,需完成如下配置的,

<setting name="cacheEnabled" value="TRUE"/>

在MyBatis的映射XML中配置cache或者 cache-ref 。cache标签用于声明这个namespace使用二级缓存,并且可以自定义配置。

<cache eviction="LRU" flushInterval="120000" size="1024" readOnly="true"/>

配置上述信息后,我们看一下二级缓存真的有那么神奇么?代码如下:

public static void main(String[] args) throws IOException{
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("spring/SqlMapConfig.xml"));

        SqlSession session1 = sqlSessionFactory.openSession(true);

        UserDao dao1 = session1.getMapper(UserDao.class);

        logger.debug("开始查询用户信息");

        logger.info(dao1.findUserById("A001").toString());

        //session1.commit();

        SqlSession session2 = sqlSessionFactory.openSession(true);

        UserDao dao2 = session2.getMapper(UserDao.class);

        logger.debug("再次查询用户信息");

        logger.info(dao2.findUserById("A001").toString());

    }

代码功能很简单,使用不同的SqlSession查询条件相同的记录,我们看一下结果:
这里写图片描述

运行结果好像并没有走到缓存上,这是为何?其实是因为事务未提交,我们加上事务提交的逻辑(上述代码中被注释的那一行),看一下运行结果:
这里写图片描述

从上图可知,sqlsession2的查询使用了缓存,并且缓存的命中率是0.5。那我们再来看一下update操作是否会刷新该namespace下的二级缓存。代码如下:

public static void main(String[] args) throws IOException{
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("spring/SqlMapConfig.xml"));

        SqlSession session1 = sqlSessionFactory.openSession(true);
        SqlSession session2 = sqlSessionFactory.openSession(true);
        SqlSession session3 = sqlSessionFactory.openSession(true);


        UserDao dao1 = session1.getMapper(UserDao.class);
        UserDao dao2 = session2.getMapper(UserDao.class);
        UserDao dao3 = session3.getMapper(UserDao.class);

        logger.debug("开始查询用户信息");
        logger.info(dao1.findUserById("A001").toString());
        session1.commit();

        logger.debug("再次查询用户信息");
        logger.info(dao2.findUserById("A001").toString());

        logger.debug("修改用户信息");
        dao3.updateUserById(new User("A001","Alex",35));
        session3.commit();

        logger.debug("第三次查询用户信息");
        logger.info(dao2.findUserById("A001").toString());
    }

上述代码在第三次查询之前修改了数据库信息,看一下运行结果:
这里写图片描述

可见update操作会刷新该namespace下的二级缓存。

源码分析

众所周知,SqlSession对外提供了用户和数据库之间交互需要的所有方法,隐藏了底层的细节。默认实现类是DefaultSqlSession。Executor作为SqlSession的四大对象之一,承担了数据库操作有关的职责。如下图所示,Executor有若干个实现类,为Executor赋予了不同的能力,细心的读者可以发现Executor使用了装饰者模式。
这里写图片描述

BaseExecutor是一个实现了Executor接口的抽象类,具体的操作委托给子类进行执行。看一下BaseExecutor的内部实现:

public abstract class BaseExecutor implements Executor {
……
protected ConcurrentLinkedQueue<BaseExecutor.DeferredLoad> deferredLoads;
protected PerpetualCache localCache;
……
}

在BaseExecutor内部有一个名为localCache的成员变量,再看一下PerpetualCache的内部结构:

public class PerpetualCache implements Cache {
private String id;
private Map<Object, Object> cache = new HashMap();
……
}

在初始化SqlSesion时,会使用Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数,创建Executor代码如下所示:

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? this.defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Object 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);
    }

    // 尤其可以注意这里,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类
    if (this.cacheEnabled) {
        executor = new CachingExecutor((Executor)executor);
    }

    Executor executor = (Executor)this.interceptorChain.pluginAll(executor);
    return executor;
}

SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中,如果是Select语句的话,最后会执行到SqlSession的selectList,代码如下所示:

public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
 }

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    ……
    CacheKey cacheKey = new CacheKey();
    cacheKey.update(ms.getId());
    cacheKey.update(rowBounds.getOffset());
    cacheKey.update(rowBounds.getLimit());
    cacheKey.update(boundSql.getSql());
    ……
    return cacheKey;
  }

上述代码会根据传入的参数生成CacheKey,将MappedStatement的Id、sql的offset、Sql的limit、Sql本身以及Sql中的参数传入了CacheKey这个类,最终构成CacheKey。
由上可知只要两条SQL的下列五个值相同,即可以认为是相同的SQL。

Statement Id + Offset + Limmit + Sql + Params

如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。

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);
  }

最后会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。

if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
    clearLocalCache();
  }

此外,SqlSession的insert方法和delete方法,都会统一走update的流程,

public int update(String statement, Object parameter) {
    try {
      dirty = true;
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.update(ms, wrapCollection(parameter));
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error updating database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

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);
  }

由此可见,每次执行update前都会清空localCache。至此,一级缓存的工作流程讲解以及源码分析完毕。

我们再来看二级缓存,之前提到,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类,在委托具体职责给delegate之前,实现了二级缓存的查询和写入功能。进入CahingExecutor的query方法:

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语句不会刷新缓存,insert/update/delte会刷新缓存
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, parameterObject, boundSql);
        @SuppressWarnings("unchecked")
        //MyBatis的CachingExecutor持有了TransactionalCacheManager,即此处代码中的tcm。
        //在getObject方法中,会把获取值的职责一路传递,最终到PerpetualCache。
        //CachingExecutor继续往下走,如果查询到数据,则调用tcm.putObject方法,往缓存中放入值。
        //tcm的put方法也不是直接操作缓存,只是在把这次的数据和key放入待提交的Map中。
        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);
  }

如果不调用commit方法的话,并不会对二级缓存造成直接的影响。因此我们看看Sqlsession的commit方法中做了什么。

public void commit(boolean force) {
      ……
      executor.commit(isCommitOrRollbackRequired(force));
      dirty = false;
      ……
}

//CachingExecutor的commit方法
public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    tcm.commit();
}

//TransactionalCacheManager的commit方法
public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
      txCache.commit();
    }
}

//TransactionalCache的commit方法
public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
}

//TransactionalCache的flushPendingEntries方法
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);
      }
    }
}

在flushPendingEntries中,将待提交的Map进行循环处理,委托给包装的Cache类,进行putObject的操作。后续的查询操作会重复执行这套流程。如果是insert|update|delete的话,会统一进入CachingExecutor的update方法,其中调用了flushCacheIfRequired方法

private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {      
      tcm.clear(cache);
    }
  }

在二级缓存执行流程后就会进入一级缓存的执行流程,因此不再赘述。

参考:https://blog.csdn.net/tjreal/article/details/79922523