前言
最近在编写公司APP产品的商品砍价功能,其中有一个接口涉及并发访问。自测时通过ApiFox接口管理工具进行压测,落地数据时出现了"锁失效"的情景。十分感谢后端小伙伴的帮助排查,解决了这个问题。
问题描述
并发接口中,先对主表数据进行读取,进行业务判断后,新增、修改它表的数据。在理应串行执行的情况下发生了多个请求线程读取到了相同的主表数据,导致数据处理异常。也正是前言中所说的"锁失效"了。(实际情况加锁操作是有效的)
代码复现
@RequestMapping("/test")
@Transactional(rollbackFor = Exception.class)
public String test() {
DistributedLock.lock("ct_lock");
try {
Map<String, Object> resultMap = jdbcTemplate.queryForMap("select * from concurrent_read_uncommit");
int num = Integer.parseInt(resultMap.get("num").toString());
num++;
jdbcTemplate.update("update concurrent_read_uncommit set num = " + num);
} finally {
DistributedLock.unlock("ct_lock");
}
return "success";
}
- 最少的代码进行演示,Controller方法体中的内容应是Service中的代码
- DistributedLock中封装的Redission
- 通过将先读后改的方式演示,实际中本质就是进行了这样的操作,但会存在更多的业务代码(不演示新增的情况)
ApiFox中通过创建100个请求线程进行压测,最终concurrent_read_uncommit表中的num字段值为94,而非100。
排查
1. 锁失效
新编写了两个简单接口,第一个接口加锁,并线程休眠30秒后释放锁。另一个接口加同样的锁,打印一条语句后直接返回。先调用第一个接口,在调用第二个接口。Debug中发现锁是有效的,在redis中存有锁Key。并且访问第二个接口时,线程被阻塞在了加锁行代码。
2. 事务隔离级别
查询数据库事务默认隔离级别:
select @@tx_isolation;
结果
REPEATABLE-READ
就是默认的RR级别,那么说明同个事务内多次读取数据都会是一样的,不会读取到脏数据。
3. 修改Spring事务传播配置
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
与这个并没有关系,八竿子打不着。当时的想法时是多个并发请求在进入到了同个事务内,并一起读取到了没有被修改前的数据。细想想:
- 事务传播配置一般用在不同事务方法间产生调用时的事务决策,是共用事务还是新创建事务,亦或是其他的方式进行处理
- test方法本身为根方法,也没有调用其他的事务方法,所以无需配置事务传播配置
- 即便不在同一事务内,依旧能查询到其他事务修改但未提交的相同数据
解决方案
在锁代码块中调用事务方法,而不是在事务方法中进行加锁。
原因为:并发情境下,执行速度过快,很有可能发生:请求线程在释放锁后没有来得及提交事务,另一个请求线程在加锁处被唤醒,继而读取到了事务未提交的数据。即读取到了脏数据,产生了"锁失效"的效果。
修正代码:
@RequestMapping("/test2")
public String test2() {
ConcurrentTransactionalController proxyBean = SpringContextUtils.getBean(this.getClass());
proxyBean.doTest2();
return "success";
}
@Transactional(rollbackFor = Exception.class)
public void doTest2() {
DistributedLock.lock("ct_lock");
try {
Map<String, Object> resultMap = jdbcTemplate.queryForMap("select * from concurrent_read_uncommit");
int num = Integer.parseInt(resultMap.get("num").toString());
num++;
jdbcTemplate.update("update concurrent_read_uncommit set num = " + num);
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
DistributedLock.unlock("ct_lock");
}
}
- 将需要加锁的事务代码进行提取另一个方法
- 调用方法中进行加锁,并且必须要去掉事务注解
- 因为是在非事务方法调用事务方法,为了保证事务生效,需要通过事务代理Bean进行调用
这样就保证了不会读取到事务未提交的数据,同时又具有锁的排他性。
其实锁一直都是有效的,本质原因就在于Spring的事务代理Bean屏蔽了事务代码。我们不能手动的进行控制,也就是说你变更了不了事务代码的顺序。如果能将提交事务的行代码写到释放锁之前,就不会存在这个问题了。所以,也可以通过编程式事务解决这个问题,关于编程式事务,Spring也有做代码封装。如果不通过编程式事务,那么就只能通过上述代码变相的来实现。
到此这篇关于Spring事务原理解析的文章就介绍到这了,更多相关Spring事务内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!