Mybatis缓存解析
1. 概述
在系统代码的运行中,我们可能会在一个数据库会话中,执行多次查询条件完全相同的Sql,鉴于日常应用的大部分场景都是读多写少,这重复的查询会带来一定的网络开销,同时select查询的量比较大的话,对数据库的性能是有比较大的影响的。
如果是Mysql数据库的话,在服务端和Jdbc端都开启预编译支持的话,可以在本地JVM端缓存Statement,可以在Mysql服务端直接执行Sql,省去编译Sql的步骤,但也无法避免和数据库之间的重复交互。
在默认情况下,mybatis 的一级缓存是默认开启的。类似于hibernate, 所谓一级缓存,也就是基于同一个sqlsession 的查询语句,即 session 级别的缓存,非全局缓存,或者非二级缓存.
如果要实现 mybatis 的二级缓存,一般来说有如下两种方式:
(1)采用 mybatis 内置的 cache 机制。
(2)采用三方 cache 框架, 比如ehcache, oscache 等等.
2. 一级缓存
Mybatis提供了一级缓存的方案来优化在数据库会话间重复查询的问题。实现的方式是每一个SqlSession中都持有了自己的缓存,一种是SESSION级别,即在一个Mybatis会话中执行的所有语句,都会共享这一个缓存。一种是STATEMENT级别,可以理解为缓存只对当前执行的这一个statement有效。如果用一张图来代表一级查询的查询过程的话,可以用下图表示。
每一个SqlSession中持有了自己的Executor,每一个Executor中有一个Local Cache。当用户发起查询时,Mybatis会根据当前执行的MappedStatement生成一个key,去Local Cache中查询,如果缓存命中的话,返回。如果缓存没有命中的话,则写入Local Cache,最后返回结果给用户。
2.1 一级缓存配置
上文介绍了一级缓存的实现方式,解决了什么问题。在这个章节,我们学习如何使用Mybatis的一级缓存。只需要在Mybatis的配置文件中,添加如下语句,就可以使用一级缓存。共有两个选项,SESSION或者STATEMENT,默认是SESSION级别。
<setting name="localCacheScope" value="SESSION"/>
接下来我们就用简单的示例来测试下一级缓存。
示例1,
public static void main(String[] args) {
SqlSession session = MybatisUtils.getSqlSession();
RoleMapper mapper = session.getMapper(RoleMapper.class);
System.out.println(mapper.findRoleById(1L).getRoleName());
System.out.println(mapper.findRoleById(1L).getRoleName());
System.out.println(mapper.findRoleById(1L).getRoleName());
}
我们看下执行的日志,如下图:
我们可以看到,只有第一次真正查询了数据库,后续的查询使用了一级缓存。
示例2,
在这次的试验中,我们增加了对数据库的修改操作,验证在一次数据库会话中,对数据库发生了修改操作,一级缓存是否会失效。如下代码:
public static void main(String[] args) {
SqlSession session = MybatisUtils.getSqlSession();
RoleMapper mapper = session.getMapper(RoleMapper.class);
Role role = new Role();
role.setRoleName("cache1");
role.setNote("cache1");
System.out.println(mapper.findRoleById(1L).getRoleName());
mapper.insert(role);
System.out.println(mapper.findRoleById(1L).getRoleName());
}
再看下执行日志:
我们可以看到,在修改操作后执行的相同查询,查询了数据库,一级缓存失效。
示例3,开启两个SqlSession,在sqlSession1中查询数据,使一级缓存生效,在sqlSession2中更新数据库,验证一级缓存只在数据库会话内部共享。
public static void main(String[] args) {
SqlSession session1 = MybatisUtils.getSqlSession();
SqlSession session2 = MybatisUtils.getSqlSession();
RoleMapper mapper1 = session1.getMapper(RoleMapper.class);
RoleMapper mapper2 = session2.getMapper(RoleMapper.class);
Role role = new Role();
role.setRoleName("update");
role.setNote("update");
role.setId(1L);
System.out.println(mapper1.findRoleById(1L).getRoleName());
System.out.println(mapper1.findRoleById(1L).getRoleName());
mapper2.update(role);
System.out.println(mapper1.findRoleById(1L).getRoleName());
System.out.println(mapper2.findRoleById(1L).getRoleName());
}
继续看下执行日志:
我们可以看到,sqlSession2插入了一个新的role,但session1之后的查询中,id为1的role的名字test1,出现了脏数据,也证明了我们之前就得到的结论,一级缓存只存在于只在数据库会话内部共享。
2.2 一级缓存工作流程&源码分析
根据一级缓存的工作流程,我们绘制出一级缓存执行的时序图,如下图所示。
主要步骤如下:
(1)对于某个Select Statement,根据该Statement生成key。
(2)判断在Local Cache中,该key是否用对应的数据存在。
(3)如果命中,则跳过查询数据库,继续往下走。
(4)如果没命中:
去数据库中查询数据,得到查询结果;
将key和查询到的结果作为key和value,放入Local Cache中。
将查询结果返回;
(5)判断缓存级别是否为STATEMENT级别,如果是的话,清空本地缓存。
了解具体的工作流程后,我们队Mybatis查询相关的核心类和一级缓存的源码进行走读。这对于之后学习二级缓存时也有帮助。
SqlSession: 对外提供了用户和数据库之间交互需要的所有方法,隐藏了底层的细节。它的一个默认实现类是DefaultSqlSession。
如下是接口SqlSession的内部方法:
Executor: SqlSession向用户提供操作数据库的方法,但和数据库操作有关的职责都会委托给Executor。如下是Executor的结构:
如下图所示,Executor有若干个实现类,为Executor赋予了不同的能力,大家可以根据类名,自行私下学习每个类的基本作用。
在一级缓存章节,我们主要学习BaseExecutor。
BaseExecutor: BaseExecutor是一个实现了Executor接口的抽象类,定义若干抽象方法,在执行的时候,把具体的操作委托给子类进行执行。如下图所示:
在一级缓存的介绍中,我们提到对Local Cache的查询和写入是在Executor内部完成的。在阅读BaseExecutor的代码后,我们也发现Local Cache就是它内部的一个成员变量,如下代码所示。
从上图可以看到这个LocalCache的成员变量是PerpetualCache的一个实例,而PerpetualCache是实现了Cache接口。提供了和缓存相关的最基本的操作,有若干个实现类,使用装饰器模式互相组装,提供丰富的操控缓存的能力。我们看下Cache的内部结构,如下图:
下图就是mybatis中cache的一些实现:
BaseExecutor成员变量之一的PerpetualCache,就是对Cache接口最基本的实现,其实现非常的简内部持有了hashmap,对一级缓存的操作其实就是对这个hashmap的操作。如下代码所示。
在阅读相关核心类代码后,从源代码层面对一级缓存工作中涉及到的相关代码,出于篇幅的考虑,对源码做适当删减,读者朋友可以结合本文,后续进行更详细的学习。
为了执行和数据库的交互,首先会通过DefaultSqlSessionFactory开启一个SqlSession,在创建SqlSession的过程中,会通过Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数,如下图所示。
如果用户不进行制定的话,Configuration在创建Executor时,默认创建的类型就是SimpleExecutor,它是一个简单的执行类,只是单纯执行Sql。以下是具体用来创建的代码。如下代码:
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);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
在SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中,如果是Select语句的话,最后会执行到SqlSession的selectList,代码如下所示。
@Override
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();
}
}
在上文的代码中,SqlSession把具体的查询职责委托给了Executor。如果只开启了一级缓存的话,首先会进入BaseExecutor的query方法。代码如下所示。
@Override
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);
}
在上述代码中,会先根据传入的参数生成CacheKey,进入该方法查看CacheKey是如何生成的,代码如下所示。
@Override
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
if (closed) {
throw new ExecutorException("Executor was closed.");
}
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;
}
在上述的代码中,我们可以看到它将MappedStatement的Id、sql的offset、Sql的limit、Sql本身以及Sql中的参数传入了CacheKey这个类,最终生成了CacheKey。我们看一下这个类的结构。如下代码:
private static final long serialVersionUID = 1146682552656046210L;
public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;
private final int multiplier;
private int hashcode;
private long checksum;
private int count;
private List<Object> updateList;
public CacheKey() {
this.hashcode = DEFAULT_HASHCODE;
this.multiplier = DEFAULT_MULTIPLYER;
this.count = 0;
this.updateList = new ArrayList<Object>();
}
首先是它的成员变量和构造函数,有一个初始的hachcode和乘数,同时维护了一个内部的updatelist。在CacheKey的update方法中,会进行一个hashcode和checksum的计算,同时把传入的参数添加进updatelist中。如下代码所示。
public void update(Object object) {
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
count++;
checksum += baseHashCode;
baseHashCode *= count;
hashcode = multiplier * hashcode + baseHashCode;
updateList.add(object);
}
我们是如何判断CacheKey相等的呢,在CacheKey的equals方法中给了我们答案,代码如下所示。
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;
if (hashcode != cacheKey.hashcode) {
return false;
}
if (checksum != cacheKey.checksum) {
return false;
}
if (count != cacheKey.count) {
return false;
}
for (int i = 0; i < updateList.size(); i++) {
Object thisObject = updateList.get(i);
Object thatObject = cacheKey.updateList.get(i);
if (!ArrayUtil.equals(thisObject, thatObject)) {
return false;
}
}
return true;
}
除去hashcode,checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条Sql的下列五个值相同,即可以认为是相同的Sql。
Statement Id + Offset + Limmit + Sql + Params
回到BaseExecutor的query方法继续往下走,代码如下所示。
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.");
}
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
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) {
// issue #482
clearLocalCache();
}
}
return list;
}
如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。
在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。
在源码分析的最后,我们确认一下,如果是insert/delete/update方法,缓存就会刷新的原因。
SqlSession的insert方法和delete方法,都会统一走update的流程,update方法也是委托给了Executor执行。BaseExecutor的执行方法如下所示。。
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。至此,一级缓存的工作流程讲解以及源码分析完毕。
总结
(1)Mybatis一级缓存的生命周期和SqlSession一致。
(2)Mybatis的缓存是一个粗粒度的缓存,没有更新缓存和缓存过期的概念,同时只是使用了默认的hashmap,也没有做容量上的限定。
(3)Mybatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,有操作数据库写的话,会引起脏数据,建议是把一级(4)缓存的默认级别设定为Statement,即不使用一级缓存。
3. 二级缓存
3.1 二级缓存介绍
在上文中提到的一级缓存中,其最大的共享范围就是一个SqlSession内部,那么如何让多个SqlSession之间也可以共享缓存呢,答案是二级缓存。
当开启二级缓存后,会使用CachingExecutor装饰Executor,在进入后续执行前,先在CachingExecutor进行二级缓存的查询,具体的工作流程如下所示。
在二级缓存的使用中,一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存是被多个SqlSession共享着的,是一个全局的变量。
当开启缓存后,数据的查询执行的流程就是 二级缓存 -> 一级缓存 -> 数据库。
3.2 二级缓存的配置
要正确的使用二级缓存,需完成如下配置的。
(1) 在Mybatis的配置文件中开启二级缓存。
<setting name="cacheEnabled" value="true"/>
(2) 在Mybatis的映射XML中配置cache或者 cache-ref 。
<cache/>
cache标签用于声明这个namespace使用二级缓存,并且可以自定义配置。
(1)type: cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
(2)eviction: 定义回收的策略,常见的有FIFO,LRU。
(3)flushInterval: 配置一定时间自动刷新缓存,单位是毫秒
(4)size: 最多缓存对象的个数
(5)readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
(6)blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。
<cache-ref namespace="mapper.RoleMapper"/>
cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。
3.3 简单示例
示例1,测试二级缓存效果,不提交事务,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。
SqlSession session1 = MybatisUtils.getSqlSession();
SqlSession session2 = MybatisUtils.getSqlSession();
RoleMapper mapper1 = session1.getMapper(RoleMapper.class);
RoleMapper mapper2 = session2.getMapper(RoleMapper.class);
System.out.println("mapper1读取数据:"+mapper1.findRoleById(1L).getRoleName());
System.out.println("mapper2读取数据:"+mapper2.findRoleById(1L).getRoleName());
我们看下执行日志,如下图所示:
我们看到当sqlsession没有调用commit()方法时,二级缓存并没有起到作用。
示例2,测试二级缓存效果,当提交事务时,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。
SqlSession session1 = MybatisUtils.getSqlSession();
SqlSession session2 = MybatisUtils.getSqlSession();
RoleMapper mapper1 = session1.getMapper(RoleMapper.class);
RoleMapper mapper2 = session2.getMapper(RoleMapper.class);
System.out.println("mapper1读取数据:"+mapper1.findRoleById(1L).getRoleName());
session1.commit();
System.out.println("mapper2读取数据:"+mapper2.findRoleById(1L).getRoleName());
3.4 二级缓存源码分析
Mybatis二级缓存的工作流程和前文提到的一级缓存类似,只是在一级缓存处理前,用CachingExecutor装饰了BaseExecutor的子类,实现了缓存的查询和写入功能,所以二级缓存直接从源码开始分析。
源码分析从CachingExecutor的query方法展开,源代码走读过程中涉及到的知识点较多,不能一一详细讲解,可以在文后留言,我会在交流环节更详细的表示出来。
CachingExecutor的query方法,首先会从MappedStatement中获得在配置初始化时赋予的cache。
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")
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);
}
本质上是装饰器模式的使用,具体的执行链是
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。
以下是具体这些Cache实现类的介绍,他们的组合为Cache赋予了不同的能力。
(1)SynchronizedCache: 同步Cache,实现比较简单,直接使用synchronized修饰方法。
(2)LoggingCache: 日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
(3)SerializedCache: 序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
(4)LruCache: 采用了Lru算法的Cache实现,移除最近最少使用的key/value。
(5)PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。
然后是判断是否需要刷新缓存,在默认的设置中SELECT语句不会刷新缓存,insert/update/delte会刷新缓存。进入该方法。代码如下所示。
private void flushCacheIfRequired(MappedStatement ms) {
Cache cache = ms.getCache();
if (cache != null && ms.isFlushCacheRequired()) {
tcm.clear(cache);
}
}
Mybatis的CachingExecutor持有了TransactionalCacheManager,即上述代码中的tcm。TransactionalCacheManager中持有了一个Map,代码如下所示。
这个Map保存了Cache和用TransactionalCache包装后的Cache的映射关系。TransactionalCache实现了Cache接口,CachingExecutor会默认使用他包装初始生成的Cache,作用是如果事务提交,对缓存的操作才会生效,如果事务回滚或者不提交事务,则不对缓存产生影响。
在TransactionalCache的clear,有以下两句。清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存,代码如下所示。
public void clear() {
clearOnCommit = true;
entriesToAddOnCommit.clear();
}
继续回到CachingExecutor中的query方法继续往下走,ensureNoOutParams主要是用来处理存储过程的,暂时不用考虑。之后会尝试从tcm中获取缓存的列表。在getObject方法中,会把获取值的职责一路向后传,最终到PerpetualCache。如果没有查到,会把key加入Miss集合,这个主要是为了统计命中率。如果查询到数据,则调用tcm.putObject方法,往缓存中放入值。tcm的put方法也不是直接操作缓存,只是在把这次的数据和key放入待提交的Map中,以下是此方法的代码:
public void putObject(Object key, Object object) {
entriesToAddOnCommit.put(key, object);
}
从以上的代码分析中,我们可以明白,如果不调用commit方法的话,由于TranscationalCache的作用,并不会对二级缓存造成直接的影响。因此我们看看Sqlsession的commit方法中做了什么。代码如下所示。
public void commit(boolean required) throws SQLException {
delegate.commit(required);
tcm.commit();
}
会把具体commit的职责委托给包装的Executor。主要是看下tcm.commit(),tcm最终又会调用到TrancationalCache。
public void commit() {
if (clearOnCommit) {
delegate.clear();
}
flushPendingEntries();
reset();
}
看到这里的clearOnCommit就想起刚才TrancationalCache的clear方法设置的标志位,真正的清理Cache是放到这里来进行的。具体清理的职责委托给了包装的Cache类。之后进入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方法,其中调用了这个函数,代码如下所示,因此不再赘述。
private void flushCacheIfRequired(MappedStatement ms)
3.5 总结
Mybatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到Mapper级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
Mybatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用的条件比较苛刻。
在分布式环境下,由于默认的Mybatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将Mybatis的Cache接口实现,有一定的开发成本,不如直接用Redis,Memcache实现业务上的缓存就好了。
4. 实现自定义缓存
除了系统定义的缓存外,你也可以通过实现你自己的缓存或为其他第三方缓存方案 创建适配器来完全覆盖缓存行为,比如比较流行的Redis、Memcache等。要实现自定义缓存我们需要实现mybatis提供的Cache接口,如下图所示是Cache的结构
在我们实现了接口后可以用以下方式使用我们自定义的缓存:
<cache type="com.domain.something.MyCustomCache"/>
要配置你的缓存, 简单和公有的 JavaBeans 属性来配置你的缓存实现, 而且是通过 cache 元素来传递属性, 比如, 下面代码会在你的缓存实现中调用一个称为 “setCacheFile(String file)” 的方法:
<cache type="com.yhl.mybatis.cache.MyCustomCache">
<property name="cacheFile" value="/tmp/my-custom-cache.tmp"/>
</cache>