记录一次mybatis缓存和事务传播行为导致ut挂的排查过程
rhea项目有两个ut一直都是挂的,之前也经过几个同事排查过,但是都没有找到解决办法,慢慢的这个问题就搁置了。因为之前负责rhea项目的同事离职,我临时接手了这个项目,刚好最近来了一个新同事在做新的功能开发的时候遇到了这个问题,于是我就接了一个锅,最终证明这个锅很好玩。
rhea是一个典型的使用mybatis orm的springboot项目,我们使用h2内存数据库做单元测试,每个单元测试都在一个事务内,都由Transactional进行注解。testGetBGWechatAccountByOpenid这个ut的核心调用链如下
调用深度较深,并且有多处使用到了事务,其中BasePlatformUserService.insert
这个方法用到了Propagation.REQUIRES_NEW
,也就是图中最右边的这个链路中最终插入了一个PlatformUser
ut代码如下:
@Test
@Transactional
public void testGetBGWechatAccountByOpenid() {
OpenidRo openidRo = OpenidRo.builder()
.openid(openidZmall)
.appId(appIdZmall)
.unionid(unionid)
.openAppId(openAppId)
.platformCategory(PlatformCategoriesEnum.Zmall.getValue())
.service(ServicesEnum.Server.getValue())
.serviceBusinessGroupId(serviceBusinessGroup2)
.alived(false)
.build();
RheaAccount rheaAccount = platformUserService.getAccountByOpenId(openidRo);
Assert.assertEquals(rheaAccount.getPhone(), phone2);
RheaPlatformUser platformUser = platformUserMapper.getByOpenIdAndBG(
openidZmall, appIdZmall, serviceBusinessGroup2, ServicesEnum.Server.getValue());
Assert.assertEquals(rheaAccount.getId(), platformUser.getAccountId());
}
但是在ut里面使用getByOpenIdAndBG查询platformUser却是null导致最终platformUser.getAccountId()这个方法抛出了NPE。
排查这个问题会用到以下两个知识点
Springboot的Transactional的实现包含两部分,一个部分是事务传播行为,一个部分是数据库隔离级别,代码如下:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
@AliasFor("transactionManager")
String value() default "";
@AliasFor("value")
String transactionManager() default "";
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
...
数据库隔离级别默认是Isolation.DEFAULT,也就是使用数据库自身的隔离级别,Mysql的默认隔离级别是REPEATABLE_READ可重复读,Oracle的默认事务隔离级别是读已提交READ_COMMITTED。具体的隔离级别不在此讨论。我们需要关注事务的传播行为,也就是Propagation。Propagation实现如下:
public enum Propagation {
REQUIRED(0),
SUPPORTS(1),
MANDATORY(2),
REQUIRES_NEW(3),
NOT_SUPPORTED(4),
NEVER(5),
NESTED(6);
private final int value;
private Propagation(int value) {
this.value = value;
}
public int value() {
return this.value;
}
}
这里我们只是用到了REQUIRED和REQUIRED_NEW,REQUIRED也是默认的传播行为,这两个传播行为的区别在于:
REQUIRES_NEW
事务属性REQUIRES_NEW
属性,审计记录就会连同尝试执行的交易一起回滚。使用 REQUIRES_NEW
属性可以确保不管初始事务的结果如何,审计数据都会被保存Mybatis-config.xml中可以配置mybatis的本地缓存范围localCacheScope。
mybatis官网解释:MyBatis 利用本地缓存机制(Local Cache)防止循环引用(circular references)和加速重复嵌套查询。 默认值为 SESSION,这种情况下会缓存一个会话中执行的所有查询。 若设置值为 STATEMENT,本地会话仅用在语句执行上,对相同 SqlSession 的不同调用将不会共享数据。
用白话解释:
在新版本的mysql中数据库自身有自己的缓存,我们并不需要Mybatis的缓存,而且Mybatis不是最底层的缓存,因为多个Session的存在,往往导致一些问题。
修改mybatis的默认缓存范围可以在Mybatis-config.xml中加入以下配置:
<!--设置缓存作用域,它决定是否使用mybatis的缓存。 系统默认值是SESSION,为了不使用mybatis缓存,设置为STATEMENT -->
<setting name="localCacheScope" value="STATEMENT"/>
使用以下配置可以打印出mybatis执行时的操作log和sql语句:
<setting name="logImpl" value="STDOUT_LOGGING" />
开启一个新的事务并且在新的事务中首次执行mybatis操作时会开启新的mybatis Session,因此在REQUIRES_NEW
中执行mybatis操作一定会开启新的Session
REQUIRES_NEW
的方法改为默认的REQUIRED
,发现能查询到platformUserREQUIRES_NEW
开启的新事务中开启的新Session插入的记录并没有打破老Session缓存的查询结果,因此在老Session中使用相同的查询语句是查询不到真实记录的具体的debug日志如下:
红框中的就是最外层的事务开启的老session,绿色框是中间REQUIRES_NEW
新事务中开启的新Session。所以对于红框这个Session而言,它并不知道已经发生了DML操作,因此在后续继续查询时会使用最开始的查询结果,也就是null。
这种问题通常发生在getOrCreate操作中。
去掉Mybatis层面的缓存
<!--设置缓存作用域,它决定是否使用mybatis的缓存。 系统默认值是SESSION,为了不使用mybatis缓存,设置为STATEMENT -->
<setting name="localCacheScope" value="STATEMENT"/>
解决这个问题对于REQUIRES_NEW
这个传播行为的理解就更深刻了。
参考: