Mysql数据库一级缓存对业务双重检查锁的影响
背景介绍
业务上,会有多人可能同时对同一笔申请进行审核的情况发生,为了防止出现重复审批的情况,我首先想到了单例模式的双重锁检查。然后就套用,一直没有出现问题。直到某项功能测试准备上线时发生的一个诡异问题,才让我意识到,基于Mysql数据库的状态检查,是业务的双重检查与单例模式的双重锁检查的最大不同。而恰恰就是微小这个的,一开始并没有引起我注意的不同,差点导致线上重大问题的发生。
场景还原
类似的业务双重检查代码如下:
//审核方法
@Transactional
public Boolean audit(AuditRequest request) {
//第一层检查
A a = checkService.checkAudit(request);
A result;
// 通过工厂类获取一个分布式锁
DistLock distLock = distLock.buildLock(LOCK_KEY + a.getId());
try {
// 限时获取锁,给定超时时间后则会抛出异常
distLock.tryAcquire(1, TimeUnit.MINUTES);
// 获取锁后执行业务逻辑
//执行第二层检查
A a = checkService.checkAudit(request);
result = audit(request, a);
} catch (Exception e) {
LOGGER.error("audit error, errorMsg -> {}", e);
throw new BusinessRuntimeException(FAIL);
} finally {
// 正常执行或发生异常均释放锁
distLock.release();
}
return true;
}
//检查方法
public A checkAudit(auditRequest auditRequest) {
//查询数据库,并判断状态
A a = ADao.selectById(auditRequest.getId());
if (a == null || a.getStatus() != Status.TO_AUDIT) {
throw new BusinessRuntimeException(NOT_EXIST);
}
}
通过以上代码可以看出来,完全是按照单例模式的思路去做的。
但是实际上,调试下来发现,在执行两次ADao.selectById()方法之后的A对象实例a的内存地址是一样的。通过debug日志,发现,只有第一次执行的时候,真正执行了数据库查询,之后第二次执行的时候,并没有打印出sql语句。
原因剖析及原理解析
和大多数持久层框架一样,MyBatis同样提供了一级缓存和二级缓存的支持。一级缓存是基于 PerpetualCache 的 HashMap 的本地缓存,其缓存的作用域为 Session,当 Session flush 或 close 之后,该Session中的所有缓存就会被清空。二级缓存与一级缓存机制相同,默认也是采用PerpetualCache的HashMap进行存储,不同之处在于其存储作用域为 Mapper(即同一个Namespace),并且可自定义存储源,如 Ehcache、Hazelcast等,本文不做详细介绍。
MyBatis默认是开启一级缓存的,它是SqlSession级别的,也即同一个SqlSession对象调用相同的Select语句,就会从一级缓存中直接拿到结果,而不是再去查询一次数据库。并且默认Select会使用缓存,增删改不使用。在同一个SqlSession中,使用同样的查询条件调用同一个查询方法(调不同的方法即使SQL完全相同也不会使用一级缓存)时只有第一次会发送SQL到数据库进行查询,后续都会直接从一级缓存中获取缓存结果。示意图如下:
一级缓存的原理:MyBatis内部缓存使用了一个HashMap,HashMap是键值对的结构,键值对中的key是hashCode + 查询的SqlId(即XxxDao.xml中的id) + 编写的sql查询语句,键值对的值是执行查询后所获得Java对象。一级缓存的作用域是SqlSession,每次查询先找缓存,找到了就使用,找不到再从数据库查询,查询到数据后会将数据写入一级缓存。
一级缓存失效的方法:
1,处于事务中的方法中查询使用的SqlSession对象不是同一个,比如在方法中执行了sqlSession.close()之后又新开一个SqlSession
2,是同一个SqlSession对象,但执行过sqlSession.clearCache();清空了缓存
3,增删改操作之后,一旦方法中有增删改的操作,SqlSession就会清空缓存
而本次遇到的问题,是sqlSession被spring代理了,处于同一个事务中的sqlSession都是同一个,所以一定会存在缓存。
问题解决
了解到以上信息之后,针对开头的问题,我的处理方式是:
1,在第二次检查的时候,新建一个sqlSession重新进行数据库查询,保证缓存失效
或者将org.mybatis.spring提供的SqlSessionTemplate替换为org.apache.ibatis.session.SqlSessionFactory,这样就避开了spring代理中的缓存,也能保证每次使用都是一个新的sqlSession
2,执行sqlSession.clearCache()方法,清除现有缓存
3,还有一种方法,将第一次检查放在事务外
参考资料:
https://blog.csdn.net/luanlouis/article/details/41280959
https://blog.csdn.net/rubulai/article/details/96106754