记一次 MyBatis 缓存踩坑过程,最后通过跟踪源码定位、解决问题。

踩坑背景

我们的项目使用 MyBatis 作为数据库访问层,并且使用 shardbatis 进行分表,分表逻辑通过实现 ShardStrategy 接口中的 getTargetTableName 接口完成。踩坑是在下面的特定情况下发生的:

  • 假如有三个分表,分别是 demo_0demo_1demo_2,其中包含的满足后面查询条件的数据分别有3条、0条、1条;
  • 通过 MyBatis Generator 生成对应的文件,包括 DemoMapper.xmlDemoMapper.javaDemo.JavaDemoExample.java
  • 业务处理函数 getDemoList@Transactional 注解,这是一个事务性操作,失败时数据库操作会回滚;
  • getDemoList 函数需要用同样的查询条件查询多张分表,并汇总结果;
  • 查询的 SQL 语句通过 DemoExample 类实例创建。

实际表现

实际的查询结果与 getDemoList 函数查询分表的顺序有关,如下表格所示:

数据表查询顺序 查询总结果中的记录数
demo_0、demo_1、demo_2 9
demo_1、demo_0、demo_2 0
demo_2、demo_1、demo_0 3

通过实际的代码 debug 调试,发现 getDemoList 返回的结果是将一份数据重复了三次,并且都是每次查询时 第一个访问的数据表 中的数据。

问题原因及其解决办法

问题原因

通过跟踪代码和 Google,发现问题的原因如下:

  • 在使用 MyBatis 的项目中,被 @Transactional 注解的函数中,一个单独的 sqlSession 对象将会被创建和使用,所有数据库操作会共用这个 sqlSession,当事务完成时,这个 sqlSession 会以合适的方式提交或回滚;
  • select 语句默认开启查询缓存,并且不清除缓存,所以使用同一个 sqlSession 多次用相同的条件查询数据库时,只有第一次真实访问数据库,后面的查询都直接读取缓存返回;

虽然在项目中增加了分表逻辑,但是因为是采用相同的查询语句及其查询条件,所以在到达分表逻辑之前就已经命中缓存,提前返回,从而导致上面的错误。

flushCacheuseCache 属性

MyBatis 的一级缓存(local cache),即本地缓存,作用域默认为 Session,所以当 Session flush 或 close 之后,该 Session 中的所有 Cache 就将清空。其中 flush 可在每个 SQL 语句中配置,具体说明如下:

  • 当为 select 语句时
    • flushCache 默认为 false,表示任何时候语句被调用,都不会去清空本地缓存和二级缓存;
    • useCache 默认为 true,表示会将本条语句的结果进行二级缓存;
  • 当为 insertupdatedelete 语句时:
    • flushCache 默认为 true,表示任何时候语句被调用,都会导致本地缓存和二级缓存被清空;
    • useCache 属性在该情况下没有。
默认的 select
1
2
3
<select id="save" parameterType="XX" flushCache="false" useCache="true">
……
</select>

解决办法

为了解决这个问题,需要在 DemoMapper.xml 中对应的 SQL 中添加 flushCache="true" 属性,表示在每次查询前清空缓存,下面几个 sql 都需要添加,因为底层都是通过 select 实现:

  • selectByExample
  • selectByExampleWithRowbounds
  • selectByPrimaryKey
  • countByExample

注:Mapper.xml 文件的配置可参考 Mapper XML 文件

定位问题的过程

本文中用到的 MyBatis 版本是 mybatis-3.3.0mybatis-3.3.0-sources.jar

查询本地缓存之前清除缓存

/org/apache/ibatis/executor/BaseExecutor.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@SuppressWarnings("unchecked")
@Override
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.");
}
// 配置 flushCache="true" 之后,ms.isFlushCacheRequired() 函数返回 true,将会在这里清除本地缓存
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
// 在这里查询本地缓存,key 由查询语句生成
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;
}

缓存查询结果

/org/apache/ibatis/executor/BaseExecutor.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}

根据查询语句生成 cacheKey

/org/apache/ibatis/executor/BaseExecutor.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@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(Integer.valueOf(rowBounds.getOffset()));
cacheKey.update(Integer.valueOf(rowBounds.getLimit()));
cacheKey.update(boundSql.getSql());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
// mimic DefaultParameterHandler logic
// 依此遍历查询参数,将每个参数对应的值拼接到 cacheKey 中
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
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;
}

Example:如下,就是根据一个 select 语句生成 cacheKey:

1
2
3
4
350854748:2813260859:com.demo.dao.mapper.DemoMapper.selectByExample:0:2147483647:select
id, uid, start_time, end_time, status, is_deleted
from demo
WHERE ( uid = ? and end_time > ? and status = ? and is_deleted = ? ):2:Wed Aug 16 13:32:01 CST 2017:1:false:SqlSessionFactoryBean

Session 管理

/org/mybatis/spring/SqlSessionTemplate.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* Proxy needed to route MyBatis method calls to the proper SqlSession got
* from Spring's Transaction Manager
* It also unwraps exceptions thrown by {@code Method#invoke(Object, Object...)} to
* pass a {@code PersistenceException} to the {@code PersistenceExceptionTranslator}.
*/
private class SqlSessionInterceptor implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
SqlSession sqlSession = getSqlSession(
SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType,
SqlSessionTemplate.this.exceptionTranslator);
try {
Object result = method.invoke(sqlSession, args);
// 对于非事务性 session,会直接调用 commit
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
// 在 commit 中,会清除本地缓存
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
Throwable unwrapped = unwrapThrowable(t);
if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
// release the connection to avoid a deadlock if the translator is no loaded. See issue #22
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
sqlSession = null;
Throwable translated = SqlSessionTemplate.this.exceptionTranslator.translateExceptionIfPossible((PersistenceException) unwrapped);
if (translated != null) {
unwrapped = translated;
}
}
throw unwrapped;
} finally {
if (sqlSession != null) {
// 对于事务性 session,closeSqlSession 时只会 holder.released()
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}
}

flushCache 所在的 Java 对象

/org/apache/ibatis/mapping/MappedStatement.java
1
2
3
public final class MappedStatement {
private boolean flushCacheRequired;
}

References