背景
最近项目上遇到一个并发问题,调用方由于不知名的原因,在打开一个页面的时候要调用同一个接口两次,这个接口是一个新增、删除的事务性接口。然后导致在有些调用情况下数据会被重复插入。
@RestController
@Slf4j
public class XxController {
@Autowired
private XXService service;
@PostMapping(value = "/xx/munytime", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Result> xxCon(@Validated({Validate.class}) @RequestBody Xxxx xx) {
......
service.do(xx);
......
}
}
@Service
public class XXService {
@Transactional(rollbackFor = Exception.class)
public Result do(Xxxx xx) {
.......
insertOrUpdateXx;
InsertOrUpdateXxRelation;
......
}
}
第一反应的处理方法
- 首先检查业务逻辑代码,所有的插入操作都做了select防止重复,并且识别唯一的数据字段没有问题;
- 检查所有的重复数据,发现整个do方法里面的插入数据偶尔xx和xxrelation全部都重复,偶尔只有xxRelation重复,没有出现xx重复,xxRelation不重复的情况;
得出的结论是,进来的第一次请求还没有执行完,第二次请求就进来了,导致select判断不到数据,直接变成插入了,导致插入了两次。因为业务逻辑接口本身做了防止重复的处理,因此第一个想法就是加一个锁来防止并发调用,为了提高性能,采用在方法内部加锁方法。
service的代码修改如下:
// 识别关键数据 防止重复调用
private ConcurrentHashMap<String, String> xxMap = new ConcurrentHashMap<>();
@Transactional(rollbackFor = Exception.class)
public Result do(Xxxx xx) {
.......// 前面数据处理不影响
String xxbh = xx.getYwbh();
String sycXxbh = xxMap.putIfAbsent(xxbh, xxbh);
if (sycXxbh == null) {
sycXxbh = xxMap.get(xxbh);
}
synchronized (sycXxbh) {
insertOrUpdateXx;
InsertOrUpdateXxRelation;
......
}
}
为了大家更好的明白我这块的逻辑,我这里做一个简答的解释:
首先判定重复的关键数据就是Xxxx里面的ywbh字段,因此取出每次调用的这个字段,因此这个数据是从请求线程里面来的,因此每次获取的这个ywbh值是一样的但是内存地址不一样,这就导致synchronized关键字会认为不是同一个,因此使用一个局部变量
ConcurrentHashMap<String, String> xxMap
来保证锁和每次取值的ywbh内存地址一样,那么这样synchronized就能锁上代码块了。
然后测试执行了几次,发现没有复现前面的重复数据这个问题。遂以为解决了。
问题再现
过了一段时间后,交给测试进行测试。由于以前服务器的问题,测试弄了一台新的数据库和应用服务器,数据库服务器和应用服务器不在同一个地域,有一定的网络时延。然后测试的时候,进行了一轮接口压力测试,偶尔复现重复插入数据的问题。当时就有点懵逼了,这个都加了块级锁了,怎么可能两个相同的数据同时进入块,一度怀疑人生,并且找来了几个同事一起看这块加锁的代码,都没有发现问题。然后正常操作的时候也是偶尔会出现重复数据的问题…
然后对hashmap的没存地址和取值产生了怀疑,没法了,实在找不到怀疑的地方了,偶现的问题也不好debug(debug的时候都是好的)…写了一个main函数然后使用==
比较每次取的内存地址是否和放进去的意义,具体代码和结论如下:
private static ConcurrentHashMap<String, String> xxMap = new ConcurrentHashMap<>();
public static void main(String[] args) {
String a =new String("a");
String na =new String("a");
String ma=xxMap.putIfAbsent(a, a);
System.out.println(ma);
String mna=xxMap.putIfAbsent(na, na);
System.out.println(mna==ma);
System.out.println(mna==a);
}
/**
*运行结果如下:
null
false
true
*/
从代码看出,之前的加锁设计是没毛病的,内存地址都是一样的,那么就肯定能锁住。然后…就彻底陷入了僵局。
然后静下来整理一遍执行逻辑,代码进入do方法,然后判断是否加锁,没获取到锁的就等待,然后得到锁的进行执行,执行完了释放锁,然后看了一眼方法头部有个事务注解,那么释放了锁再提交事务。然后突然恍然大悟!!!在提交事务的时候锁已经被释放了,第二个等待的线程就获取到锁了,然后开始执行代码里面的插入更新,但是现在可能提交的事务还没有执行到库导致这个判断不到,然后就导致重复插入了…找到原因了,那么现在第一想法是事务提交了再释放锁。有两种方法,第一种就是把synchronized加在方法上,方法没有释放之前谁都进不来,这样理论上会导致本身不会重复业务处理也跟着一起排队,会导致一定的性能问题;第二种就是要根据ywbh去加锁的方式,synchronized字段是代码块结束就释放,因此不适用,spring可以通过事务回调的方式来释放锁,但是已知的加锁的方式里面要在do方法里面对特定字段加锁的方式没有。
spring事务提交后的监听:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
xxxxx
});
所以最后采用的方式是在调用do方法的controller层去加锁处理。代码如下
@RestController
@Slf4j
public class XxController {
@Autowired
private XXService service;
// 识别关键数据 防止重复调用
private ConcurrentHashMap<String, String> xxMap = new ConcurrentHashMap<>();
@PostMapping(value = "/xx/munytime", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Result> xxCon(@Validated({Validate.class}) @RequestBody Xxxx xx) {
......
String xxbh = xx.getYwbh();
String sycXxbh = xxMap.putIfAbsent(xxbh, xxbh);
if (sycXxbh == null) {
sycXxbh = xxMap.get(xxbh);
}
synchronized (sycXxbh) {
service.do(xx);
}
......
}
}
然后交给前端进行一轮压测,没再复现,至此,整个过程结束。
综上所述,在spring的事务调用中,如果要防止并发(除了select操作)导致数据问题,加锁要加在方法上直接锁住class或者在调用之前锁住。从这里推论出:在aop调用中,应该考虑aop的round执行前后是否会影响锁释放的时机(或者其它多线程操作),避免非期望的进入锁区域。