前言
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);
}
}
在二级缓存执行流程后就会进入一级缓存的执行流程,因此不再赘述。