【Mybatis】Mybatis源码之一级缓存

374 阅读4分钟

这是我参与8月更文挑战的第31天,活动详情查看:8月更文挑战

使用及配置

  • 使用方式见Mybatis之缓存、懒加载

  • 一级缓存的作用域

    • mybatis-config.xml中的配置

      <setting name="localCacheScope" value="SESSION"/>
      
    • 对应枚举类

      // 本地缓存作用域
      public enum LocalCacheScope {
          SESSION, // 本地缓存的作用域为一次回话
          STATEMENT // 本地缓存的作用域为一条语句,也就是不进行任何缓存
      }
      

关键代码

缓存执行的流程

BaseExecutorquery方法(不带CacheKey和BoundSql参数)中,调用createCacheKey方法对一级缓存的key进行了生成。

// 创建CacheKey,并将所有查询参数依次更新写入
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
for (ParameterMapping parameterMapping : parameterMappings) {
    if (parameterMapping.getMode() != ParameterMode.OUT) {     
        cacheKey.update(value);
    }
}
cacheKey.update(configuration.getEnvironment().getId());

由关键代码可以看出,key是由MappedStatement的id,查询的RowBounds参数,BoundSql,参数映射以及环境配置共同组成的。

在生成了缓存的key后,进入到BaseExecutorquery方法(带CacheKey和BoundSql参数)中,首先根据key从一级缓存中查询。如果查询到了数据,则不再查询数据库,直接返回。如果没有查询到,则进入数据库查询。

// 尝试从本地缓存获取结果
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
    // 本地缓存中有结果,则对于CALLABLE语句还需要绑定到IN/INOUT参数上
    handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
    // 本地缓存没有结果,故需要查询数据库
    list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

// 如果本地缓存的作用域为STATEMENT,则立刻清除本地缓存
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
    // issue #482
    clearLocalCache();
}

query方法的最后,如果当前MappedStatement查询结束,则会判断一级缓存的作用域,如果作用域为STATEMENT,则会清除一级缓存。

在查询数据库的queryFromDatabase方法中,查询到结果后,会将查询结果写入到一级缓存中。

// 向缓存中增加占位符,表示正在查询
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
    // 使用具体的Executor执行查询
    list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
    // 删除占位符
    localCache.removeObject(key);
}
// 将查询结果写入缓存
localCache.putObject(key, list);

缓存的生命周期

BaseExecutor类中,可以看到localCache是它的一个成员变量,并且在构造方法中进行了初始化,那么localCache的创建就是与BaseExecutor的创建是一起的。

// 查询操作的结果缓存
protected PerpetualCache localCache;

protected BaseExecutor(Configuration configuration, Transaction transaction) {
    this.localCache = new PerpetualCache("LocalCache");
}

而在Mybatis源码之创建SqlSession对象一文中可以看到,在通过openSession方法创建SqlSession对象时,就会通过Configuration对象创建Executor

// 创建执行器
final Executor executor = configuration.newExecutor(tx, execType);

所以说,一级缓存最大的作用域是不能跨SqlSession的。

存在的问题

缓存最容易出现的问题就是更新不及时导致的脏读。下面列举两种一级缓存中可能会出现的问题:

不同SqlSession操作相同的数据

sequenceDiagram
autonumber
SqlSession1 ->> DB : read
DB -->> SqlSession1 : data1
SqlSession1 ->> SqlSession1 : cache data1
SqlSession2 ->> DB : update data1 to data1+
SqlSession1 ->> SqlSession1 : read cache data1

在上述情况中,步骤5读取的数据是缓存中的数据,而真实数据已经在步骤4中被修改了,因此产生了脏读。

解决这种情况,可以将一级缓存的作用域修改为STATEMENT,修改后的步骤5就会去读DB中的最新数据,避免脏读。

一级缓存的作用域为STATEMENT的情况下,使用嵌套查询

上文提到,在当前MappedStatement查询结束后,会判断一级缓存的作用域是否为STATEMENT,如果是,则清除缓存,否则不会清除缓存。

判断当前MappedStatement查询是否结束,是根据queryStack参数是否为0来判断的,当执行嵌套内部查询时,外部查询还未结束,因此内部查询在使用一级缓存时,仍然会存在脏读的问题。

sequenceDiagram
autonumber
SqlSession1 ->> DB : 嵌套外部查询, queryStack=1
SqlSession1 ->> DB : 嵌套内部查询,queryStack=2
DB -->> SqlSession1 : 返回嵌套内部查询,并cache,queryStack=1,不会清除cache
SqlSession2 ->> DB : update data
SqlSession1 ->> SqlSession1 : 嵌套内部查询,走cache,queryStack=1
DB -->> SqlSession1 : 嵌套外部查询结束,queryStack=0,清除缓存

由于嵌套查询需要多次发SQL去数据库查询数据,因此嵌套内部查询仍然可以使用缓存,如果按上图的步骤4步骤5来走,那后续的嵌套内部查询都会出现脏读的情况。

解决这种情况,可以禁用一级缓存,flushCache="true",或者在Mybatis的查询中不使用嵌套查询。

以上就是Mybatis源码中一级缓存的介绍。