Mybatis一级缓存问题

引言

很多 Java 后端服务都选择使用 MyBatis 作为它们的 ORM 框架,帮助简化开发。但是基本上大家都不会太关心 MyBatis 的缓存机制,基本都在使用 MyBatis 缓存的默认配置,在不了解 MyBatis 缓存机制的情况下进行开发,可能就会发生一些意料之外的事情。下面帮助大家了解 MyBatis 一级缓存和二级缓存的机制,以及如何避免 MyBatis 缓存中的坑。

缓存

众所周知数据库的请求中大部分都是查询请求,使用缓存可以大大减少数据库的压力,提高系统的性能,但是如果使用不当可能就会产生数据一致性的问题。

一级缓存

MyBatis 的一级缓存又被叫做本地缓存,一级缓存默认作用在 Session 级别,并且不能被关闭,只能修改一级缓存的作用域。

一级缓存的作用域有两种,分别是 SESSIONSTATEMENT ,可以通过修改配置项 localCacheScope 来设置,默认的 SESSION 会缓存一个会话中执行的所有查询,用来加速重复的嵌套查询,当在这个会话中执行更新操作时则会清除缓存。如果设置成 STATEMENT 则值作用在执行语句中,当语句执行完成就会清除缓存。

问题

下面看一下两个一级缓存导致的问题:

问题 1

localCacheScope 被设置为 SESSION 的时候,并且当前服务有多个实例时就可能会导致查询到的数据不一致。

ini 复制代码
// 会话 1
SqlSession session1 = factory.openSession(true);
// 会话 2
SqlSession session2 = factory.openSession(true);
​
TemplateInfoMapper templateInfoMapper1 = session1.getMapper(TemplateInfoMapper.class);
TemplateInfoMapper templateInfoMapper2 = session2.getMapper(TemplateInfoMapper.class);
​
TemplateInfo a1 = templateInfoMapper1.get(1L);
TemplateInfo a2 = templateInfoMapper2.get(1L);
// 数据一致
Assert.assertEquals(a1.getTemplateName(), a2.getTemplateName());
​
a1.setTemplateName("a1");
templateInfoMapper1.updateByPrimaryKey(a1);
​
TemplateInfo b1 = templateInfoMapper1.get(1L);
TemplateInfo b2 = templateInfoMapper2.get(1L);
// 数据不一致
Assert.assertEquals(b1.getTemplateName(), b2.getTemplateName());

上面这段代码模拟了多实例情况下数据不一致的场景。

问题 2

这个问题也是在被配置为 SESSION 时导致的问题,不过这个问题是在同一个会话当中发生的,下面我们看一段代码:

ini 复制代码
SqlSession session = factory.openSession(true);
TemplateInfoMapper templateInfoMapper = session.getMapper(TemplateInfoMapper.class);
TemplateInfo a1 = templateInfoMapper.get(1L);
a1.setTemplateName("a1");
TemplateInfo a2 = templateInfoMapper.get(1L);
a2.setTemplateName("a2");
// 数据一致
Assert.assertEquals(a1.getTemplateName(), a2.getTemplateName());
session.close();

上面这段代码我们分别获得结果集 a1 和 a2,并且分别对 a1 和 a2 结果集中的 templateName 进行了修改,但是最后比较时 a1 和 a2 的 templateName 反而数据是一致的。

原因

问题 1 中开启了两个会话(在分布式环境下,一个服务有多个实例很常见,这里可以把会话看作实例),每个会话都会有自己的一级缓存,也就是两个会话都会缓存 ID 等于 1 的数据到一级缓存当中,但是当 会话 1 去更新了 ID 为 1 的数据时,会话 1 中的一级缓存会被清理,会话 1 再去查询 ID 为 1 的数据时就会查询数据库,但是会话 2 中去查询 ID 为 1 的数据时还是会命中缓存,所以就会导致两个数据不一致的问题。

问题 2 在同一个会话当中第一次查询 ID 为 1 的数据时会把查询到的结果集对象放到一级缓存当中,当第二次查询 ID 为 1 的数据时会把缓存的对象直接返回,因为 MyBatis 的一级缓存使用的是 Java 的 HashMap 缓存数据这里返回的都是对象的引用地址,这就导致结果集 a1 和 a2 其实都指向了同一个对象,所以不管 a1 还是 a2 修改了对象的字段都会导致双方的数据被修改。

原理

这里我们先简单了解一下使用 MyBatis 操作数据库时大概的一个流程:

基于上面这个流程图可以看出 SqlSession 用于创建和连接数据库 Executor 用于执行 SQL,那 MyBatis 的一级缓存在哪里呢?下面我们看一下类图和源码。

java 复制代码
​
public class DefaultSqlSession implements SqlSession {
​
  private final Configuration configuration;
  private final Executor executor;
  private final boolean autoCommit;
  private boolean dirty;
  private List<Cursor<?>> cursorList;
​
  public DefaultSqlSession(Configuration configuration, Executor executor, boolean autoCommit) {
    this.configuration = configuration;
    this.executor = executor;
    this.dirty = false;
    this.autoCommit = autoCommit;
  }
​
  public DefaultSqlSession(Configuration configuration, Executor executor) {
    this(configuration, executor, false);
  }
​
  private <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
    try {
      MappedStatement ms = configuration.getMappedStatement(statement);
      dirty |= ms.isDirtySelect();
      return executor.query(ms, wrapCollection(parameter), rowBounds, handler);
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }
​
  @Override
  public void clearCache() {
    executor.clearLocalCache();
  }
}
ini 复制代码
public abstract class BaseExecutor implements Executor {
​
  private static final Log log = LogFactory.getLog(BaseExecutor.class);
  protected Transaction transaction;
  protected Executor wrapper;
  protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
  protected PerpetualCache localCache;
  protected PerpetualCache localOutputParameterCache;
  protected Configuration configuration;
  protected int queryStack;
  private boolean closed;
​
  protected BaseExecutor(Configuration configuration, Transaction transaction) {
    this.transaction = transaction;
    this.deferredLoads = new ConcurrentLinkedQueue<>();
    this.localCache = new PerpetualCache("LocalCache");
    this.localOutputParameterCache = new PerpetualCache("LocalOutputParameterCache");
    this.closed = false;
    this.configuration = configuration;
    this.wrapper = this;
  }
​
  @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.");
    }
    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();
      }
      deferredLoads.clear();
      // 当 localCacheScope 设置为 statement 时,查询后会清除本地缓存
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        clearLocalCache();
      }
    }
    return list;
  }
​
  @Override
  public void clearLocalCache() {
    // 清除本地缓存
    if (!closed) {
      localCache.clear();
      localOutputParameterCache.clear();
    }
  }
}
typescript 复制代码
public class PerpetualCache implements Cache {
​
  private final String id;
  // 使用 Map 来缓存数据。
  private final Map<Object, Object> cache = new HashMap<>();
​
  public PerpetualCache(String id) {
    this.id = id;
  }
​
  @Override
  public String getId() {
    return id;
  }
​
  @Override
  public int getSize() {
    return cache.size();
  }
​
  @Override
  public void putObject(Object key, Object value) {
    cache.put(key, value);
  }
​
  @Override
  public Object getObject(Object key) {
    return cache.get(key);
  }
​
  @Override
  public Object removeObject(Object key) {
    return cache.remove(key);
  }
​
  @Override
  public void clear() {
    cache.clear();
  }
​
  @Override
  public boolean equals(Object o) {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    if (this == o) {
      return true;
    }
    if (!(o instanceof Cache)) {
      return false;
    }
​
    Cache otherCache = (Cache) o;
    return getId().equals(otherCache.getId());
  }
​
  @Override
  public int hashCode() {
    if (getId() == null) {
      throw new CacheException("Cache instances require an ID.");
    }
    return getId().hashCode();
  }
​
}

DefaultSqlSessionBaseExecutor 分别是 SqlSessionExecutor 的默认实现。PerpetualCache 则提供了基础的缓存功能,使用 HashMap 存放缓存对象,MyBatis 给 PerpetualCache 提供了多种装饰器类用于增强 PerpetualCache 的功能,但是一级缓存都不会用到,这里就先不介绍了。

通过这个类图我们可以了解到 SqlSession 会使用 Executor 去执行 SQL,而 Executor 中存在 localCache 对象,通过源码可以了解到 Executor 会先尝试从缓存中获取,如果获取不到才会查询数据库。

通过上面这个流程图,我们可以了解到在同一个 SqlSession 下相同的查询只有第一次会查询数据库,后续的重复查询或者嵌套查询都会使用缓存。但是当 localCacheScope 设置为 STATEMENT 时在返回查询结果前反而会去清空缓存,到这里基本介绍完了 MyBatis 一级缓存的所有内容。

这里拓展一点 MyBatis 在 Spring 当中的情况,因为上面都是在说 SqlSession,但是在 Spring 当中使用 MyBatis 的时候大部分都是直接使用 Mapper 类或者使用 SqlSessionTemplate 来操作数据库,那在 Spring 中 SqlSession 去哪里了呢?其实在 Spring 当中 MyBatis 的 SqlSession 基本和 Spring 的事务进行了绑定,下面看一下基本的流程。

总结

最后我们了解一下如何避免一级缓存的坑:

  1. 最简单的办法,把 localCacheScope 设置为 STATEMENT 这样每次执行完查询后都会清除缓存,基本上就是把一级缓存关闭了,就不会导致上述的两个问题了。分布式应用推荐都把 localCacheScope 设置为 STATEMENT。
  2. 如果只是要避免问题 2 也可以简单的把查询的数据进行深拷贝,避免因为浅拷贝的问题造成数据异常的问题。但是最好还是在写代码的时候注意不要直接修改任何查询到的数据,因为大部分的本地缓存框架都会使用 HashMap 进行数据缓存,这样的缓存都会存在浅拷贝的问题。

参考

聊聊 MyBatis 缓存机制

MyBatis 3 源码深度解析

推荐阅读

MySQL死锁浅析

探索Taro:跨平台开发的实践与原理

spring如何使用三级缓存解决循环依赖

化繁为简:Flutter组件依赖可视化

dubbo的SPI 机制与运用实现

招贤纳士

政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。

如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊......如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

相关推荐
乘风御浪云帆之上2 小时前
数据库操作【JDBC & HIbernate & Mybatis】
数据库·mybatis·jdbc·hibernate
向阳121818 小时前
mybatis 动态 SQL
数据库·sql·mybatis
新手小袁_J18 小时前
JDK11下载安装和配置超详细过程
java·spring cloud·jdk·maven·mybatis·jdk11
xlsw_1 天前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
cmdch20171 天前
Mybatis加密解密查询操作(sql前),where要传入加密后的字段时遇到的问题
数据库·sql·mybatis
秋恬意2 天前
什么是MyBatis
mybatis
CodeChampion2 天前
60.基于SSM的个人网站的设计与实现(项目 + 论文)
java·vue.js·mysql·spring·elementui·node.js·mybatis
ZWZhangYu2 天前
【MyBatis源码分析】使用 Java 动态代理,实现一个简单的插件机制
java·python·mybatis
程序员大金3 天前
基于SSM+Vue的个性化旅游推荐系统
前端·vue.js·mysql·java-ee·tomcat·mybatis·旅游
奔跑草-3 天前
【服务器】MyBatis是如何在java中使用并进行分页的?
java·服务器·mybatis