事务与锁导致重复数据

背景

​ 最近项目上遇到一个并发问题,调用方由于不知名的原因,在打开一个页面的时候要调用同一个接口两次,这个接口是一个新增、删除的事务性接口。然后导致在有些调用情况下数据会被重复插入。

@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;
        ......
    }
}

第一反应的处理方法

  1. 首先检查业务逻辑代码,所有的插入操作都做了select防止重复,并且识别唯一的数据字段没有问题;
  2. 检查所有的重复数据,发现整个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执行前后是否会影响锁释放的时机(或者其它多线程操作),避免非期望的进入锁区域。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值