背景
在一个平平无奇的下午,发现线上有个出现了一条死信息,排查日志发现原因是,消息中某个对象的状态为A,但是查询数据发现状态已经变成了B,意识到不对,赶紧查看代码,结果发现一个很奇怪的现象,代码的实现逻辑如下:
- 对象加锁
- 查询数据库对象状态,如果为A,发送消息。
这就奇怪了,明明实现逻辑中对象加锁了,并且查询状态为A了,对象状态的变更都需要获取锁才能变更,感觉像是锁的问题,一段排查下发现不是锁的问题,变更为状态B是也有加锁,并且是在问题逻辑时间之前已经完成变更状态并且解锁了。
后面通过公司trace全链路打点排查,发现竟然在步骤1.对象加锁后,并没有执行查询数据库的sql打点,顿感奇怪,询问老司机一起排查后,发现竟然是Mybatis一级缓存导致。
问题原因
下面直接先说明下为什么Mybatis一级缓存或导致上述问题,在执行加锁逻辑之前,提前查询数据判断了对象状态是否为A, 代码实现可以简单概括如下:
java
数据库:mysql 事务隔离级别设置为读已提交
ORM框架:Mybatis
@Transactional(...//事务隔离级别使用数据库默认, 数据库事务隔离级别为读已提交)
public void doSomething() {
// 第一次查询数据库获取对象
Object obj1 = mapper.queryById(id);
....逻辑省略
lock(obj1)
....逻辑省略
// 第二次查询数据库获取对象, sql与第一次查询完全一致
Object obj2 = mapper.queryById(id);
// 校验状态是否为A, 如果为A做一些业务处理
if (statusIsA(obj2)) {
// 执行业务逻辑
....
}
}
第一次查询obj1时对象的状态为A, 在加锁之前,其他业务逻辑中将对象的状态修改成为了B, 然后再第二次查询对象obj2时,触发了mybatis缓存机制,并未实际查询数据库,而是直接从缓存中取值,也就是前面第一次查询返回的对象obj, 没错obj1与obj2是完全相同的对象,obj1 == obj2, 这就导致了statusIsA(obj2)判断状态是否仍未A时,返回了true,而状态实际已经修改了成了B,导致接下来的处理逻辑与预期不一致。
说实话之前并没有了解到过mybatis缓存相关的知识,因此立即查阅资料学习了下mybatis缓存相关的知识。
Mybatis缓存机制
MyBatis 提供了两级缓存机制来提高应用程序的性能:
- 一级缓存(Local Cache)
- 二级缓存(Second Level Cache)
一级缓存(Local Cache)
- 作用范围 :同一
SqlSession
内
- 缓存位置 :
SqlSession
对象内部
- 缓存行为:
-
- 读取:当您执行查询时,MyBatis 会先检查一级缓存是否存在结果集。如果存在,直接返回缓存结果。
-
- 写入 :当您执行增删改操作(
insert
、update
、delete
)时,MyBatis 会清空当前SqlSession
的一级缓存。
- 写入 :当您执行增删改操作(
-
- 生命周期 :一级缓存的生命周期与
SqlSession
相同。当SqlSession
关闭或提交时,一级缓存被清空。
- 生命周期 :一级缓存的生命周期与
- 配置:一级缓存是默认启用的,无需额外配置。
二级缓存(Second Level Cache)
- 作用范围 :同一
Mapper
接口或同一namespace
内的所有SqlSession
- 缓存位置 :
Mapper
接口或namespace
级别
- 缓存行为:
-
- 读取:当一级缓存未命中时,MyBatis 会检查二级缓存是否存在结果集。如果存在,直接返回缓存结果。
-
- 写入 :当
SqlSession
提交或关闭时,如果配置了二级缓存,MyBatis 会将结果写入二级缓存。
- 写入 :当
-
- 生命周期:二级缓存的生命周期取决于配置,可以是整个应用程序的生命周期,也可以根据配置定期清空。
- 配置 :需要在
mapper
配置文件中显式启用二级缓存。
MyBatis 查询时 SqlSession
的创建和复用机制
在 MyBatis 中,SqlSession
是一个关键对象,负责执行 SQL 查询和操作数据库。了解 SqlSession
的创建和复用机制,有助于您更好地使用 MyBatis。下面我们来探讨 MyBatis 查询时 SqlSession
的创建和复用规则。
SqlSession 的作用
- 执行 SQL :
SqlSession
负责将 MyBatis 配置文件(如 XML 或注解)中定义的 SQL 语句发送到数据库执行。
- 管理事务 :
SqlSession
提供了对数据库事务的管理,包括提交、回滚等。
- 缓存管理 :
SqlSession
也参与 MyBatis 的缓存机制,包括一级缓存(本地缓存)和二级缓存(全局缓存)。
SqlSession 的创建和复用规则
1. 默认行为:每次查询新建 SqlSession
- 无显式事务管理 : 如果您没有显式地管理事务(如使用
@Transactional
注解或手动调用sqlSession.commit()
/sqlSession.rollback()
),MyBatis 会为每个查询创建一个新的SqlSession
。
- 示例:
java
// 每次调用 selectUser() 都会新建一个 SqlSession
User user = sqlMapper.selectUser(id);
2. 显式事务管理:复用 SqlSession
- 使用
@Transactional
注解: 当您使用 Spring 的@Transactional
注解(或其他事务管理框架)包裹您的服务方法时,MyBatis 会在事务范围内复用SqlSession
。
- 手动事务管理: 如果您手动调用
sqlSession.commit()
或sqlSession.rollback()
来管理事务,同样会在事务范围内复用SqlSession
。
3. 线程绑定(ThreadLocal)
- MyBatis 内部机制 : MyBatis 使用 ThreadLocal 机制来存储当前线程的
SqlSession
。这意味着,如果在同一线程内执行多个查询,MyBatis 会自动复用SqlSession
,除非您显式地关闭或提交了事务。
4. 手动关闭 SqlSession
- 显式关闭 : 如果您手动调用
sqlSession.close()
,则当前SqlSession
将被关闭,不再被复用。
问题反思与总结
通过对mybitis缓存机制学习了解到,mybatisi的1级缓存其实是默认开启了的,并且只有在使用相同SqlSession并且查询sql完全相同是才会跳过查询数据库,直接使用缓存。并且一般情况下,通过mybatis mapper查询数据库,每次查询都会新建sqlSession, 因此一般情况下重复查询多次数据库其实没有使用到mybatis1级缓存。而开启事务时,在事务范围内才会复用SqlSession,此时则可能会使用缓存。
而我遇到的问题正式如此,开了个大事务,然后事务内多次查询,第二次查询开始返回的为缓存结果,这里其实可以通过减小事务的范围来解决,血的教训阿,以后再不随便框个大事务了。
额外提一点:只要使用了@Transactional注解,就有可能复用sqlSession,本人这个case中其实事务注解配置的transactionManger并非查询sql访问数据库的事务管理器,但是查询时也复用同一个sqlSession.