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

57 篇文章 4 订阅
10 篇文章 0 订阅

问题

Q:一个业务服务器,一个数据库,操作:查询用户当前余额,扣除当前余额的3%作为手续费
synchronized
lock
db lock
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个客户端都在处理共享资源的情况。

在这里插入图片描述

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

在这里插入图片描述

然后专家还说到了算法依赖本地时间,而且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作者也给出了解释。客户端实际获得的锁的时间是默认的超时时间,减去获取锁所花费的时间,如果获取锁花费时间过长导致超过了锁的默认超时间,那么此时客户端并不能获取到锁,不会存在专家提出的例子。

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

性能好

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

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

基于Zookeeper实现的分布式锁

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

基于ZooKeeper分布式锁的流程

  • 在zookeeper指定节点(locks)下创建临时顺序节点node_n
  • 获取locks下所有子节点children
  • 对子节点按节点自增序号从小到大排序
  • 判断本节点是不是第一个子节点,若是,则获取锁;若不是,则监听比该节点小的那个节点的删除事件
  • 若监听事件生效,则回到第二步重新进行判断,直到获取到锁

来看下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 > 缓存 > 数据库

基于redis和zk实现分布式锁的代码

https://github.com/Allenmcc/DistributedLock.git

参考:
https://blog.csdn.net/u010963948/article/details/79006572 (分布式锁的几种使用方式(redis、zookeeper、数据库))
https://blog.csdn.net/xlgen157387/article/details/79036337 (分布式锁简单入门以及三种实现方式介绍)
https://www.cnblogs.com/liuyang0/p/6800538.html (分布式锁与实现(二)——基于ZooKeeper实现)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值