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