Mybatis一级缓存
首先是在双检锁的实现过程中,mybatis的两条一摸一样语句为什么第二条不执行。后续排查可能是mybatis的一级缓存导致不执行第二条SQL
解决办法是,通过更换mapper的接口名称使缓存的key不一致,就可以执行第二条SQL查询。
MyBatis 中的缓存机制是基于缓存键(Cache Key)的。每次查询的时候,MyBatis 会使用一个缓存键来查找是否已经有缓存的结果。缓存键的生成是根据以下几个因素:
- Mapped Statement 的 ID: 即 SQL 语句的唯一标识符。
- Statement Type: SQL 语句的类型,包括 SELECT、UPDATE、DELETE、INSERT。
- SQL 语句的参数: SQL 语句的参数值,这些参数值会被用于生成缓存键。
- RowBounds: 如果有使用 RowBounds 进行分页,RowBounds 也会用于生成缓存键。
- Environment ID: 数据源的环境 ID。
这些因素一起构成了一个缓存键,用于在缓存中查找或存储结果。当 MyBatis 执行一个查询时,它会首先生成一个缓存键,然后使用这个缓存键在缓存中查找是否已经有对应的结果。如果找到了缓存的结果,MyBatis 将直接返回缓存的结果,而不执行实际的 SQL 查询。
这个缓存键的生成方式确保了不同的查询会生成不同的缓存键,以防止查询结果被错误地从缓存中返回。
需要注意的是,一级缓存是 SqlSession 级别的,而二级缓存是 Mapper 级别的。一级缓存的生命周期是与 SqlSession 相关的,而二级缓存的生命周期是与整个应用程序的 Mapper 相关的。
需要注意的是,这与可重读的隔离级别是不一样的,可重复读是基于数据库的快照,而一级缓存仅仅是基于SQLSESSION的缓存。
SqlSession的坑
昨天线上出了一个问题,场景是有一个事务A,事务B,事务C。事务A包含事务B1和事务B2。采用@Transactional
默认的Propagation.REQUIRED
的隔离级别,当前隔离级别是如果存在事务就加入当前事务,若不存在事务,就新建事务执行。也就是说,
事务A以及事务B1以及事务B2都在一个大事务内。问题就来了,事务B2读取不到事务B1修改之后的值,直接就导致大事务A的数据产生错误。
事务B1以及事务B2的SQL为
sql
-- SQL1
SELECT id, serial_no FROM COM_NO_SERIAL WHERE rule_id = '23e344f6206441dc8d2ae23197759e65' AND rule_format = 'YSPF-240308%s' FOR UPDATE
-- SQL2
UPDATE COM_NO_SERIAL SET serial_no = 2 , update_time = '2024-03-08 09:43:49' WHERE id = '1d68eba2b64748cd953b16776ee0fb64'
按照数据库的事务来说,B1,B2都属于大事务A,A以及B1以及B2都在一个事务内,也同属一个SqlSession。B2肯定能获取到B1的更改。
由分析判断属于一个事务,那么在一个事务内依据数据库验证:隔离级别为:read committed
,事务提交方式修改为手动
sql
-- 在事务未提交的情况下依次执行一下SQL
SELECT id, serial_no FROM COM_NO_SERIAL WHERE rule_id = '23e344f6206441dc8d2ae23197759e65' AND rule_format = 'YSPF-240308%s' FOR UPDATE;
-- '1d68eba2b64748cd953b16776ee0fb64', 2
UPDATE COM_NO_SERIAL SET serial_no = 3 , update_time = '2024-03-08 09:43:50' WHERE id = '1d68eba2b64748cd953b16776ee0fb64'
SELECT id, serial_no FROM COM_NO_SERIAL WHERE rule_id = '23e344f6206441dc8d2ae23197759e65' AND rule_format = 'YSPF-240308%s' FOR UPDATE;
-- '1d68eba2b64748cd953b16776ee0fb64', 3
UPDATE COM_NO_SERIAL SET serial_no = 3 , update_time = '2024-03-08 09:43:50' WHERE id = '1d68eba2b64748cd953b16776ee0fb64'
由以上结构证明在同一事物内,绝对是可以获取到自身事务修改后的数据的,无论当前事务是否提交。那么为什么在代码中,事务B2为啥读取不到B1的数据呢?下面编写单元测试验证,采用循环调用去查询与更新操作
csharp
// 调用方
@Transactional(rollbackFor = Exception.class)
public void testNo() {
// 循环十次,相当于有十个事务加入到大事务中
for (int i = 0; i < 10; i++) {
String billNo = xxxService.executeService();
}
}
// 业务方executeService();
public String executeService() {
// SQL1
XXX xxx = comNoSerialMapper.selectXXX(XXX, XXX);
Long xxx = xxx.get() + 1L;
// SQL2
UpdateChain.of(XXX.class)
.set(XXX::xxx, xxx)
.set(XXX::UpdateTime, DateUtil.formatDate(new Date()))
.where(XXX::Id).eq(xxx.Id())
.update();
}
通过单元测试的日志发现,SQL1只在首次执行了一次,后续的SQL日志都是SQL2的更新日志。为什么SQL1只在首次执行呢?
排查数据库的隔离级别,并且在同一事务内,那么应该就是Mybatis的一级缓存问题。一级缓存是默认开启的,由于在同一个事务内,也就是同一个SqlSession会话内,肯定是会存在查询缓存的,并且只会查询一次
有两种解决方案:
- 修改事务隔离级别:从默认的
required
改为required_new
每次都新建一个事务执行。事务新建之后会自动提交,所以每次事务查询都会是从数据库获取的最新值。 - 使一级缓存的失效:一级缓存默认会在同一会话进行增删改后失效。(下文再提及为什么执行了修改一级缓存也没有失效的问题)或者就是关闭默认的一级缓存。
为什么执行了修改,SqlSession的一级缓存没有失效,仍然存在?
通过业务代码可以看出,SQL2是通过工具类去更新,类似于直接SQL,这种可能就没有触发清除一级缓存。因为mapper是代理对象在切面清理缓存,个人推测应该至少通过mapper方式去执行更新的方式才能清除掉一级缓存,避免数据不一值。