|
Mybatis一级缓存问题
407 MyBatis 缓存问题引言很多 Java 后端服务都选择使用 MyBatis 作为它们的 ORM 框架,帮助简化开发。但是基本上大家都不会太关心 MyBatis 的缓存机制,基本都在使用 MyBatis 缓存的默认配置,在不了解 MyBatis 缓存机制的情况下进行开发,可能就会发生一些意料之外的事情。下面帮助大家了解 MyBatis 一级缓存和二级缓存的机制,以及如何避免 MyBatis 缓存中的坑。缓存众所周知数据库的请求中大部分都是查询请求,使用缓存可以大大减少数据库的压力,提高系统的性能,但是如果使用不当可能就会产生数据一致性的问题。一级缓存MyBatis 的一级缓存又被叫做本地缓存,一级缓存默认作用在 Session 级别,并且不能被关闭,只能修改一级缓存的作用域。一级缓存的作用域有两种,分别是 SESSION 和 STATEMENT ,可以通过修改配置项 localCacheScope 来设置,默认的 SESSION 会缓存一个会话中执行的所有查询,用来加速重复的嵌套查询,当在这个会话中执行更新操作时则会清除缓存。如果设置成 STATEMENT 则值作用在执行语句中,当语句执行完成就会清除缓存。问题下面看一下两个一级缓存导致的问题:问题 1当 localCacheScope 被设置为 SESSION 的时候,并且当前服务有多个实例时就可能会导致查询到的数据不一致。// 会话 1SqlSession session1 = factory.openSession(true);// 会话 2SqlSession 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 时导致的问题,不过这个问题是在同一个会话当中发生的,下面我们看一段代码: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 的一级缓存在哪里呢?下面我们看一下类图和源码。public class DefaultSqlSession implements SqlSession { private final Configuration configuration; private final Executor executor; private final boolean autoCommit; private boolean dirty; private List> 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 List 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(); }}public abstract class BaseExecutor implements Executor { private static final Log log = LogFactory.getLog(BaseExecutor.class); protected Transaction transaction; protected Executor wrapper; protected ConcurrentLinkedQueue deferredLoads; protected erpetualCache localCache; protected erpetualCache 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 erpetualCache("LocalCache"); this.localOutputParameterCache = new erpetualCache("LocalOutputParameterCache"); this.closed = false; this.configuration = configuration; this.wrapper = this; } @SuppressWarnings("unchecked") @Override public List 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 list; try { queryStack++; // 使用本地缓存中的数据。 list = resultHandler == null ? (List) 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(); } }}public class erpetualCache implements Cache { private final String id; // 使用 Map 来缓存数据。 private final Map cache = new HashMap(); public erpetualCache(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(); }}DefaultSqlSession 和 BaseExecutor 分别是 SqlSession 和 Executor 的默认实现。PerpetualCache 则提供了基础的缓存功能,使用 HashMap 存放缓存对象,MyBatis 给 erpetualCache 提供了多种装饰器类用于增强 erpetualCache 的功能,但是一级缓存都不会用到,这里就先不介绍了。通过这个类图我们可以了解到 SqlSession 会使用 Executor 去执行 SQL,而 Executor 中存在 localCache 对象,通过源码可以了解到 Executor 会先尝试从缓存中获取,如果获取不到才会查询数据库。通过上面这个流程图,我们可以了解到在同一个 SqlSession 下相同的查询只有第一次会查询数据库,后续的重复查询或者嵌套查询都会使用缓存。但是当 localCacheScope 设置为 STATEMENT 时在返回查询结果前反而会去清空缓存,到这里基本介绍完了 MyBatis 一级缓存的所有内容。这里拓展一点 MyBatis 在 Spring 当中的情况,因为上面都是在说 SqlSession,但是在 Spring 当中使用 MyBatis 的时候大部分都是直接使用 Mapper 类或者使用 SqlSessionTemplate 来操作数据库,那在 Spring 中 SqlSession 去哪里了呢?其实在 Spring 当中 MyBatis 的 SqlSession 基本和 Spring 的事务进行了绑定,下面看一下基本的流程。总结最后我们了解一下如何避免一级缓存的坑:最简单的办法,把 localCacheScope 设置为 STATEMENT 这样每次执行完查询后都会清除缓存,基本上就是把一级缓存关闭了,就不会导致上述的两个问题了。分布式应用推荐都把 localCacheScope 设置为 STATEMENT。如果只是要避免问题 2 也可以简单的把查询的数据进行深拷贝,避免因为浅拷贝的问题造成数据异常的问题。但是最好还是在写代码的时候注意不要直接修改任何查询到的数据,因为大部分的本地缓存框架都会使用 HashMap 进行数据缓存,这样的缓存都会存在浅拷贝的问题。参考聊聊 MyBatis 缓存机制MyBatis 3 源码深度解析
|
|