`
海浪儿
  • 浏览: 274349 次
  • 性别: 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:代码需不需要加锁?加锁的时机对不对?如果存在多个锁,那么加锁的顺序对不对?会不会存在死锁?单例里如果有成员变量,那么该变量是否具有状态?
分享到:
评论

相关推荐

    Django如何防止定时任务并发浅析

    然而,如果不正确地处理并发问题,可能会导致定时任务的重复执行或相互干扰,造成不必要的资源浪费和数据不一致。本文将深入探讨如何在Django中防止定时任务并发。 首先,Django提供了`commands`类,它允许开发者...

    浅析MYSQL中的并发操作与锁定

    浅析MYSQL中的并发操作与锁定 MYSQL中的并发操作和锁定是数据库管理系统中非常重要的概念。并发操作是指多个线程或用户同时访问和操作同一个数据库表的能力,而锁定则是为了避免数据不一致和数据丢失所采取的一种...

    浅析连接器设计中的并发开关噪声

    在高速电路设计领域,连接器扮演着至关重要的角色,而并发开关噪声是连接器设计中的一大挑战。并发开关噪声主要源于相邻信号路径间的互感,即当多个信号同时切换时,通过互感耦合在静止信号环路上产生的噪声。这种...

    高并发重负载网站架构浅析.pdf

    高并发重负载网站架构的设计是互联网领域的一个核心挑战。随着网站用户数量和功能需求的不断提升,传统的单一系统架构难以满足性能需求。本文主要从四个方面探讨了应对高并发和重负载的网站架构策略:静态内容分离和...

    CEREC口腔椅旁CAD_CAM数字化修复临床疗效及常见术后并发症浅析.pdf

    文章总结了CEREC口腔椅旁CAD/CAM数字化修复系统的临床疗效和常见术后并发症,不仅为临床医师提供了参考依据,也为患者及其家属提供了必要的知识,有助于他们在选择合适的治疗方案和术后护理时作出明智的决定。...

    Netty实现原理浅析.pdf

    ### Netty实现原理浅析 #### 一、总体结构概览 Netty是一个高性能的Java NIO框架,由JBoss出品。它不仅提供了一套完整的客户端和服务端开发工具集,而且具备高度可定制化的特点,使得开发者能够轻松构建出可靠且...

    java并发编程专题(六)----浅析(JUC)Semaphore

    Java 并发编程专题(六)----浅析(JUC)Semaphore Java 并发编程专题(六)----浅析(JUC)Semaphore 是 Java 并发编程中的一种重要机制,它主要用于控制多个线程访问共享资源的同时性。Semaphore 是一种信号量...

    技术浅析.pdf 知识领域 Javase javaee Java技术浅析 技术关键词 JAVA语言 编程技术框架 原理

    Java技术浅析 Java是一种广泛应用于企业级项目开发的编程语言,它具有强大的功能和灵活的特性,使其成为开发大型项目的首选语言。Java技术浅析主要介绍了Java语言的技术关键词、编程技术框架、原理等知识点。 一、...

    浅析Java_Concurrency

    在分析Java并发机制之前,首先需要了解并发编程的重要性和复杂性。Java并发机制是Java语言特性中的精髓所在,它允许开发者利用多线程高效地执行任务,提高程序的执行效率。在多线程环境中,正确地使用并发控制可以...

    浅析PHP中Session可能会引起并发问题

    但可能有人不知道,在PHP中,Session使用不当可能会引起并发问题。印度医疗行业软件解决方案提供商Plus91 Technologies高级工程师Kishan Gor在个人博客上对这个问题进行了阐释。 如果同一个客户端并发发送多个请求,...

    浅析PostgreSQL事务处理机制

    ### 浅析PostgreSQL事务处理机制 #### PostgreSQL简介 PostgreSQL是一款开源的对象关系数据库系统,其历史可以追溯至1977年,由Michael Stonebraker领导的加州伯克利分校的INGRES项目发端。它支持大部分SQL标准,...

    Nginx设计浅析.pptx

    Nginx 设计浅析 Nginx 是一个免费的、开源的、高性能的 HTTP 服务器和反向代理服务器,同时也是一款 IMAP/POP3 代理服务器。它以其高性能、稳定性、丰富的功能集、简单的配置和低资源消耗而闻名。 高性能、高并发 ...

    浅析进程与线程,深入了解MFC的基础知识点

    线程之间共享进程的资源,但拥有各自的栈空间和程序计数器,因此可以并发执行。当需要创建新的线程时,可以调用`CreateThread`函数,系统会分配线程对象,设置线程上下文,并开始执行指定的线程函数。线程的生命周期...

    深入浅析NodeJs并发异步的回调处理

    这里说并发异步,并不准确,应该说连续异步。NodeJs单线程异步的特性,直接导致多个异步同时进行时,无法确定最后的执行结果来回调。举个简单的例子: for(var i = 0; i < 5; i++) { fs.readFile('file', ...

    网站架构及高性能并发服务器设计

    很早之前开始收集整理的网站架构及高性能并发服务器设计的一些好的案例及实际优化经验。 实际优化经验:  初创网站与开源软件 6  谈谈大型高负载网站服务器的优化心得!... CommunityServer性能问题浅析 250

    深入浅析Random类在高并发下的缺陷及JUC对其的优化

    《深入剖析Random类在高并发下的缺陷与JUC优化》 Random类在Java编程中扮演着重要的角色,它是我们生成随机数的常用工具。然而,当我们面对高并发环境时,Random类的一些内在缺陷就会显现出来。本文将深入探讨这些...

Global site tag (gtag.js) - Google Analytics