1. 问题起因
Java应用的某个功能里有个循环,每个循环中调用MyBatis的SQL来获取Oracle的序列Sequence,然后把序列值填充到实体中,调用jpa的save方法将实体保存到数据库。
取序列号的sql没啥特殊的:
1 | select seq_name.nextval from dual |
但实际保存到数据库的时候,发现所有循环保存的实体的序列值都相同。
2. 问题分析
首先排除了Oracle数据库的问题。从这篇stackoverflow的回答,可以看到Oracle的序列实现是考虑很周到的。并发/Oracle部署方式/回滚都不会使序列重复。那么疑点就落在了MyBatis上。
以MyBatis为关键字,从网上能找到一些解答,例如如下这篇回答:
1 | (该问题的原因)是因为其每次都会去取一级缓存中的值。 |
原因找到的没错:的确是MyBatis的一级缓存导致的。但你可能会疑惑:这4点都要做么?还是只做其中1-2点就有用了?从这篇解答的表述和后面的分析很容易就可以看到,作者对Mybatis的理解还是一团浆糊,并没有真正搞懂原理。
所以我们先从原理开始讲。
3. MyBatis缓存的原理
一级缓存与二级缓存
正如大多数持久层框架一样,MyBatis 同样提供了一级缓存和二级缓存。
一级缓存还有个别名Local Cache(本地缓存)。我觉得这个比较容易引起歧义,好像二级缓存就不放在本地了一样。事实上不管一级缓存还是二级缓存都是默认以HashMap的形式保存在本地内存Heap里的(虽然二级缓存也可以通过扩展保存到Memcached上)。
一级缓存必定开启不能关闭,二级缓存默认不开启。
一级缓存和二级缓存的差别在于作用域:
- 一级缓存默认基于SqlSession,可配置为基于Statement
- 二级缓存基于namespace,即可以跨SqlSession
如果开启二级缓存的话,先从二级缓存中查询,没有命中的话再查询一级缓存。流程图可以参考下图:
缓存命中的机制判断
每个查询都会生成一个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 Spring的组件关系图如下:
MyBatis Spring中增加的SqlSessionTemplate是MyBatis Spring的核心。这个类负责管理MyBatis的SqlSession。从源代码中可以看到,如果如果Sql Session是在一个事务中,MyBatis不会急着提交。
1 | SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory, |
而getSqlSession方法中有Spring的TransactionSynchronizationManager参与,增加一次sessionHolder的引用计数。
1 | SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory); |
执行完关闭sqlsession的方法也会判断如果holder的引用计数减光了,那么就直接关闭session;如果还有引用计数,就只是减少引用计数,不关闭session。
1 | public static void closeSqlSession(SqlSession session, SqlSessionFactory sessionFactory) { |
当我们在Spring Bean的public方法上加了@Transactional注解,那么就会判断会话在事务中。
所以在没有加事务的情况下,Mapper每次请求数据库,都会创建一个SqlSession,并在请求结束后关闭该SqlSession;如果加了事务,则会在事务里复用同一个SqlSession。(这里简化了一点逻辑,事实上还会进行executionType等的判断)
之后做验证的时候也可以发现,如果我们循环获取序列的service方法上没有加@Transactional,那么每次获取的序列号是不同的,log里打印了多次请求的sql;而加上@Transactional,那么获取的序列号就是相同的,log里也只打印了一次请求sql。
缓存存储与刷新的机制
缓存最底层的实现类是PerpetualCache类(Perpetual的意思是“永恒的”)。可以通过源代码看到它的实现非常简单:
1 | public class PerpetualCache implements Cache { |
通过装饰器模式,在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 | flushCache 将其设置为 true 后,只要语句被调用,都会导致本地缓存和二级缓存被清空,默认值:false。 |
我们不使用二级缓存,所以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 | 事务