重复的Sequence序列和MyBatis缓存

1. 问题起因

Java应用的某个功能里有个循环,每个循环中调用MyBatis的SQL来获取Oracle的序列Sequence,然后把序列值填充到实体中,调用jpa的save方法将实体保存到数据库。
取序列号的sql没啥特殊的:

1
select seq_name.nextval from dual

但实际保存到数据库的时候,发现所有循环保存的实体的序列值都相同。

2. 问题分析

首先排除了Oracle数据库的问题。从这篇stackoverflow的回答,可以看到Oracle的序列实现是考虑很周到的。并发/Oracle部署方式/回滚都不会使序列重复。那么疑点就落在了MyBatis上。

以MyBatis为关键字,从网上能找到一些解答,例如如下这篇回答:

1
2
3
4
5
(该问题的原因)是因为其每次都会去取一级缓存中的值。
1.拿出@Transactional,就不会出错。
2.加上useCache="false" flushCache="true",不保存在二级缓存中,并清空缓存
3.mybatis.configuration.localCacheScope=STATEMENT,修改一级缓存的作用域
4.mybatis.configuration.cacheEnabled = false,禁用一级和二级缓存

原因找到的没错:的确是MyBatis的一级缓存导致的。但你可能会疑惑:这4点都要做么?还是只做其中1-2点就有用了?从这篇解答的表述和后面的分析很容易就可以看到,作者对Mybatis的理解还是一团浆糊,并没有真正搞懂原理。
所以我们先从原理开始讲。

3. MyBatis缓存的原理

一级缓存与二级缓存

正如大多数持久层框架一样,MyBatis 同样提供了一级缓存和二级缓存。
一级缓存还有个别名Local Cache(本地缓存)。我觉得这个比较容易引起歧义,好像二级缓存就不放在本地了一样。事实上不管一级缓存还是二级缓存都是默认以HashMap的形式保存在本地内存Heap里的(虽然二级缓存也可以通过扩展保存到Memcached上)。
一级缓存必定开启不能关闭,二级缓存默认不开启。
一级缓存和二级缓存的差别在于作用域:

  • 一级缓存默认基于SqlSession,可配置为基于Statement
  • 二级缓存基于namespace,即可以跨SqlSession

如果开启二级缓存的话,先从二级缓存中查询,没有命中的话再查询一级缓存。流程图可以参考下图:
mybatis缓存流程

缓存命中的机制判断

每个查询都会生成一个CacheKey对象。CacheKey对象包含了MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数。
全部匹配的Cache才会走缓存。

MyBatis Spring中缓存与事务的关系

上面那段中的Statement和namespace没有什么特别需要解释的,看Mapper文件就可以了解。关于SqlSession可能需要解释一下。
我们项目中引用的是mybatis-spring-boot-starter的1.3.2版本。从源代码可以看到是基于mybatis 3.4.6和mybatis-spring 1.3.2版本。除了上述介绍的MyBatis 3的原理之外,还允许MyBatis参与到Spring的事务管理中。
原始的MyBatis 3的组件关系图如下:
mybatis 3组件关系
MyBatis Spring的组件关系图如下:
mybatis Spring组件关系

MyBatis Spring中增加的SqlSessionTemplate是MyBatis Spring的核心。这个类负责管理MyBatis的SqlSession。从源代码中可以看到,如果如果Sql Session是在一个事务中,MyBatis不会急着提交。

1
2
3
4
5
6
7
8
9
10
11
SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
try {
Object result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
return result;
}

而getSqlSession方法中有Spring的TransactionSynchronizationManager参与,增加一次sessionHolder的引用计数。

1
2
3
4
5
6
SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);

SqlSession session = sessionHolder(executorType, holder);
if (session != null) {
return session;
}

执行完关闭sqlsession的方法也会判断如果holder的引用计数减光了,那么就直接关闭session;如果还有引用计数,就只是减少引用计数,不关闭session。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void closeSqlSession(SqlSession session, SqlSessionFactory sessionFactory) {
notNull(session, NO_SQL_SESSION_SPECIFIED);
notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);

SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
if ((holder != null) && (holder.getSqlSession() == session)) {
LOGGER.debug(() -> "Releasing transactional SqlSession [" + session + "]");
holder.released();
} else {
LOGGER.debug(() -> "Closing non transactional SqlSession [" + session + "]");
session.close();
}
}

当我们在Spring Bean的public方法上加了@Transactional注解,那么就会判断会话在事务中。
所以在没有加事务的情况下,Mapper每次请求数据库,都会创建一个SqlSession,并在请求结束后关闭该SqlSession;如果加了事务,则会在事务里复用同一个SqlSession。(这里简化了一点逻辑,事实上还会进行executionType等的判断)
之后做验证的时候也可以发现,如果我们循环获取序列的service方法上没有加@Transactional,那么每次获取的序列号是不同的,log里打印了多次请求的sql;而加上@Transactional,那么获取的序列号就是相同的,log里也只打印了一次请求sql。

缓存存储与刷新的机制

缓存最底层的实现类是PerpetualCache类(Perpetual的意思是“永恒的”)。可以通过源代码看到它的实现非常简单:

1
2
3
public class PerpetualCache implements Cache {
private final String id;
private Map<Object, Object> cache = new HashMap<>();

通过装饰器模式,在PerpetualCache类的基础上增加了日志、序列化、线程安全、清理等功能。装饰链如下:
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache
缓存默认最大容量1024(参见源代码),使用LRU算法自动清理。
在缓存作用域上调用任何修改语句(insert/update/delete)都会清空缓存,比较简单粗暴。

4. MyBatis缓存的特点

MyBatis缓存的优点

默认配置下的MyBatis缓存的作用比较有限,只会对同一事务中多次执行的同一SQL有优化效果。
我们现在框架中的获取当前用户/获取权限/获取数据字典等经常被调用的方法,如果没有使用Spring Cache的话,至少MyBatis会避免一个方法查询100次数据库。

MyBatis缓存的坑

还是那句听起来像玩笑话的名言:“计算机科学只存在两个难题:缓存失效和命名。”
如果将MyBatis的一级缓存配置改为Statement级别,或开启MyBatis的二级缓存,问题就来了。

问题1:占用的内存大小
如果我们在进行大数据量查询的时候没有加上分页条件,那么庞大的结果集会占用了大量内存,而且无法及时释放。

问题2:缓存失效的触发
使statement或namespace里的缓存失效,只有两种方法:

  • 触发LRU算法
  • 在同一个应用上执行insert/update/delete
  • 重启应用
    如果我们使用了分布式部署,在某一个节点上更新了数据,其他节点是不会得知数据有变更。

问题3:MyBatis+JPA的脏数据隐患
我们现在使用的规范是单表情况下使用JPA进行CRUD,多表联合查询使用MyBatis。
即使使用的是session级别的一级缓存,如果在同一个方法里包含了“MyBatis查询+JPA更新+MyBatis查询”三个步骤的逻辑,那么最后一个查询得到的就是更新前的结果。
当然这个问题可以靠良好的代码规范部分解决,即“一个方法做一件事”。换句话说,不在一个方法里即做更新又做查询。

即使不使用JPA,全部用MyBatis来做CRUD,使用二级缓存也会有隐患。如果某个多表查询使用到的某几张表不在同一个namespace下,那么当这些表里的数据进行了修改,也会引发脏数据问题。

5. 我们场景下最终采取的方案

从上述MyBatis的隐患可以看到,在我们分布式部署+使用JPA做单表CRUD的技术方案下,不适用开启statement级别的一级缓存,也不适宜开启二级缓存。

现在我们回过来看之前的那个解答:
拿出@Transactional,就不会出错:
【不完全对】
这样的确可以解决问题,但不采用事务会引入其他风险。倒洗澡水连带着把孩子也倒掉了。

加上useCache=”false” flushCache=”true”,不保存在二级缓存中,并清空缓存
【不完全对】
这两个参数的含义可以看官方文档

1
2
flushCache	将其设置为 true 后,只要语句被调用,都会导致本地缓存和二级缓存被清空,默认值:false。
useCache 将其设置为 true 后,将会导致本条语句的结果被二级缓存缓存起来,默认值:对 select 元素为 true。

我们不使用二级缓存,所以useCache可以不用加。只需要加flushCache=”true”就可以了。

mybatis.configuration.localCacheScope=STATEMENT,修改一级缓存的作用域
【不对】
如果将一级缓存改为statement级别,获取sequence的语句还是会命中缓存,问题依然会存在。

mybatis.configuration.cacheEnabled = false,禁用一级和二级缓存
【不对】
源代码可以看到,cacheEnabled只控制CachingExecutor,即只能关闭二级缓存。而二级缓存本来就是默认关闭的。所以这么改毫无意义。

结论

所以对于我们的场景的最终结论是:在获取序列的SQL语句的XML上,增加flushCache=”true”。

参考资料

MyBatis组件关系的架构图就是从这个博客里拿的,感谢日本同僚。虽然也只看得懂图。。。
データベースアクセス(MyBatis3編)

作者写了很多的demo,结合源代码把MyBatis缓存讲得很透彻。
聊聊MyBatis缓存机制 - 美团技术团队

MyBatis的官方文档
mybatis – MyBatis 3 | XML 映射文件
mybatis-spring – MyBatis-Spring | 事务

本文永久链接 [ https://galaxyyao.github.io/2019/05/13/Java-重复的Sequence序列和MyBatis缓存/ ]