`
海浪儿
  • 浏览: 271756 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

并发浅析

 
阅读更多
之前做的项目里涉及到了一些并发问题,今天总结一下

并发是由对共享资源的访问不当引起的,总的来说,常见的共享资源分为两大类:一种是数据库表中的行记录;一种是代码中的共享变量(譬如单例或者静态类型等等)。下面对这两类共享资源引发的并发问题借助一些实际的例子进行阐述。

1.数据库表中的行记录共享
此类资源共享导致并发问题的原因一般分为以下三类:
 没有加锁
 加锁的时机不对
 加锁的顺序不对
1.1没有加锁
如下业务场景:对一笔已收取滞纳金的订单进行退还,退还金额应该小于等于实际已经收取的滞纳金。代码如下:

//1.根据billId从数据库中加载账单(第二个参数为false表示不加锁)
Bill bill = billCoreService.getBillById(billId, false);

// 2.根据billId从数据库中获取该账单关联的利息账单
InterestBill interestBill =billCoreService.getInterestBill(billId);

// 3.判断该账单的已退还的滞纳金金额+本次申请退款的金额是否小于等于该笔账单实际已收取
// 滞纳金金额
if (sumAmount.compareTo(interestBill.getRepayedInterest()) > 0) {
logger.warn("【退还单笔滞纳金】 请求中账单请求退还金额大于已还金额!");
throw new CreditCoreServiceException(
CreditCoreGeneralResultEnum.ERR_REFUND_INTEREST_AMOUNT_GREATER);
}
// 4.如果第三步判断通过了,则对本次申请的金额进行退还(具体的代码省略)


很明显,如果第一步加载账单的时候没有对账单加锁,当出现两个线程并发执行这段逻辑时,就会出现问题:
 线程1执行到第三步的时候进行判断,实际已退还金额+本次申请的金额确实小于等于该笔账单实际已收取的金额,所以准备执行第四步,此时被中断;
 线程2开始执行,并执行退款,且退款后该账单的实际退还金额已经等于实际收取的滞纳金。
 线程1重新被调度,仍然会继续执行第四步,最终导致账单的实际退还金额大于实际收取的滞纳金金额。

1.2加锁的时机不对
还是上面那种业务场景,不过此时加锁了,但加锁的时机不对,加锁放在了金额判断的后面,代码如下:

// 1.根据billId从数据库中获取该账单关联的利息账单
InterestBill interestBill =billCoreService.getInterestBill(billId);

// 2.判断该账单的已退还的滞纳金金额+本次申请退款的金额是否小于等于该笔账单实际已收取
// 滞纳金金额
if (sumAmount.compareTo(interestBill.getRepayedInterest()) > 0) {
logger.warn("【退还单笔滞纳金】 请求中账单请求退还金额大于已还金额!");
throw new CreditCoreServiceException(
CreditCoreGeneralResultEnum.ERR_REFUND_INTEREST_AMOUNT_GREATER);
}

//3.根据billId从数据库中加载账单
Bill bill = billCoreService.getBillById(billId, true);

// 4.如果第三步判断通过了,则对本次申请的金额进行退还(具体的代码省略)


当两个线程并发执行这段逻辑时,还会出现同样的问题:
 线程1执行到第二步进行金额判断发现没问题,然后执行第三步对账单进行了加锁,此时被中断;
 线程2开始执行,金额判断也发现没问题,然后准备执行第三步获取锁,但线程1还未执行完,所以线程2被阻塞;
 线程1被调度,执行退款完毕并释放锁(此时的实际退款金额已经等于已收取金额);
 线程2被唤醒并能获得锁,因此执行退款,导致账单的实际退还金额大于实际收取的滞纳金金额。

1.3加锁的顺序不对
前面两种情况实际上是比较简单的,因为只需要对一个表的记录加锁,容易识别。当需要对多个表的记录进行加锁时,情况就复杂了,不紧紧需要考虑上面的加锁时机问题,还需要考虑多个锁之间的加锁顺序问题。如果考虑不当,不仅仅会出现并发问题,还有可能出现死锁。
有如下业务场景:借款支付操作涉及到对额度的记账。一个代理人下面有多个网点,所以记账不仅仅需要记录代理人的循环额度账,还需要对当前借款支付的网点记录每日额度账。
现在为了防止并发问题,需要将这两条记录都锁住。有人可能会问,同一个代理下的任何一个网点借款支付时均需要记录所属代理人的循环额度账,那么只锁住代理人的循环额度账不就可以防止并发了吗?确实,如果只考虑借款支付这一种业务操作的并发,锁住入口(即代理人的循环额度账)就可以解决问题,但还存在其他业务操作修改每日额度账,并不涉及循环额度账,所以,只锁循环额度账不能解决不同业务之间的并发问题。
现在的代码逻辑是这样:开启一个本地事务,然后首先获取网点的每日额度账,如果存在则锁住该记录,否则生成一条新的记录;最后获取代理的循环额度账并锁住。

//1. 开启本地事务

//2.获取网点的每日额度账,如果存在,则锁住

//3.如果第一步获取的网点的每日额度账不存在,则新生成一条记录

//4.获取代理的循环额度账,并锁住

//5.其他操作

//6.事务提交


注意:代理人的循环额度账是一定存在的,但网点的每日额度账不一定存在,如果不存在就会新生成一条记录。但因为该事务并没有提交,所以新生成的这条记录在事务外是看不到的,问题就出在这里了:
两个线程并发执行这段逻辑:
 线程1执行到第三步发现网点的每日额度账不存在,就新生成了一条记录,然后执行第四步,将代理的循环额度账锁住,此时被中断;
 线程2开始执行,到第三步发现网点的每日额度帐也不存在(因为线程1的事务还未提交,所以线程2看不到线程1的事务里新增的记录),于是又生成了一条新纪录,然后准备执行第四步,因为获取不到代理的循环额度账的锁,所以被阻塞;
 线程1被调度,继续执行,并最终提交事务并释放代理的循环额度帐的锁
 线程2被唤醒,获取到代理的循环额度帐的锁,继续执行,提交事务
这样,最终导致的结果是,对于同一个网点,当天的每日额度账在数据库里存在了两条记录,这就违背了业务规则,而且该网点以后借款支付也不会成功了,因为执行到第二步里的sql就会报错,返回了两条记录,而默认最多只会存在一条记录。
出错的原因就是加锁的顺序不对,如果首先锁住代理的循环额度账,然后再去锁网点的每日额度账,就不会出现问题。

//1. 开启本地事务

//2.获取代理的循环额度账,并锁住

//3.获取网点的每日额度账,如果存在,则锁住

//4.如果第一步获取的网点的每日额度账不存在,则新生成一条记录

//5.其他操作

//6.事务提交


因为在这种情况下,线程2在线程1提交前,获取不到代理的循环额度账的锁,它是没办法执行任何操作的,从而也就不可能去新生成一条网点的每日额度账。而当线程2获取到代理的循环额度账的锁,线程1肯定已经提交了,因此线程2就能看到线程1的事务里新生成的那条记录了,所以线程2就不会去新生成多余的记录了。
另外,原来的加锁顺序还会导致死锁的情况发生,因为还存在其它的业务操作需要对这两条记录加锁。其它的业务操作里的加锁顺序是先锁代理的循环额度帐,然后再锁每日额度账,这恰好是与借款支付操作里的顺序是相反的。如果这两种业务操作并发了,譬如:
 线程1执行借款支付,先锁住了网点的每日额度账,此时执行其它业务操作的线程2也锁住了代理的循环额度账
 线程1打算获取代理的循环额度账,被阻塞,等待线程2释放该锁
 线程2打算获取网点的每日额度账,被阻塞,等待线程1释放该锁
这样,死锁就发生了。
可能有人会问,为什么不单独搞一个锁,每个业务操作执行时都必须先获取该锁,这样就解决了任何的并发问题,而且还不会死锁。确实,在xx系统技术改造前,就是采取的这种策略,但任何事情都是两面的,搞一个锁会严重影响系统的并发处理能力,因为原本两种不存在并发冲突的业务逻辑现在也只能串行执行了,所以最好的方案是该锁的地方才锁,而且锁住的业务逻辑要尽可能少。

2.变量共享
这种问题一般出现在对单例的实例或静态类修改中。此处只举一个单例的例子,静态类的情况类似。
Spring里bean的属性scope默认是singleton,因此在同一个容器中,只会存在该类型的唯一一个实例。如果在该类型里定义了成员变量,而且成员变量存在状态(即在运行中会被修改),就会出现并发问题。

Public class ProductDefinitionServiceImpl implements ProductDefinitionService{
private XMap xmap = null;
private ProductDefinition parseProdDef(ProdDefineDO productDo) {
ProductDefinition definition = null;
if (productDo != null) {
xmap = new XMap();
xmap.register(ProductDefinition.class);
try {
definition = (ProductDefinition) xmap.load(new ByteArrayInputStream(productDo
.getDefinitionContext().getBytes()));
} catch (Exception e) {
logger.error("[解析产品定义失败]", e);
}
}
return definition;
}
}


分析:xmap作为了单例ProductDefinitionServiceImpl的一个成员变量,现在两个线程并发执行方法parseProdDef:
 线程1执行xmap.register(ProductDefinition.class)后,被中断;
 线程2开始执行,执行xmap = new XMap()后,被中断;(注意:因为ProductDefinitionServiceImpl是一个单例,所以这两个线程修改的是同一块内存,问题就出现了,线程2现在是又new了一个XMap,并把它赋给了成员变量xmap,这样线程1所作的修改(将ProductDefinition.class注册到xmap)就被线程2给覆盖了,此时的xmap又回到了原样,没有注册ProductDefinition.class)
 线程1被调度,执行definition = (ProductDefinition) xmap.load(new ByteArrayInputStream(productDo .getDefinitionContext().getBytes()));
因为此时用到的xmap是线程2新生成的一个XMap,而且没有注册ProductDefinition.class,所以调用xmap.load方法就会返回null,导致加载产品定义失败。

上面对导致并发问题的常见原因进行了分析,那么如何去检测这种缺陷呢?通过运行并发测试脚本或者借助jmeter、lr之类的工具确实有可能把并发缺陷检测出来,但这是要看运气的。首先,编写并发测试脚本不一定很容易,而且一不小心有可能测试脚本本身就会出现并发问题;其次,采用的并发数可能不足以让缺陷显现出来,即使并发数够了,但压的时间不够长,缺陷也可能不会显现出来;如果需要长时间的压力,有些场景又可能不好做,因为测试数据运行一次就脏了,没法做到高并发下长时间运行,除非搞大量的不同的测试数据以支持长时间的高并发运行。最后,万事俱备,但运气不好,也可能缺陷不会显现出来~~
那么什么是利器呢,其实很简单,就是codereview,带有目的性的codereview:代码需不需要加锁?加锁的时机对不对?如果存在多个锁,那么加锁的顺序对不对?会不会存在死锁?单例里如果有成员变量,那么该变量是否具有状态?
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics