分布式锁的研究

分布式锁的使用场景和尝试解决的问题

详情请看 分布式锁的研究

在单进程的系统中,当存在多个线程可以同时改变某个变量时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性有先后顺序的执行消除并发修改变量。我们在单进程中往往采用同步锁的方式保证多线程的同步,但是在分布式系统中,各个系统的线程不是在同一进程甚至不在同一台主机上执行,这时常用的同步加锁方式就不再适用了。为了保证分布式系统中多系统并发修改的数据安全性问题,分布式锁是必不可少的,同步的本质是通过锁来实现的,而锁的本质是为各个线程提供一个可访问的标记使线程们都按照这个标记来协调自己与其他线程的执行动作。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做上个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待已经拥有标记的线程结束同步代码块并取消标记后再去尝试设置标记。

分布式环境下,数据一致性问题一直是一个比较重要的话题,而又不同于单进程的情况。分布式与单机情况下最大的不同在于其不是多线程而是多进程。多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁解锁。
  • 具有容错性。只要大部分的保存锁的数据库节点正常运行,客户端就可以加锁和解锁。
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,正常情况下客户端自己不能把别人加的锁给解了。

例如:

例子1: 分布式系统中,有两个独立子系统。在redis中存放了某个用户的账户余额,假设100(用户id:余额)

A端需要对用户扣费-1,需要两步:

  1. A1.将该用户的目前余额取出来(100)
  2. A2.将余额扣除一部分(99)后再插入到redis中

B端需要对用户充值+10,需要两步:

  1. B1.将该用户的目前余额取出来(99)
  2. B2.将余额添加充值额度(109)后再插入到redis中

我们的期望执行顺序是A1、A2、B1、B2 结果就会是109. 但是如果不加锁,就会出现A1、B1、A2、B2(110)或者其他各种随机情况,这样就会造成数据错误。

例子2:

常见的是秒杀场景,订单服务部署了多个实例。如秒杀商品有4个,第一个用户购买3个,第二个用户购买2个,理想状态下第一个用户能购买成功,第二个用户提示购买失败,反之亦可。而实际可能出现的情况是,两个用户都得到库存为4,第一个用户买到了3个,更新库存之前,第二个用户下了2个商品的订单,更新库存为2,导致出错。

在上面的这些场景中,面对高并发情形,需要保证对资源的访问互斥。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。分布式系统中,由于分布式系统的分布性,即多线程和多进程并且分布在不同机器中,synchronized和lock这两种锁将失去原有锁的效果,需要我们自己实现分布式锁

常见的锁方案

为了保证数据库ACID特性,分布式系统的实现往往有很多手段,由于分布式系统的技术多样化,一般没有一个绝对完美的解决方式,但是他们的思路都是一样:保证数据的一致性和并发修改的互斥。

为了实现上面的条件,其实有两种方式:

  1. 把大并发事件由并行直接变成串行的,例如使用消息队列(MQ),秒杀系统进行抢购时的削峰直接把海量的抢购请求变成串行的消息队列。这种方式很适合非实时场景,就是用户点击抢购后并不期待立即或者可以忍受一点点排队时间延迟。
  2. 采用分布式锁,多系统中各个线程互相抢占,先得锁先执行。

本文只对分布式锁做讨论。

常见的锁方案有

  • 基于关系型数据库实现分布式锁 ,如mysql,oracle.
  • 基于缓存或者关系型数据库保存锁标记位,实现分布式锁,如redis,memcache缓存锁标记(推荐),mysql,oracle单独建表保存锁标记。
  • 基于Zookeeper实现分布式锁

基于关系型数据库实现分布式锁

我们先简单讨论关系型数据库的锁实现,以mysql为例.

在传统的关系型数据库中为了保证数据的安全。乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。其实不仅仅是关系型数据库系统中有乐观锁和悲观锁的概念,像memcache、hibernate、tair等都有类似的概念,只是细节不如关系型数据库完整而已。

针对于不同的业务场景,应该选用不同的并发控制方式。所以,不要把乐观并发控制和悲观并发控制狭义的理解为DBMS中的概念,更不要把他们和数据中提供的锁机制(行锁、表锁、排他锁、共享锁)混为一谈。其次,在DBMS中,悲观锁正是利用数据库本身提供的锁机制来实现的。

下面来分别学习一下悲观锁和乐观锁。

悲观锁

在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作在某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。

悲观锁正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。悲观锁的特点是先获取锁,再进行业务操作,即“悲观”的认为获取锁是非常有可能失败的,因此要先确保获取锁成功再进行业务操作。通常所说的“一锁二查三更新”即指的是使用悲观锁。通常来讲在数据库上的悲观锁需要数据库本身提供支持,即通过常用的select … for update操作来实现悲观锁。当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select .... for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。select ... for update获取的行锁会在当前事务结束时自动释放,因此必须在事务中使用

在数据库中,悲观锁的流程如下(一锁二查三更新):

  1. 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
  2. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
  3. 完成事务主动解锁.

MySQL InnoDB中使用悲观锁

要使用悲观锁,我们必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。set autocommit=0;

//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息
select status from t_goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status2
update t_goods set status=2;
//4.提交事务
commit;/commit work;

上面的查询语句中,我们使用了select…for update的方式,这样就通过开启排他锁的方式实现了悲观锁。此时在t_goods表中,id为1的那条数据就被我们锁定了,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。上面我们提到,使用select…for update会把数据给锁住,不过我们需要注意一些锁的级别,MySQL InnoDB默认行级锁。行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。

优点与不足

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,不论锁的开销,仅就一锁二查三更新的流程来讲,修改时一定会有查询操作,所以1次修改会有2次数据库操作而普通修改直接就是一次update操作,还有增加产生死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数

乐观锁

在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。

乐观锁( Optimistic Locking )相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据

实现数据版本有两种方式,第一种是使用版本号,第二种是使用时间戳。

例如使用版本号实现乐观锁,使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。

1.查询出商品信息
select (status,status,version) from t_goods where id=#{id}
2.根据商品信息生成订单
3.修改商品status2
update t_goods set status=2,version=version+1 where id=#{id} and version=#{version};

优点与不足

乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题,必然会有一个事务后发生导致版本不一致导致回滚,这在 发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中不适用,但是在回滚概率不多的场景下效果很好。

基于缓存或者关系型数据库保存锁标记位

使用关系型数据库实现的分布式锁机制在运用广泛,但是相比于基于缓存的新型NoSQl数据库例如Redis,memcahed相比,数据的CURD吞吐量没有优势,时间延迟也略高。基于缓存来实现在性能方面会表现的更好一点,存取速度快很多。而且很多缓存也是可以集群部署的,可以解决单点问题。基于缓存的锁有好几种,如memcached、redis、本文下面主要讲解基于redis的分布式实现,附带其它实现方式。

据统计,目前实用的采用保存锁标记位实现分布式的方式有如下几种

常用的四种方案:

  1. 基于数据库表做乐观锁,用于分布式锁。(上文提及,传统关系型存取性能不及基于内存的NoSql,一般用在没有redis这类内存型数据库的简单的分布式系统上,思路与下面几种完全一样)
  2. 使用memcached的add()方法,用于分布式锁。
  3. 使用redis的setnx()、expire()方法,用于分布式锁。(有缺点)
  4. 使用redis的setnx()、get()、getset()方法,用于分布式锁。(比较完美)
  5. 使用zookeeper,用于分布式锁。

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

  1. 使用memcached的cas()方法,用于分布式锁。
  2. 使用redis的watch、multi、exec命令,用于分布式锁。

memcahed 缓存方案(原理与重点讲的Redis缓存方案一样,本次简单提及,用于打开思路)

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

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

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

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

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

Memcached CAS(Check-And-Set 或 Compare-And-Swap) 命令用于执行一个"检查并设置"的操作它仅在当前客户端最后一次取值后,该key 对应的值没有被其他客户端修改的情况下, 才能够将值写入。与Memcached add()类似,思路完全一样。

redis 缓存方案(目前主流方案,思路可以完全使用在其他缓存锁标志方案上)

使用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(key, value),如果返回0,则说明占位失败;如果返回1,则说明占位成功,获取锁成功进入第二步;如果返回0,说明占位失败尝试重新获取锁。注意这种方式不是很关心key的value而是关心key的存在性,所以value没有规定可以随便填。
  2. expire()命令对key设置超时时间,为的是避免死锁问题,例如线程获取锁了并且加锁成功但是后来崩溃或者失去与redis数据库的连接,这时过期时间到了会自己释放锁。
  3. 执行完业务代码后,可以通过delete命令删除key,获取到锁的线程主动释放锁。

这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,并不是没有缺点,可能还有一些可以完善的地方。由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。比如,如果在第一步setnx执行成功后,在expire()命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用redis的setnx()、get()和getset()方法来实现分布式锁。。

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

这个方案的背景主要是在setnx()和expire()的方案上针对可能存在的死锁问题,做了一版优化。区别主要在过期时间的设置上,讲上一种setnx(key,value)由不关心key的value而是关心key的存在性变成既关心key的纯在性又关心value的值,value的值作为过期时间

那么先说明一下这三个命令,对于setnx()和get()这两个命令,相信不用再多说什么。那么getset()命令?这个命令主要有两个参数,getset(key,value)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。假设key原来是不存在的,会直接设key值为value,返回nil;假设key存在就更新值为value,返回旧值。

例如:

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

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

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

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

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

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

  5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行delete释放锁;如果大于锁设置的超时时间,则可以不需要再进行锁进行处理。当然也可以直接就在执行结束后直接释放锁。注意过期时间的设置不能少于执行所需要的时间,这点很重要,不然执行期间可能会被其他线程误以为时间过期而去释放锁

注意: 这个方案我发现有两个问题

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

我认为这套方案是不存在这个问题的。依据如下: redis是单进程单线程模式,串行执行命令。在串行执行的前提条件下,getset之后再比较返回的expireTime与oldExpireTime 是否相等,这时绝对不会存在这个问题,因为N个线程对redis是串行的,一个线程getset后数据已经更新后面的线程不可能取到相同的newExpireTime。

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

我认为这套方案确实存在这个问题的可能,甚至本身就是存在这个问题。但是我认为这个问题没什么影响。依据如下:我们假设解锁加锁的开销为lock_time,分布式子系统中的N个线程的最大执行时间为t_max,最短执行时间为t_min,具体的执行时间为t_exe,过期时间为delay_time。那么可以计算出从上一个线程执行完毕自己解锁或者过期开始,N个线程开始抢占时到抢占结束时间为lock_time,抢占后线程执行的时间为t_exe,期间例外的N-1的线程执行getset,那么最长的过期时间点为:t=lock_time+t_exe+delay_time,一般delay_time取略大于t_max的值保证所有线程都有足够时间执行完。而获取了锁的线程在t=lock_time+delay_time时会让key过期或者在t=lock_time+t_exe时释放锁。在获取锁到释放锁本身就不应该长期占有锁,而且t_exe不会太久且小于t_max,这意味着最坏的情况下实际的过期时间会略多一点,多的时间误差在t_exe到t_delay之间。我个人认为这个微小的误差是可以忽略的,不过技术方案上确实存在这个缺陷。

问题3在分布式系统的各个子系统中,如果时间不同步的话,过期时间会出现错乱。

我认为如果是同一个局域网或机房的计算集群的话,问题可能不大,但是跨机房跨地域的话这个问题很严重,基本无法解决,这也是这个方案最大的问题,网络上出现的最多的版本就是这种,如果是单机部署多个子系统或者少量同局域网的集群下问题可能不会触发,这可能也是这个方案没有太多人质疑的原因。如果跨地区跨机房的场景这个方案不可以用。总之,有这个几个问题:1.由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。2.当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。网上之所以有人这样实现,也是因为低版本的jedis并不支持多参数的set()方法,即使后面两个缺点可以容忍,但是第一个问题,即服务器时间同步的问题,很难完美解决。

使用redis,用于分布式锁(改进方案 推荐)

上面的两种redis方案,都有一定的问题,主要共同原因是设置操作setnx与expire是单独的两条命令,不是原子性的。为了解决它才出现第二种方式去试图解决这个问题。然而第二个问题在于多个服务器时间同步非常不好实现,机器越多这个方案越差甚至影响正常使用。

之前参考的很多博客,关于redis加锁都是先setNX()获取锁,然后再setExpire()设置锁的有效时间。然而这样的话获取锁的操作就不是原子性的了,如果setNX后系统宕机,就会造成锁死,系统阻塞。 根据官方的推荐(http://redisdoc.com/string/set.html),最好使用set命令:SET key value [EX seconds] [PX milliseconds] [NX|XX]EXPX设置有效时间NX属性的作用就是如果key存在就返回失败,否则插入数据。从Redis2.6.12版本开始, SET 命令可以通过参数来实现和SETNX、SETEX和PSETEX三个命令的效果。这样我们的可以将加锁操作用一个set命令来实现,直接是原子性操作,既没有死锁的风险,也不依赖服务器时间同步,可以完美解决这两个问题。需要注意的是:在Redis 2.6.12之前,set只能返回OK,所以无法判断操作是否成功,所以也就不适用。如果使用的是spring-boot-starter-data-redis依赖,那么在2.x版本之前的接口也不支持上述的set操作

具体的使用步骤如下:

客户端执行以上的命令:

  • 如果服务器返回 OK ,那么这个客户端获得锁。
  • 如果服务器返回 NIL ,那么客户端获取锁失败,可以在稍后再重试。
  • 设置的过期时间到达之后,锁将自动释放。

可以通过以下修改,让这个锁实现更健壮:

  • 不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)。
  • 不使用 DEL 命令来释放锁,而是发送一个Lua脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。这两个改动可以防止持有过期锁的客户端误删现有锁的情况出现。

附带样例代码:

package com.tcl.thread;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;

import org.apache.tomcat.util.codec.binary.StringUtils;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;

import junit.framework.Assert;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisCluster;

/**
 * Redis分布式锁 使用 SET resource-name anystring NX EX max-lock-time 实现
 * <p>
 * 该方案在 Redis 官方 SET 命令页有详细介绍。 http://doc.redisfans.com/string/set.html
 * <p>
 * 在介绍该分布式锁设计之前,我们先来看一下在从 Redis 2.6.12 开始 SET 提供的新特性, 命令 SET key value [EX
 * seconds] [PX milliseconds] [NX|XX],其中:
 * <p>
 * EX seconds  以秒为单位设置 key 的过期时间; PX milliseconds  以毫秒为单位设置 key 的过期时间; NX 
 * key 的值设为value ,当且仅当key 不存在,等效于 SETNX XX  key 的值设为value ,当且仅当key 存在,等效于
 * SETEX
 * <p>
 * 命令 SET resource-name anystring NX EX max-lock-time 是一种在 Redis 中实现锁的简单方法。
 * <p>
 * 客户端执行以上的命令:
 * <p>
 * 如果服务器返回 OK ,那么这个客户端获得锁。 如果服务器返回 NIL ,那么客户端获取锁失败,可以在稍后再重试。
 */
public class RedisLock3 {

	private static Logger logger = LoggerFactory.getLogger(RedisLock3.class);

	private StringRedisTemplate redisTemplate;

	/**
	 * key 的值设为value ,当且仅当key 不存在设置成功,等效于 SETNX
	 */
	public static final String NX = "NX";

	/**
	 * seconds  以秒为单位设置 key 的过期时间,等效于EXPIRE key seconds
	 */
	public static final String EX = "EX";

	/**
	 * 调用set后的返回值
	 */
	public static final String OK = "OK";

	/**
	 * 默认请求锁的超时时间(ms 毫秒)
	 */
	private static final long TIME_OUT = 100;

	/**
	 * 默认锁的有效时间(s)
	 */
	public static final int EXPIRE = 60;

	/**
	 * 解锁的lua脚本
	 */
	public static final String UNLOCK_LUA;

	static {
		StringBuilder sb = new StringBuilder();
		sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] ");
		sb.append("then ");
		sb.append("    return redis.call(\"del\",KEYS[1]) ");
		sb.append("else ");
		sb.append("    return 0 ");
		sb.append("end ");
		UNLOCK_LUA = sb.toString();
	}

	/**
	 * 锁标志对应的key
	 */
	private String lockKey;

	/**
	 * 记录到日志的锁标志对应的key
	 */
	private String lockKeyLog = "";

	/**
	 * 锁对应的值
	 */
	private String lockValue;

	/**
	 * 锁的有效时间(s)
	 */
	private int expireTime = EXPIRE;

	/**
	 * 请求锁的超时时间(ms)
	 */
	private long timeOut = TIME_OUT;

	/**
	 * 锁标记
	 */
	private volatile boolean locked = false;

	final Random random = new Random();

	/**
	 * 构造函数 使用默认的锁过期时间和请求锁的超时时间
	 *
	 * @param redisTemplate
	 * @param lockKey
	 *            锁的keyRedisKey
	 */
	public RedisLock3(StringRedisTemplate redisTemplate, String lockKey) {
		this.redisTemplate = redisTemplate;
		this.lockKey = lockKey + "_lock";
	}

	/**
	 * 构造函数 使用默认的请求锁的超时时间,指定锁的过期时间
	 *
	 * @param redisTemplate
	 * @param lockKey
	 *            锁的keyRedisKey
	 * @param expireTime
	 *            锁的过期时间(单位:秒)
	 */
	public RedisLock3(StringRedisTemplate redisTemplate, String lockKey, int expireTime) {
		this(redisTemplate, lockKey);
		this.expireTime = expireTime;
	}

	/**
	 * 构造函数 使用默认的锁的过期时间,指定请求锁的超时时间
	 *
	 * @param redisTemplate
	 * @param lockKey
	 *            锁的keyRedisKey
	 * @param timeOut
	 *            请求锁的超时时间(单位:毫秒)
	 */
	public RedisLock3(StringRedisTemplate redisTemplate, String lockKey, long timeOut) {
		this(redisTemplate, lockKey);
		this.timeOut = timeOut;
	}

	/**
	 * 锁的过期时间和请求锁的超时时间都是用指定的值
	 *
	 * @param redisTemplate
	 * @param lockKey
	 *            锁的keyRedisKey
	 * @param expireTime
	 *            锁的过期时间(单位:秒)
	 * @param timeOut
	 *            请求锁的超时时间(单位:毫秒)
	 */
	public RedisLock3(StringRedisTemplate redisTemplate, String lockKey, int expireTime, long timeOut) {
		this(redisTemplate, lockKey, expireTime);
		this.timeOut = timeOut;
	}

	/**
	 * 尝试获取锁 超时返回
	 *
	 * @return
	 */
	public boolean tryLock() {
		// 生成随机key
		lockValue = UUID.randomUUID().toString();
		// 请求锁超时时间,纳秒
		long timeout = timeOut * 1000000;
		// 系统当前时间,纳秒
		long nowTime = System.nanoTime();
		while ((System.nanoTime() - nowTime) < timeout) {
			if (OK.equalsIgnoreCase(this.set(lockKey, lockValue, expireTime))) {
				locked = true;
				// 上锁成功结束请求
				return true;
			}

			// 每次请求等待一段时间
			seleep(10, 50000);
		}
		return locked;
	}

	/**
	 * 尝试获取锁 立即返回
	 *
	 * @return 是否成功获得锁
	 */
	public boolean lock() {
		lockValue = UUID.randomUUID().toString();
		// 不存在则添加 且设置过期时间(单位ms
		String result = set(lockKey, lockValue, expireTime);
		return OK.equalsIgnoreCase(result);
	}

	/**
	 * 以阻塞方式的获取锁
	 *
	 * @return 是否成功获得锁
	 */
	public boolean lockBlock() {
		lockValue = UUID.randomUUID().toString();
		while (true) {
			// 不存在则添加 且设置过期时间(单位ms
			String result = set(lockKey, lockValue, expireTime);
			if (OK.equalsIgnoreCase(result)) {
				return true;
			}

			// 每次请求等待一段时间
			seleep(10, 50000);
		}
	}

	/**
	 * 解锁
	 * <p>
	 * 可以通过以下修改,让这个锁实现更健壮:
	 * <p>
	 * 不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)。 不使用 DEL
	 * 命令来释放锁,而是发送一个 Lua 脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。
	 * 这两个改动可以防止持有过期锁的客户端误删现有锁的情况出现。
	 */
	public Boolean unlock() {
		// 只有加锁成功并且锁还有效才去释放锁
		if (locked) {
			return redisTemplate.execute(new RedisCallback<Boolean>() {
				@Override
				public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
					Object nativeConnection = connection.getNativeConnection();
					Long result = 0L;

					List<String> keys = new ArrayList<>();
					keys.add(lockKey);
					List<String> values = new ArrayList<>();
					values.add(lockValue);

					// 集群模式
					if (nativeConnection instanceof JedisCluster) {
						result = (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, values);
					}

					// 单机模式
					if (nativeConnection instanceof Jedis) {
						result = (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, values);
					}

					if (result == 0 && !StringUtils.isEmpty(lockKeyLog)) {
						logger.info("Redis分布式锁,解锁{}失败!解锁时间:{}", lockKeyLog, System.currentTimeMillis());
					}

					locked = result == 0;
					return result == 1;
				}
			});
		}

		return true;
	}

	/**
	 * 重写redisTemplateset方法
	 * <p>
	 * 命令 SET resource-name anystring NX EX max-lock-time 是一种在 Redis 中实现锁的简单方法。
	 * <p>
	 * 客户端执行以上的命令:
	 * <p>
	 * 如果服务器返回 OK ,那么这个客户端获得锁。 如果服务器返回 NIL ,那么客户端获取锁失败,可以在稍后再重试。
	 *
	 * @param key
	 *            锁的Key
	 * @param value
	 *            锁里面的值
	 * @param seconds
	 *            过去时间(秒)
	 * @return
	 */
	private String set(final String key, final String value, final long seconds) {
		Assert.isTrue(!StringUtils.isEmpty(key), "key不能为空");
		return redisTemplate.execute(new RedisCallback<String>() {
			@Override
			public String doInRedis(RedisConnection connection) throws DataAccessException {
				Object nativeConnection = connection.getNativeConnection();
				String result = null;
				// 集群模式
				if (nativeConnection instanceof JedisCluster) {
					result = ((JedisCluster) nativeConnection).set(key, value, NX, EX, seconds);
				}
				// 单机模式
				if (nativeConnection instanceof Jedis) {
					result = ((Jedis) nativeConnection).set(key, value, NX, EX, seconds);
				}

				if (!StringUtils.isEmpty(lockKeyLog) && !StringUtils.isEmpty(result)) {
					logger.info("获取锁{}的时间:{}", lockKeyLog, System.currentTimeMillis());
				}

				return result;
			}
		});
	}

	/**
	 * @param millis
	 *            毫秒
	 * @param nanos
	 *            纳秒
	 * @Title: seleep
	 * @Description: 线程等待时间
	 * @author yuhao.wang
	 */
	private void seleep(long millis, int nanos) {
		try {
			Thread.sleep(millis, random.nextInt(nanos));
		} catch (InterruptedException e) {
			logger.info("获取分布式锁休眠被中断:", e);
		}
	}

	public String getLockKeyLog() {
		return lockKeyLog;
	}

	public void setLockKeyLog(String lockKeyLog) {
		this.lockKeyLog = lockKeyLog;
	}

	public int getExpireTime() {
		return expireTime;
	}

	public void setExpireTime(int expireTime) {
		this.expireTime = expireTime;
	}

	public long getTimeOut() {
		return timeOut;
	}

	public void setTimeOut(long timeOut) {
		this.timeOut = timeOut;
	}
}

使用方式样例:

public void redisLock3(int i) {
    RedisLock3 redisLock3 = new RedisLock3(redisTemplate, "redisLock:" + i % 10, 5 * 60, 500);
    try {
        long now = System.currentTimeMillis();
        if (redisLock3.tryLock()) {
            logger.info("=" + (System.currentTimeMillis() - now));  
            
            
            // TODO 获取到锁要执行的代码块,在这里执行代码![image](https://note.youdao.com/favicon.ico)
            
            
            logger.info("j:" + j++);
        } else {
            logger.info("k:" + k++);
        }
    } catch (Exception e) {
        logger.info(e.getMessage(), e);
    } finally {
        redisLock2.unlock();
    }
}

Redissan

zookeeper

什么是Zookeeper?

Zookeeper(业界简称zk)是一种提供配置管理、分布式协同以及命名的中心化服务,这些提供的功能都是分布式系统中非常底层且必不可少的基本功能,但是如果自己实现这些功能而且要达到高吞吐、低延迟同时还要保持一致性和可用性,实际上非常困难。因此zookeeper提供了这些功能,开发者在zookeeper之上构建自己的各种分布式系统。

虽然zookeeper的实现比较复杂,但是它提供的模型抽象却是非常简单的。Zookeeper提供一个多层级的节点命名空间(节点称为znode),每个节点都用一个以斜杠(/)分隔的路径表示,而且每个节点都有父节点(根节点除外),非常类似于文件系统。例如,/foo/doo这个表示一个znode,它的父节点为/foo,父父节点为/,而/为根节点没有父节点。与文件系统不同的是,这些节点都可以设置关联的数据,而文件系统中只有文件节点可以存放数据而目录节点不行。Zookeeper为了保证高吞吐和低延迟,在内存中维护了这个树状的目录结构,这种特性使得Zookeeper不能用于存放大量的数据,每个节点的存放数据上限为1M。

而为了保证高可用,zookeeper需要以集群形态来部署,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么zookeeper本身仍然是可用的。客户端在使用zookeeper时,需要知道集群机器列表,通过与集群中的某一台机器建立TCP连接来使用服务,客户端使用这个TCP链接来发送请求、获取结果、获取监听事件以及发送心跳包。如果这个连接异常断开了,客户端可以连接到另外的机器上。

架构简图如下所示:

image

客户端的读请求可以被集群中的任意一台机器处理,如果读请求在节点上注册了监听器,这个监听器也是由所连接的zookeeper机器来处理。对于写请求,这些请求会同时发给其他zookeeper机器并且达成一致后,请求才会返回成功。因此,随着zookeeper的集群机器增多,读请求的吞吐会提高但是写请求的吞吐会下降

有序性是zookeeper中非常重要的一个特性,所有的更新都是全局有序的,每个更新都有一个唯一的时间戳,这个时间戳称为zxid(Zookeeper Transaction Id)。而读请求只会相对于更新有序,也就是读请求的返回结果中会带有这个zookeeper最新的zxid。 如何使用zookeeper实现分布式锁?

在描述算法流程之前,先看下zookeeper中几个关于节点的有趣的性质:

有序节点:假如当前有一个父节点为/lock,我们可以在这个父节点下面创建子节点;zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号,也就是说如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推。 临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点。 事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端。当前zookeeper有如下四种事件:1)节点创建;2)节点删除;3)节点数据修改;4)子节点变更。

下面描述使用zookeeper实现分布式锁的算法流程,假设锁空间的根节点为/lock:

客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。
客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听/lock的子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁;
执行业务代码;
完成业务流程后,删除对应的子节点释放锁。

步骤1中创建的临时节点能够保证在故障的情况下锁也能被释放,考虑这么个场景:假如客户端a当前创建的子节点为序号最小的节点,获得锁之后客户端所在机器宕机了,客户端没有主动删除子节点;如果创建的是永久的节点,那么这个锁永远不会释放,导致死锁;由于创建的是临时节点,客户端宕机后,过了一定时间zookeeper没有收到客户端的心跳包判断会话失效,将临时节点删除从而释放锁。

另外细心的朋友可能会想到,在步骤2中获取子节点列表与设置监听这两步操作的原子性问题,考虑这么个场景:客户端a对应子节点为/lock/lock-0000000000,客户端b对应子节点为/lock/lock-0000000001,客户端b获取子节点列表时发现自己不是序号最小的,但是在设置监听器前客户端a完成业务流程删除了子节点/lock/lock-0000000000,客户端b设置的监听器岂不是丢失了这个事件从而导致永远等待了?这个问题不存在的。因为zookeeper提供的API中设置监听器的操作与读操作是原子执行的,也就是说在读子节点列表时同时设置监听器,保证不会丢失事件。

最后,对于这个算法有个极大的优化点:假如当前有1000个节点在等待锁,如果获得锁的客户端释放锁时,这1000个客户端都会被唤醒,这种情况称为“羊群效应”;在这种羊群效应中,zookeeper需要通知1000个客户端,这会阻塞其他的操作,最好的情况应该只唤醒新的最小节点对应的客户端。应该怎么做呢?在设置事件监听时,每个客户端应该对刚好在它之前的子节点设置事件监听,例如子节点列表为/lock/lock-0000000000、/lock/lock-0000000001、/lock/lock-0000000002,序号为1的客户端监听序号为0的子节点删除消息,序号为2的监听序号为1的子节点删除消息。

所以调整后的分布式锁算法流程如下:

客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。 客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁; 执行业务代码; 完成业务流程后,删除对应的子节点释放锁。

Curator的源码分析

虽然zookeeper原生客户端暴露的API已经非常简洁了,但是实现一个分布式锁还是比较麻烦的…我们可以直接使用curator这个开源项目提供的zookeeper分布式锁实现。

我们只需要引入下面这个包(基于maven):

<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.0.0</version>
</dependency>

然后就可以用啦!代码如下:

package cc.omoz.cms.beans.test.demo;

/**
 * @author think 201874日下午9:38:43
 */
public class Test {
	public static void main(String[] args) throws Exception {
		// 创建zookeeper的客户端
		RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
		CuratorFramework client = CuratorFrameworkFactory
				.newClient("10.21.41.181:2181,10.21.42.47:2181,10.21.49.252:2181", retryPolicy);
		client.start();

		// 创建分布式锁, 锁空间的根节点路径为/curator/lock
		InterProcessMutex mutex = new InterProcessMutex(client, "/curator/lock");
		mutex.acquire();
		// 获得了锁, 进行业务流程
		System.out.println("Enter mutex");
		// 完成业务流程, 释放锁
		mutex.release();

		// 关闭客户端
		client.close();
	}

	// 可以看到关键的核心操作就只有mutex.acquire()mutex.release(),简直太方便了!
	// 下面来分析下获取锁的源码实现。acquire的方法如下:

	/*
	 * 获取锁,当锁被占用时会阻塞等待,这个操作支持同线程的可重入(也就是重复获取锁),acquire的次数需要与release的次数相同。
	 * 
	 * @throws Exception ZK errors, connection interruptions
	 */
	@Override
	public void acquire() throws Exception {
		if (!internalLock(-1, null)) {
			throw new IOException("Lost connection while trying to acquire lock: " + basePath);
		}
	}

	// 这里有个地方需要注意,当与zookeeper通信存在异常时,acquire会直接抛出异常,需要使用者自身做重试策略。代码中调用了internalLock(-1,
	// null),参数表明在锁被占用时永久阻塞等待。internalLock的代码如下:

	private boolean internalLock(long time, TimeUnit unit) throws Exception {

		// 这里处理同线程的可重入性,如果已经获得锁,那么只是在对应的数据结构中增加acquire的次数统计,直接返回成功
		Thread currentThread = Thread.currentThread();
		LockData lockData = threadData.get(currentThread);
		if (lockData != null) {
			// re-entering
			lockData.lockCount.incrementAndGet();
			return true;
		}

		// 这里才真正去zookeeper中获取锁
		String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
		if (lockPath != null) {
			// 获得锁之后,记录当前的线程获得锁的信息,在重入时只需在LockData中增加次数统计即可
			LockData newLockData = new LockData(currentThread, lockPath);
			threadData.put(currentThread, newLockData);
			return true;
		}

		// 在阻塞返回时仍然获取不到锁,这里上下文的处理隐含的意思为zookeeper通信异常
		return false;
	}

	// 代码中增加了具体注释,不做展开。看下zookeeper获取锁的具体实现:

	String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
		// 参数初始化,此处省略
		// ...

		// 自旋获取锁
		while (!isDone) {
			isDone = true;

			try {
				// 在锁空间下创建临时且有序的子节点
				ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
				// 判断是否获得锁(子节点序号最小),获得锁则直接返回,否则阻塞等待前一个子节点删除通知
				hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
			} catch (KeeperException.NoNodeException e) {
				// 对于NoNodeException,代码中确保了只有发生session过期才会在这里抛出NoNodeException,因此这里根据重试策略进行重试
				if (client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++,
						System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper())) {
					isDone = false;
				} else {
					throw e;
				}
			}
		}

		// 如果获得锁则返回该子节点的路径
		if (hasTheLock) {
			return ourPath;
		}

		return null;
	}
}

上面代码中主要有两步操作:

  1. driver.createsTheLock:创建临时且有序的子节点,里面实现比较简单不做展开,主要关注几种节点的模式:1)PERSISTENT(永久);2)PERSISTENT_SEQUENTIAL(永久且有序);3)EPHEMERAL(临时);4)EPHEMERAL_SEQUENTIAL(临时且有序)。
  2. internalLockLoop:阻塞等待直到获得锁。

看下internalLockLoop是怎么判断锁以及阻塞等待的,这里删除了一些无关代码,只保留主流程:

// 自旋直至获得锁
while ((client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock) {
	// 获取所有的子节点列表,并且按序号从小到大排序
	List<String> children = getSortedChildren();

	// 根据序号判断当前子节点是否为最小子节点
	String sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
	PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
	if (predicateResults.getsTheLock()) {
		// 如果为最小子节点则认为获得锁
		haveTheLock = true;
	} else {
		// 否则获取前一个子节点
		String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();

		// 这里使用对象监视器做线程同步,当获取不到锁时监听前一个子节点删除消息并且进行wait(),当前一个子节点删除(也就是锁释放)时,回调会通过notifyAll唤醒此线程,此线程继续自旋判断是否获得锁
		synchronized (this) {
			try {
				// 这里使用getData()接口而不是checkExists()是因为,如果前一个子节点已经被删除了那么会抛出异常而且不会设置事件监听器,而checkExists虽然也可以获取到节点是否存在的信息但是同时设置了监听器,这个监听器其实永远不会触发,对于zookeeper来说属于资源泄露
				client.getData().usingWatcher(watcher).forPath(previousSequencePath);

				// 如果设置了阻塞等待的时间
				if (millisToWait != null) {
					millisToWait -= (System.currentTimeMillis() - startMillis);
					startMillis = System.currentTimeMillis();
					if (millisToWait <= 0) {
						doDelete = true; // 等待时间到达,删除对应的子节点
						break;
					}

					// 等待相应的时间
					wait(millisToWait);
				} else {
					// 永远等待
					wait();
				}
			} catch (KeeperException.NoNodeException e) {
				// 上面使用getData来设置监听器时,如果前一个子节点已经被删除那么会抛出NoNodeException,只需要自旋一次即可,无需额外处理
			}
		}
	}
}

具体逻辑见注释,不再赘述。代码中设置的事件监听器,在事件发生回调时只是简单的notifyAll唤醒当前线程以重新自旋判断,比较简单不再展开

猜你喜欢

转载自blog.csdn.net/thinktik/article/details/80946277