1.背景并发问题是电子商务系统中最常见的问题之一,例如库存超卖、多次抽奖、多张优惠券、积分多积分少等。以上问题是由于存在多台机器,多个请求同时修改同一个共享资源。如果不加限制,就会造成数据混乱和数据不一致。解决并发问题的方法有很多种,比如:队列,异步,响应式,所有的锁都可用;由于现在的互联网是分布式系统,本文只介绍目前广泛使用的分布式锁方式如何进行质量保证。二、分布式锁简介1、什么是分布式锁?我们先来了解一下什么是锁。在单机系统中,当多个线程同时修改一个变量时,需要对该变量或代码块进行同步,以保证该变量被串行修改。同步本质上是通过锁来实现的。为了让多个线程同时对同一块代码串行执行,需要在某个地方进行标记。该标记必须对每个线程可见。当标记不存在时,可以设置标记,后续线程发现已经有标记时,等待有标记的线程结束同步代码块,取消标记在尝试设置标记之前。这个标记可以理解为一把锁。分布式锁是多机系统下的标志。2、分布式锁的主流实现方式目前主流的分布式锁实现方式有3种,分别是:(1)基于数据库实现分布式锁,这里的数据库是指基于MySQL锁表数据库版本号的MySQL关系型数据库乐观锁(二)基于缓存实现分布式锁。这里的缓存是指Redis(3)基于zookeeper/etcd实现分布式锁。关于锁的具体实现的文章太多了。本文不再赘述。3.质量保证一旦并发问题涉及到钱,通常会导致不同程度的资金损失,而我们的功能测试很难发现。因此,对于并发的质量保证就显得尤为重要,可以抽象为三层来保证:三大步骤:事前、事中、事后;事前保障通过Review提前规避技术风险,事中保障验证技术实施过程是否存在漏洞,事后保障验证数据是否符合预期。对于有并发风险的项目,以上三步的保护是必不可少的。1.事前质量保证事前保证阶段发生在技术审评阶段。这个阶段需要评估当前业务场景是否存在并发风险;如果是这样,确定我们的技术选择。评估并发风险的关键点是是否有多个进程同时访问共享资源。简单的说,是否有多个进程同时更新同一个数据;例如:电商中的库存,多人同时购买同一个商品,意味着同一个商品的库存会同时更新,这里存在并发的风险。技术选型要实现正确的技术选型,我们需要了解以上几种方式实现的锁的优缺点和应用场景。MySQL数据库表乐观锁适用于读多写少,共享资源为数据库单行数据的场景;一般不推荐使用MySQL表锁实现的锁;ZooKeeper分布式锁适用于大部分分布式场景,但是由于其实现复杂度比较高,需要引入额外的中间件,所以在大部分业务场景中很少使用,而基于Redis的缓存分布式锁被广泛使用;但是具体的业务实现是用哪种类型的分布式锁呢?还是要结合当前的业务特点来做决定;在技??术审核阶段,一方面要评估是否存在并发风险;对于实现漏洞,请参考以下CodeReview关注点。2.CodeReview(1)进程中保证Redis缓存分布式锁Redis通常可以使用setnx(key,value)函数实现分布式锁。key和value是基于缓存的分布式锁的两个属性,其中key代表锁id。setnx函数返回1表示已经获得锁,返回0表示其他服务器已经获得锁;Redis缓存分布式锁CodeReview笔记:1)RedisKey全面梳理业务场景。对于同一个公共资源,key必须一致;key是标识共享资源的唯一键,键的设计需要能够锁定当前共享资源且不影响其他资源;比如:商品库存,我们的key应该是针对某一种商品,而不是所有商品,锁定A商品,不会影响B商品。2)锁释放锁必须显式释放。try/finally结构加锁和解锁,最后释放锁;锁只能由被锁定的对象释放。这是经常出现问题的地方。如下图所示,A加锁锁被B释放,导致锁失效,锁被C抢占;针对以上问题,在释放锁时,需要先读取当前key的值,然后与传入的值进行比较;以上两步必须保证原子性,如果原生Redis可以使用lua脚本保证原子性;如果是Tair,可以使用TairString的cad方法;value必须是一个唯一的值,唯一的标记是加在当前对象上的锁。3)锁定超时一定要设置key的超时时间;例如:客户端A抢到锁后,系统突然出现异常,A无法释放锁,成为死锁;设置超时时间是为了防止这种情况发生。时间到后自动删除key,间接释放锁;b.一般来说,超时设置可以设置大于服务的最大执行时间,但是服务的最大执行时间会受到很多因素的影响,是不可控的;例如:A服务一般执行时间为30ms,设置的锁超时时间为100ms。服务执行时间受网络影响变成200ms,100ms会释放锁;在大多数场景下,开发不会处理这种情况,需要协商是否需要处理这种极端情况;处理方法如下:可以另开一个线程继续当前的超时时间,但是会增加系统的复杂度。设置过期时间很长,必须能保证在锁释放前逻辑能执行;这个解决方案很简单但有缺陷。当系统遇到突发异常时,无法释放锁,只能等待rediskey超时,而超时时间设置的比较长,所以在当前时间内没有人能获得锁,阻塞业务执行,容易导致失败;4)锁粒度如果对一个共享资源的写入是根据另一个共享资源的值来计算的,那么锁的范围必须是包括读共享资源;该范围不包括读取共享资源,这将导致脏读并最终导致数据错误。如下图,ClientB最终计算出的B结果是错误的。5)获取锁失败由于其他线程已经获取了锁,当前线程获取锁失败后的处理方式有3种:抛出异常让用户重试;通过旋转再次抓住锁;发布订阅,订阅解锁消息;在低并发的场景下,抛异常和自旋抓取都是可以的,但是在高并发的场景下,抛异常和自旋抓取是不可取的。(2)MySQL数据库锁CR点1)数据库版本号乐观锁需要在数据库表中包含一个数字字段版本,读取数据时读取版本字段,更新时判断当前版本是否等于读出的数据版本,当前版本加1;如果相等,则更新成功,如果不相等,则表示数据已过期,更新失败。比如以积分系统为例,增加积分的场景有很多,使用乐观锁来保证数据的正确性。乐观锁CR注意点:where条件必须命中索引(最好是主键或唯一索引),否则会锁表;updatetableset必须包含version=version+1;当update返回0时,必须根据业务场景进行相应处理,独立重试或抛出异常;2)基于MySQL锁表,实现原理是:创建锁表,对关键资源做唯一约束,通过增加一条记录锁定某个资源,释放锁删除记录;一般不推荐这种用法。并发测试并发测试一般可以分为三类:(1)复杂并发场景,一次请求多个共享资源,前后存在各种依赖关系。该场景适用于链路级压测,压测模型需要精心设计。(2)在单并发场景下,一个共享资源可以被多次处理,例如:扣某商品的库存可以重复调用。可以通过接口压测的方式进行测试,只需要检查最终数据是否与预期不一致即可;压测工具:jmeter可以进行压测(群里可以直接用pas-server压测,方便快捷);(3)单一并发场景,共享资源,只能处理一次,例如:用户只有一次抽奖机会,连续点击两次会不会抽两次;可以使用JVM的并发函数CountDownLatch、CyclicBarrier等,CountDownLatch片段代码:publicvoidinvokeAllTask??(ConcurrencyRequestrequest,Runnabletask){finalCountDownLatchstartCountDownLatch=newCountDownLatch(1);finalCountDownLatchendCountDownLatch=newCountDownLatch(request.getConcurrency());for(inti=0;i
