分布式锁1 Java常用技术方案

前言:

      由于在平时的工作中,线上服务器是分布式多台部署的,经常会面临解决分布式场景下数据一致性的问题,那么就要利用分布式锁来解决这些问题。所以自己结合实际工作中的一些经验和网上看到的一些资料,做一个讲解和总结。希望这篇文章可以方便自己以后查阅,同时要是能帮助到他人那也是很好的。

 

===============================================================长长的分割线====================================================================

 

正文:

      第一步,自身的业务场景:

      在我日常做的项目中,目前涉及了以下这些业务场景:

      场景一: 比如分配任务场景。在这个场景中,由于是公司的业务后台系统,主要是用于审核人员的审核工作,并发量并不是很高,而且任务的分配规则设计成了通过审核人员每次主动的请求拉取,然后服务端从任务池中随机的选取任务进行分配。这个场景看到这里你会觉得比较单一,但是实际的分配过程中,由于涉及到了按用户聚类的问题,所以要比我描述的复杂,但是这里为了说明问题,大家可以把问题简单化理解。那么在使用过程中,主要是为了避免同一个任务同时被两个审核人员获取到的问题。我最终使用了基于数据库资源表的分布式锁来解决的问题。

      场景二: 比如支付场景。在这个场景中,我提供给用户三个用于保护用户隐私的手机号码(这些号码是从运营商处获取的,和真实手机号码看起来是一样的),让用户选择其中一个进行购买,用户购买付款后,我需要将用户选择的号码分配给用户使用,同时也要将没有选择的释放掉。在这个过程中,给用户筛选的号码要在一定时间内(用户筛选正常时间范围内)让当前用户对这个产品具有独占性,以便保证付款后是100%可以拿到;同时由于产品资源池的资源有限,还要保持资源的流动性,即不能让资源长时间被某个用户占用着。对于服务的设计目标,一期项目上线的时候至少能够支持峰值qps为300的请求,同时在设计的过程中要考虑到用户体验的问题。我最终使用了memecahed的add()方法和基于数据库资源表的分布式锁来解决的问题。

      场景三: 我有一个数据服务,每天调用量在3亿,每天按86400秒计算的qps在4000左右,由于服务的白天调用量要明显高于晚上,所以白天下午的峰值qps达到6000的,一共有4台服务器,单台qps要能达到3000以上。我最终使用了redis的setnx()和expire()的分布式锁解决的问题。

       场景四:场景一和场景二的升级版。在这个场景中,不涉及支付。但是由于资源分配一次过程中,需要保持涉及一致性的地方增加,而且一期的设计目标要达到峰值qps500,所以需要我们对场景进一步的优化。我最终使用了redis的setnx()、expire()和基于数据库表的分布式锁来解决的问题。

 

      看到这里,不管你觉得我提出的业务场景qps是否足够大,都希望你能继续看下去,因为无论你身处一个什么样的公司,最开始的工作可能都需要从最简单的做起。不要提阿里和腾讯的业务场景qps如何大,因为在这样的大场景中你未必能亲自参与项目,亲自参与项目未必能是核心的设计者,是核心的设计者未必能独自设计。如果能真能满足以上三条,关闭页面可以不看啦,如果不是的话,建议还是看完,我有说的不足的地方欢迎提出建议,我说的好的地方,也希望给我点个赞或者评论一下,算是对我最大的鼓励哈。

 

  第二步,分布式锁的解决方式:

      1. 首先明确一点,有人可能会问是否可以考虑采用ReentrantLock来实现,但是实际上去实现的时候是有问题的,ReentrantLock的lock和unlock要求必须是在同一线程进行,而分布式应用中,lock和unlock是两次不相关的请求,因此肯定不是同一线程,因此导致无法使用ReentrantLock。

      2. 基于数据库表做乐观锁,用于分布式锁。

      3. 使用memcached的add()方法,用于分布式锁。

      4. 使用memcached的cas()方法,用于分布式锁。(不常用) 

      5. 使用redis的setnx()、expire()方法,用于分布式锁。

      6. 使用redis的setnx()、get()、getset()方法,用于分布式锁。

      7. 使用redis的watch、multi、exec命令,用于分布式锁。(不常用) 

      8. 使用zookeeper,用于分布式锁。(不常用) 

     

      第三步,基于数据库资源表做乐观锁,用于分布式锁:

      1. 首先说明乐观锁的含义:

          大多数是基于数据版本(version)的记录机制实现的。何谓数据版本号?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。

          在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

      2. 对乐观锁的含义有了一定的了解后,结合具体的例子,我们来推演下我们应该怎么处理:

          (1). 假设我们有一张资源表,如下图所示: t_resource , 其中有6个字段id, resoource,  state, add_time, update_time, version,分别表示表主键、资源、分配状态(1未分配  2已分配)、资源创建时间、资源更新时间、资源数据版本号。

          

         (4). 假设我们现在我们对id=5780这条数据进行分配,那么非分布式场景的情况下,我们一般先查询出来state=1(未分配)的数据,然后从其中选取一条数据可以通过以下语句进行,如果可以更新成功,那么就说明已经占用了这个资源

               update t_resource set state=2 where state=1 and id=5780。

         (5). 如果在分布式场景中,由于数据库的update操作是原子是原子的,其实上边这条语句理论上也没有问题,但是这条语句如果在典型的“ABA”情况下,我们是无法感知的。有人可能会问什么是“ABA”问题呢?大家可以网上搜索一下,这里我说简单一点就是,如果在你第一次select和第二次update过程中,由于两次操作是非原子的,所以这过程中,如果有一个线程,先是占用了资源(state=2),然后又释放了资源(state=1),实际上最后你执行update操作的时候,是无法知道这个资源发生过变化的。也许你会说这个在你说的场景中应该也还好吧,但是在实际的使用过程中,比如银行账户存款或者扣款的过程中,这种情况是比较恐怖的。

         (6). 那么如果使用乐观锁我们如何解决上边的问题呢?

               a. 先执行select操作查询当前数据的数据版本号,比如当前数据版本号是26:

                   select id, resource, state,version from t_resource  where state=1 and id=5780;

               b. 执行更新操作:

                   update t_resoure set state=2, version=27, update_time=now() where resource=xxxxxx and state=1 and version=26

               c. 如果上述update语句真正更新影响到了一行数据,那就说明占位成功。如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。

      3. 通过2中的讲解,相信大家已经对如何基于数据库表做乐观锁有有了一定的了解了,但是这里还是需要说明一下基于数据库表做乐观锁的一些缺点:

          (1). 这种操作方式,使原本一次的update操作,必须变为2次操作: select版本号一次;update一次。增加了数据库操作的次数。

          (2). 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。

          (3). 乐观锁机制往往基于系统中的数据存储逻辑,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,我们应该充分考虑到这些情况出现的可能性,并进行相应调整,如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开。     

      4. 讲了乐观锁的实现方式和缺点,是不是会觉得不敢使用乐观锁了呢???当然不是,在文章开头我自己的业务场景中,场景1和场景2的一部分都使用了基于数据库资源表的乐观锁,已经很好的解决了线上问题。所以大家要根据的具体业务场景选择技术方案,并不是随便找一个足够复杂、足够新潮的技术方案来解决业务问题就是好方案?!比如,如果在我的场景一中,我使用zookeeper做锁,可以这么做,但是真的有必要吗???答案觉得是没有必要的!!!

 

      第四步,使用memcached的add()方法,用于分布式锁:

      对于使用memcached的add()方法做分布式锁,这个在互联网公司是一种比较常见的方式,而且基本上可以解决自己手头上的大部分应用场景。在使用这个方法之前,只要能搞明白memcached的add()和set()的区别,并且知道为什么能用add()方法做分布式锁就好。如果还不知道add()和set()方法,请直接百度吧,这个需要自己了解一下。

      我在这里想说明的是另外一个问题,人们在关注分布式锁设计的好坏时,还会重点关注这样一个问题,那就是是否可以避免死锁问题???!!!

      如果使用memcached的add()命令对资源占位成功了,那么是不是就完事儿了呢?当然不是!我们需要在add()的使用指定当前添加的这个key的有效时间,如果不指定有效时间,正常情况下,你可以在执行完自己的业务后,使用delete方法将这个key删除掉,也就是释放了占用的资源。但是,如果在占位成功后,memecached或者自己的业务服务器发生宕机了,那么这个资源将无法得到释放。所以通过对key设置超时时间,即便发生了宕机的情况,也不会将资源一直占用,可以避免死锁的问题。

     

      第五步,使用memcached的cas()方法,用于分布式锁:     

      下篇文章我们再细说!

 

      第六步,使用redis的setnx()、expire()方法,用于分布式锁:

      对于使用redis的setnx()、expire()来实现分布式锁,这个方案相对于memcached()的add()方案,redis占优势的是,其支持的数据类型更多,而memcached只支持String一种数据类型。除此之外,无论是从性能上来说,还是操作方便性来说,其实都没有太多的差异,完全看你的选择,比如公司中用哪个比较多,你就可以用哪个。

      首先说明一下setnx()命令,setnx的含义就是SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果key不存在,则设置当前key成功,返回1;如果当前key已经存在,则设置当前key失败,返回0。但是要注意的是setnx命令不能设置key的超时时间,只能通过expire()来对key设置。

      具体的使用步骤如下:

      1. setnx(lockkey, 1)  如果返回0,则说明占位失败;如果返回1,则说明占位成功

      2. expire()命令对lockkey设置超时时间,为的是避免死锁问题。

      3. 执行完业务代码后,可以通过delete命令删除key。

      这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用redis的setnx()、get()和getset()方法来实现分布式锁。   

 

      第七步,使用redis的setnx()、get()、getset()方法,用于分布式锁:

      这个方案的背景主要是在setnx()和expire()的方案上针对可能存在的死锁问题,做了一版优化。

      那么先说明一下这三个命令,对于setnx()和get()这两个命令,相信不用再多说什么。那么getset()命令?这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。假设key原来是不存在的,那么多次执行这个命令,会出现下边的效果:

      1. getset(key, "value1")  返回nil   此时key的值会被设置为value1

      2. getset(key, "value2")  返回value1   此时key的值会被设置为value2

      3. 依次类推!

      介绍完要使用的命令后,具体的使用步骤如下:

      1. setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向2。

      2. get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向3。

      3. 计算newExpireTime=当前时间+过期超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime。

      4. 判断currentExpireTime与oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

      5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

      注意: 这个方案我当初在线上使用的时候是没有问题的,所以当初写这篇文章时也认为是没有问题的。但是截止到2017.05.13(周六),自己在重新回顾这篇文章时,看了文章下网友的很多评论,我发现有两个问题比较集中:

      问题1:  在“get(lockkey)获取值oldExpireTime ”这个操作与“getset(lockkey, newExpireTime) ”这个操作之间,如果有N个线程在get操作获取到相同的oldExpireTime后,然后都去getset,会不会返回的newExpireTime都是一样的,都会是成功,进而都获取到锁???

      我认为这套方案是不存在这个问题的。依据有两条: 第一,redis是单进程单线程模式,串行执行命令。 第二,在串行执行的前提条件下,getset之后会比较返回的currentExpireTime与oldExpireTime 是否相等。

      问题2: 在“get(lockkey)获取值oldExpireTime ”这个操作与“getset(lockkey, newExpireTime) ”这个操作之间,如果有N个线程在get操作获取到相同的oldExpireTime后,然后都去getset,假设第1个线程获取锁成功,其他锁获取失败,但是获取锁失败的线程它发起的getset命令确实执行了,这样会不会造成第一个获取锁的线程设置的锁超时时间一直在延长???

      我认为这套方案确实存在这个问题的可能。但我个人认为这个微笑的误差是可以忽略的,不过技术方案上存在缺陷,大家可以自行抉择哈。

 

      第八步,使用redis的watch、multi、exec命令,用于分布式锁:

      下篇文章我们再细说!

 

      第九步,使用zookeeper,用于分布式锁:

      下篇文章我们再细说!

 

      第十步,总结:

      综上,关于分布式锁的第一篇文章我就写到这儿了,在文章中主要说明了日常项目中会比较常用到四种方案,大家掌握了这四种方案,其实在日常的工作中就可以解决很多业务场景下的分布式锁的问题。从文章开头我自己的实际使用中,也可以看到,这么说完全是有一定的依据。对于另外那三种方案,我会在下一篇关于分布式锁的文章中,和大家再探讨一下。

      常用的四种方案:

      1. 基于数据库表做乐观锁,用于分布式锁。

      2. 使用memcached的add()方法,用于分布式锁。

      3. 使用redis的setnx()、expire()方法,用于分布式锁。

      4. 使用redis的setnx()、get()、getset()方法,用于分布式锁。

      不常用但是可以用于技术方案探讨的:

      1. 使用memcached的cas()方法,用于分布式锁。 

      2. 使用redis的watch、multi、exec命令,用于分布式锁。

      3. 使用zookeeper,用于分布式锁。

 

      转载请注明来自博客园http://www.cnblogs.com/PurpleDream/p/5559352.html ,版权归本人和博客园所有,谢谢!

 

分类: Java,分布式锁
标签: NoSql, Memcached, Java, 分布式锁, Redis
好文要顶 关注我 收藏该文
Sky_YiBai
关注 - 32
粉丝 - 82
+加关注
50
1
« 上一篇:Flume1 初识Flume和虚拟机搭建Flume环境
» 下一篇:分布式锁2 Java非常用技术方案探讨之ZooKeeper
posted on 2016-06-05 00:39 Sky_YiBai 阅读(20758) 评论(35) 编辑 收藏

评论:
#1楼 2016-06-05 01:05 | theskyhumanking  
好文,mark!
支持(0)反对(0)
  
#2楼 2016-06-05 10:34 | 寻风问雨  
写的不错哦
支持(0)反对(0)
  
#3楼 2016-06-05 10:48 | 大新博客  
ReentrantLock的lock和unlock要求必须是在同一线程进行,而分布式应用中,lock和unlock是两次不相关的请求,因此肯定不是同一线程,因此导致无法使用ReentrantLock。

应该是同一进程内有效吧
支持(1)反对(0)
  
#4楼[楼主] 2016-06-05 14:49 | Sky_YiBai  
@大新博客
是线程哦~,进程有多个线程,lock被一个线程锁定后,需要获取这个锁定的线程执行unlock才能解锁哈~
支持(1)反对(1)
  
#5楼[楼主] 2016-06-05 14:49 | Sky_YiBai  
@寻风问雨
哈哈,谢谢夸奖~
支持(0)反对(0)
  
#6楼 2016-06-06 10:28 | AndioM  
memcached和redis的添加操作可以用来做分布式锁,添加成功返回true,如果已经添加了未过期返回false,这个方法就能完成分布式锁的功能了
支持(0)反对(0)
  
#7楼 2016-06-06 10:33 | 吴瑞祥  
分布式锁这种东西.照着环形令牌网来做就是了.前人早就想到各种可能性.
支持(0)反对(0)
  
#8楼 2016-06-06 11:01 | sonellee  
"使用redis的setnx()、get()、getset()方法,用于分布式锁"这种方式,可能存在某个请求处理时间较长,而设置的超时也足够长的情况下,由于第4步,是不是存在这样的可能, 某个新请求设置的超时时刻被不断的延长呢?
支持(0)反对(0)
  
#9楼 2016-06-06 16:25 | 海淀游民  
@吴瑞祥
第一次听说环形令牌网的方案,能介绍下它怎么应用在多服务实例的环境中吗?或者什么库实现了相关算法?
支持(0)反对(0)
  
#10楼 2016-06-06 22:45 | 朱小杰  
很不错,收藏了。
支持(0)反对(0)
  
#11楼[楼主] 2016-06-06 23:48 | Sky_YiBai  
@sonellee
hi,你说的这个问题我没太弄明白你说的意思哈。第4步的主要操作,是要看一下,第三步getset之后,是否是当前线程占用了当前锁。锁对应的时间值每次都是根据获得的锁自己设置的。有疑问的话,说出来,咱俩继续讨论~
支持(0)反对(0)
  
#12楼[楼主] 2016-06-06 23:50 | Sky_YiBai  
@吴瑞祥
环形令牌网这个还真没想过,有点儿困惑,有时间的话多说说哈
支持(0)反对(0)
  
#13楼 2016-06-07 09:00 | victor596  
ZK最适合,但好像有一点延迟
支持(0)反对(0)
  
#14楼 2016-06-07 09:23 | 程序诗人  
场景三的单台qps达到6000,这是难以想象啊,也就是说一天以内的话,差不多整个地球都访问了这网站一个遍了。
支持(0)反对(0)
  
#15楼 2016-06-07 09:23 | 程序诗人  
就算按照单个页面10kb计算,你的网络带宽能达到吗?
支持(0)反对(0)
  
#16楼 2016-06-07 10:16 | 阿斯兰  
既然是分布式锁,必定是跨进程或跨网络的,db的version实现算是乐观锁了,分布式锁和分布式事务有点像
支持(0)反对(0)
  
#17楼 2016-06-07 13:20 | TechLife  
好文要顶,尤其是使用memcache的原子特性,在满足中小型应用时绰绰有余
支持(0)反对(0)
  
#18楼[楼主] 2016-06-07 15:26 | Sky_YiBai  
@程序诗人
哈哈,我那个提供的是服务,公司的pc、m和app端用户中心只要展现都会调用这个接口,所以峰值是可以达到的~
支持(0)反对(0)
  
#19楼[楼主] 2016-06-07 15:27 | Sky_YiBai  
@程序诗人
服务接口哦,json数据传递
支持(0)反对(0)
  
#20楼 2016-06-07 17:23 | apodemakeles  
您的redis是单点还是复制集或者是分片?
支持(0)反对(0)
  
#21楼[楼主] 2016-06-08 17:57 | Sky_YiBai  
@apodemakeles
hi, 不是单点哈,场景三 一主一从 内存8G左右
支持(0)反对(0)
  
#22楼 2016-06-12 14:06 | nicky0227  
mark一下,虽然没接触过,但对此内容很感兴趣
支持(0)反对(0)
  
#23楼 2016-06-12 15:34 | dawdff  
@nicky02
大的
支持(0)反对(0)
  
#24楼 2016-06-23 03:35 | NewSea  
学习了
支持(0)反对(0)
  
#25楼 2016-07-20 13:57 | Code&God  
博主你好.
对于第七步的分布式锁,是不是也会存在一些问题.
在第二步get操作和第三步的getset操作之间其实是有时间差的.
比如存在Server A和Server B两台机器.
如果按照Server A get操作.Server B get操作.然后Server A getset,Server B getset,的确是可以成功的.
但是如果操作序列是Server A get旧值,接着Server A getset新值,获得锁.而Server B get到Server A getset的新值,接着Server B getset值,发现也可以获得锁.
毕竟,没有办法保证两台服务器的get操作一定都能在getset操作之前啊.
支持(0)反对(0)
  
#26楼 2016-08-18 18:06 | Bonnie_bao  
好文要顶!
支持(0)反对(0)
  
#27楼 2016-08-26 18:29 | openHeart  
下一篇关于分布式锁的文章,楼主咋还没写呢
支持(0)反对(0)
  
#28楼 2016-09-27 14:57 | 把酒临风  
如果大于锁设置的超时时间,则不需要再锁进行处理。

这是否可以理解为超时时间设置不当,或者业务异常,
导致没有完成业务需要的锁?
支持(0)反对(0)
  
#29楼 2016-11-05 10:47 | 小不了  
请问可以转载吗?
支持(0)反对(0)
  
#30楼 2016-11-17 11:00 | FirstClass  
@Code&God
我认为是没有问题的
如你所说ServerB.get到值以后会去跟当前系统时间比较,如果大小当前系统时间,说明锁还在被ServerA占用,并且没有超时,此时ServerB是抢占失败的,不会获得此锁
支持(0)反对(0)
  
#31楼 2016-12-13 15:02 | TomGao  
第七种,每次get 或者getset是不是需要判断下锁是否还存在?
支持(0)反对(0)
  
#32楼 2017-02-26 11:52 | Lion_  
@Sky_YiBai
由于第三步已经getSet到新值,第四步根据第三步获取到的old时间比较,如果不相等获取所失败,虽然获取失败,但是已经把当前线程的新的时间值给设进去了,这点我觉得是不是有问题
支持(0)反对(0)
  
#33楼 2017-05-27 16:35 | lijf93  
博主你好,
第七步,使用redis的setnx()、get()、getset()方法,用于分布式锁的步骤5,
5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

如果大于锁设置的过期时间,但业务还没有处理完的时候,假设这时候有一个请求X要获取该锁,按照这个设计,请求X应该就可以获取到该锁了吧?这时候就有两个请求同事持有该锁,这样不会有问题吗?
支持(0)反对(0)
  
#34楼 2017-07-28 16:58 | 只会一点java  
@Sky_YiBai
但对于锁来说,该线程释放锁之后,其他线程是可以获取到锁的,所以这样描述更恰当:可重入锁,锁住的是线程原子操作,但能提供一种机制,让进程内线程进行串行互斥操作。(因为同一段代码是运行在一个JVM中而不是一个线程中)分布式锁就是用来解决跨进程(JVM)锁住资源的。
支持(0)反对(0)
  
#35楼 2017-08-14 11:48 | 打一个情  
mark,分布式锁的场景还没怎么解除,很期待有实际场景可以实战一下。

原文地址:http://www.cnblogs.com/PurpleDream/p/5559352.html

你可能感兴趣的:(分布式,Java,分布式锁,分布式&中间件)