浅谈分布式锁的几种使用方式(redis、zookeeper、数据库)

(编辑:jimmy 日期: 2024/12/23 浏览:2)

Q:一个业务服务器,一个数据库,操作:查询用户当前余额,扣除当前余额的3%作为手续费

  • synchronized
  • lock
  • dblock

Q:两个业务服务器,一个数据库,操作:查询用户当前余额,扣除当前余额的3%作为手续费

  • 分布式锁

我们需要怎么样的分布式锁?

  • 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
  • 这把锁要是一把可重入锁(避免死锁)
  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
  • 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
  • 有高可用的获取锁和释放锁功能
  • 获取锁和释放锁的性能要好

一、基于数据库实现的分布式锁

基于表实现的分布式锁

CREATE TABLE `methodLock` ( 
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', 
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息', 
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成', 
PRIMARY KEY (`id`), 
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

当我们想要锁住某个方法时,执行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name',‘desc')
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
delete from methodLock where method_name ='method_name'

上面这种简单的实现有以下几个问题:

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
  • 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个while循环,直到insert成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
  • 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁

基于排他锁实现的分布式锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:

public boolean lock(){  
  connection.setAutoCommit(false);
  while(true){    
    try{      
      result = select * from methodLock where method_name=xxx for update;      
      if(result==null){        
        return true;      
      }    
    }catch(Exception e){

    }
    sleep(1000);
  }
  return false;
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

public void unlock(){ connection.commit(); }

通过connection.commit();操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。

锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点、可重入和公平锁的问题。

总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。

数据库实现分布式锁的优点

直接借助数据库,容易理解。

数据库实现分布式锁的缺点

会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。

操作数据库需要一定的开销,性能问题需要考虑。

二、基于缓存的分布式锁

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。

目前有很多成熟的缓存产品,包括Redis,memcached等。这里以Redis为例来分析下使用缓存实现分布式锁的方案。

基于Redis实现分布式锁在网上有很多相关文章,其中主要的实现方式是使用Jedis.setNX方法来实现。

public boolean trylock(String key) {  
  ResultCode code = jedis.setNX(key, "This is a Lock.");  
  if (ResultCode.SUCCESS.equals(code))    
    return true;  
  else    
    return false; 
} 
public boolean unlock(String key){
  ldbTairManager.invalid(NAMESPACE, key); 
}

以上实现方式同样存在几个问题:

1、单点问题。

2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在redis中,其他线程无法再获得到锁。

3、这把锁只能是非阻塞的,无论成功还是失败都直接返回。

4、这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在redis中已经存在。无法再执行setNX操作。

5、这把锁是非公平的,所有等待的线程同时去发起setNX操作,运气好的线程能获取锁。

当然,同样有方式可以解决。

  • 现在主流的缓存服务都支持集群部署,通过集群来解决单点问题。
  • 没有失效时间?redis的setExpire方法支持传入失效时间,到达时间之后数据会自动删除。
  • 非阻塞?while重复执行。
  • 非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。
  • 非公平?在线程获取锁之前先把所有等待的线程放入一个队列中,然后按先进先出原则获取锁。

redis集群的同步策略是需要时间的,有可能A线程setNX成功后拿到锁,但是这个值还没有更新到B线程执行setNX的这台服务器,那就会产生并发问题。

redis的作者Salvatore Sanfilippo,提出了Redlock算法,该算法实现了比单一节点更安全、可靠的分布式锁管理(DLM)。

Redlock算法假设有N个redis节点,这些节点互相独立,一般设置为N=5,这N个节点运行在不同的机器上以保持物理层面的独立。

算法的步骤如下:

1、客户端获取当前时间,以毫秒为单位。

2、客户端尝试获取N个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N个节点以相同的key和value获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms。这样可以在有redis节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。

3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过3个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。

4、客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。

5、如果客户端获取锁失败了,客户端会依次删除所有的锁。

使用Redlock算法,可以保证在挂掉最多2个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和缓存锁大大提高了可用性,由于redis的高效性能,分布式缓存锁性能并不比数据库锁差。但是,有一位分布式的专家写了一篇文章《How to do distributed locking》,质疑Redlock的正确性。

该专家提到,考虑分布式锁的时候需要考虑两个方面:性能和正确性。

如果使用高性能的分布式锁,对正确性要求不高的场景下,那么使用缓存锁就足够了。

如果使用可靠性高的分布式锁,那么就需要考虑严格的可靠性问题。而Redlock则不符合正确性。为什么不符合呢?专家列举了几个方面。

现在很多编程语言使用的虚拟机都有GC功能,在Full GC的时候,程序会停下来处理GC,有些时候Full GC耗时很长,甚至程序有几分钟的卡顿,文章列举了HBase的例子,HBase有时候GC几分钟,会导致租约超时。而且Full GC什么时候到来,程序无法掌控,程序的任何时候都可能停下来处理GC,比如下图,客户端1获得了锁,正准备处理共享资源的时候,发生了Full GC直到锁过期。这样,客户端2又获得了锁,开始处理共享资源。在客户端2处理的时候,客户端1 Full GC完成,也开始处理共享资源,这样就出现了2个客户端都在处理共享资源的情况。

浅谈分布式锁的几种使用方式(redis、zookeeper、数据库)

专家给出了解决办法,如下图,看起来就是MVCC,给锁带上token,token就是version的概念,每次操作锁完成,token都会加1,在处理共享资源的时候带上token,只有指定版本的token能够处理共享资源。

浅谈分布式锁的几种使用方式(redis、zookeeper、数据库)

然后专家还说到了算法依赖本地时间,而且redis在处理key过期的时候,依赖gettimeofday方法获得时间,而不是monotonic clock,这也会带来时间的不准确。比如一下场景,两个客户端client 1和client 2,5个redis节点nodes (A, B, C, D and E)。

1、client 1从A、B、C成功获取锁,从D、E获取锁网络超时。

2、节点C的时钟不准确,导致锁超时。

3、client 2从C、D、E成功获取锁,从A、B获取锁网络超时。

4、这样client 1和client 2都获得了锁。

总结专家关于Redlock不可用的两点:

1、GC等场景可能随时发生,并导致在客户端获取了锁,在处理中超时,导致另外的客户端获取了锁。专家还给出了使用自增token的解决方法。

2、算法依赖本地时间,会出现时钟不准,导致2个客户端同时获得锁的情况。
所以专家给出的结论是,只有在有界的网络延迟、有界的程序中断、有界的时钟错误范围,Redlock才能正常工作,但是这三种场景的边界又是无法确认的,所以专家不建议使用Redlock。对于正确性要求高的场景,专家推荐了Zookeeper,关于使用Zookeeper作为分布式锁后面再讨论。

Redis作者的回应

redis作者看到这个专家的文章后,写了一篇博客予以回应。作者很客气的感谢了专家,然后表达出了对专家观点的不认同。

I asked for an analysis in the original Redlock specification here: http://redis.io/topics/distlock. So thank you Martin. However I don't agree with the analysis.

redis作者关于使用token解决锁超时问题可以概括成下面五点:

观点1,使用分布式锁一般是在,你没有其他方式去控制共享资源了,专家使用token来保证对共享资源的处理,那么就不需要分布式锁了。

观点2,对于token的生成,为保证不同客户端获得的token的可靠性,生成token的服务还是需要分布式锁保证服务的可靠性。

观点3,对于专家说的自增的token的方式,redis作者认为完全没必要,每个客户端可以生成唯一的uuid作为token,给共享资源设置为只有该uuid的客户端才能处理的状态,这样其他客户端就无法处理该共享资源,直到获得锁的客户端释放锁。

观点4,redis作者认为,对于token是有序的,并不能解决专家提出的GC问题,如上图所示,如果token 34的客户端写入过程中发送GC导致锁超时,另外的客户端可能获得token 35的锁,并再次开始写入,导致锁冲突。所以token的有序并不能跟共享资源结合起来。

观点5,redis作者认为,大部分场景下,分布式锁用来处理非事务场景下的更新问题。作者意思应该是有些场景很难结合token处理共享资源,所以得依赖锁去锁定资源并进行处理。

专家说到的另一个时钟问题,redis作者也给出了解释。客户端实际获得的锁的时间是默认的超时时间,减去获取锁所花费的时间,如果获取锁花费时间过长导致超过了锁的默认超时间,那么此时客户端并不能获取到锁,不会存在专家提出的例子。

个人感觉

第一个问题我概括为,在一个客户端获取了分布式锁后,在客户端的处理过程中,可能出现锁超时释放的情况,这里说的处理中除了GC等非抗力外,程序流程未处理完也是可能发生的。之前在说到数据库锁设置的超时时间2分钟,如果出现某个任务占用某个订单锁超过2分钟,那么另一个交易中心就可以获得这把订单锁,从而两个交易中心同时处理同一个订单。正常情况,任务当然秒级处理完成,可是有时候,加入某个rpc请求设置的超时时间过长,一个任务中有多个这样的超时请求,那么,很可能就出现超过自动解锁时间了。当初我们的交易模块是用C++写的,不存在GC,如果用java写,中间还可能出现Full GC,那么锁超时解锁后,自己客户端无法感知,是件非常严重的事情。我觉得这不是锁本身的问题,上面说到的任何一个分布式锁,只要自带了超时释放的特性,都会出现这样的问题。如果使用锁的超时功能,那么客户端一定得设置获取锁超时后,采取相应的处理,而不是继续处理共享资源。Redlock的算法,在客户端获取锁后,会返回客户端能占用的锁时间,客户端必须处理该时间,让任务在超过该时间后停止下来。

第二个问题,自然就是分布式专家没有理解Redlock。Redlock有个关键的特性是,获取锁的时间是锁默认超时的总时间减去获取锁所花费的时间,这样客户端处理的时间就是一个相对时间,就跟本地时间无关了。

由此看来,Redlock的正确性是能得到很好的保证的。仔细分析Redlock,相比于一个节点的redis,Redlock提供的最主要的特性是可靠性更高,这在有些场景下是很重要的特性。但是我觉得Redlock为了实现可靠性,却花费了过大的代价。

首先必须部署5个节点才能让Redlock的可靠性更强。

然后需要请求5个节点才能获取到锁,通过Future的方式,先并发向5个节点请求,再一起获得响应结果,能缩短响应时间,不过还是比单节点redis锁要耗费更多时间。

然后由于必须获取到5个节点中的3个以上,所以可能出现获取锁冲突,即大家都获得了1-2把锁,结果谁也不能获取到锁,这个问题,redis作者借鉴了raft算法的精髓,通过冲突后在随机时间开始,可以大大降低冲突时间,但是这问题并不能很好的避免,特别是在第一次获取锁的时候,所以获取锁的时间成本增加了。

如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了。

如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况。

分析了这么多原因,我觉得Redlock的问题,最关键的一点在于Redlock需要客户端去保证写入的一致性,后端5个节点完全独立,所有的客户端都得操作这5个节点。如果5个节点有一个leader,客户端只要从leader获取锁,其他节点能同步leader的数据,这样,分区、超时、冲突等问题都不会存在。所以为了保证分布式锁的正确性,我觉得使用强一致性的分布式协调服务能更好的解决问题。

问题又来了,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。

这个问题使用数据库实现分布式锁同样存在。

对于这个问题目前主流的做法是每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间。在释放锁的同时结束这个线程。如redis官方的分布式锁组件redisson,就是用的这种方案。

使用缓存实现分布式锁的优点

性能好。

使用缓存实现分布式锁的缺点

实现过于负责,需要考虑的因素太多。

基于Zookeeper实现的分布式锁

基于zookeeper临时有序节点可以实现的分布式锁。

大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

来看下Zookeeper能不能解决前面提到的问题。

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
  • 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
  • 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
  • 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
  • 公平问题?使用Zookeeper可以解决公平锁问题,客户端在ZK中创建的临时节点是有序的,每次锁被释放时,ZK可以通知最小节点来获取锁,保证了公平。

问题又来了,我们知道Zookeeper需要集群部署,会不会出现Redis集群那样的数据同步问题呢?

Zookeeper是一个保证了弱一致性即最终一致性的分布式组件。

Zookeeper采用称为Quorum Based Protocol的数据同步协议。假如Zookeeper集群有N台Zookeeper服务器(N通常取奇数,3台能够满足数据可靠性同时有很高读写性能,5台在数据可靠性和读写性能方面平衡最好),那么用户的一个写操作,首先同步到N/2 + 1台服务器上,然后返回给用户,提示用户写成功。基于Quorum Based Protocol的数据同步协议决定了Zookeeper能够支持什么强度的一致性。

在分布式环境下,满足强一致性的数据储存基本不存在,它要求在更新一个节点的数据,需要同步更新所有的节点。这种同步策略出现在主从同步复制的数据库中。但是这种同步策略,对写性能的影响太大而很少见于实践。因为Zookeeper是同步写N/2+1个节点,还有N/2个节点没有同步更新,所以Zookeeper不是强一致性的。

用户的数据更新操作,不保证后续的读操作能够读到更新后的值,但是最终会呈现一致性。牺牲一致性,并不是完全不管数据的一致性,否则数据是混乱的,那么系统可用性再高分布式再好也没有了价值。牺牲一致性,只是不再要求关系型数据库中的强一致性,而是只要系统能达到最终一致性即可。

Zookeeper是否满足因果一致性,需要看客户端的编程方式。

  • 不满足因果一致性的做法
  • A进程向Zookeeper的/z写入一个数据,成功返回
  • A进程通知B进程,A已经修改了/z的数据
  • B读取Zookeeper的/z的数据
  • 由于B连接的Zookeeper的服务器有可能还没有得到A写入数据的更新,那么B将读不到A写入的数据

满足因果一致性的做法

  • B进程监听Zookeeper上/z的数据变化
  • A进程向Zookeeper的/z写入一个数据,成功返回前,Zookeeper需要调用注册在/z上的监听器,Leader将数据变化的通知告诉B
  • B进程的事件响应方法得到响应后,去取变化的数据,那么B一定能够得到变化的值
  • 这里的因果一致性提现在Leader和B之间的因果一致性,也就是是Leader通知了数据有变化

第二种事件监听机制也是对Zookeeper进行正确编程应该使用的方法,所以,Zookeeper应该是满足因果一致性的

所以我们在基于Zookeeper实现分布式锁的时候,应该使用满足因果一致性的做法,即等待锁的线程都监听Zookeeper上锁的变化,在锁被释放的时候,Zookeeper会将锁变化的通知告诉满足公平锁条件的等待线程。

可以直接使用zookeeper第三方库客户端,这个客户端中封装了一个可重入的锁服务。

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {  
  try {    
    return interProcessMutex.acquire(timeout, unit);  
  } catch (Exception e) {    
    e.printStackTrace();  
  }  
  return true; 
} 

public boolean unlock() {  
  try {    
    interProcessMutex.release();  
  } catch (Throwable e) {    
    log.error(e.getMessage(), e);  
  } finally {    
    executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);  
  }  
  return true; 
}

使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

使用Zookeeper实现分布式锁的优点

有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。

使用Zookeeper实现分布式锁的缺点

性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。

三种方案的比较从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

Zookeeper > 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库\

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。