如何实现一个分布锁?

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_28350997/article/details/83066674

基本概念

为何需要分布式锁?

  1. 传统环境中的情况:
    在程序开发过程中不得不考虑的就是并发问题。在java中对于同一个jvm而言,jdk已经提供了lock和同步等。但是在分布式情况下,往往存在多个进程对一些资源产生竞争关系,而这些进程往往在不同的机器上,这个时候jdk中提供的已经不能满足。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。

  2. 在很多的场景中,为了保证数据的最终一致性,需要很多方案来支持,比如分布式事务、分布式锁等。我们需要保证一个方法在同一时间内只有一个线程被执行。(比如Java中的并发情况lock, synchronized)就是实现了单机环境下的同步,保证共享变量的一致性。

  3. 分布式锁主要用于在分布式环境中保护跨进程、跨主机、跨网络的共享资源实现互斥访问,以达到保证数据的一致性。

目前的解决方案

  1. 基于数据库实现分布式锁
  2. 基于缓存(redis、memcached)等实现分布锁
  3. 基于zookeeper实现分布锁

要达到的目标

在分析上述几种方案之前,理想的分布锁理应达到的效果

  • 可以保证在分布式部署的应用集群中,同一个方法在同一个时间内只能被一台机器上面的一个线程执行。
  • 这把锁事可重入锁避免死锁即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 有高可用的获取锁和释放锁的功能
  • 释放锁和获取锁的成本要低,性能要好(包括正常情况下的释放锁和非正常情况下的释放锁)
  • 互斥性。在任意时刻,只有一个客户端能持有锁
  • 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。

# 基于数据库实现分布式锁

基于数据库的锁实现也有两种方式,一是基于数据库表,另一种是基于数据库排他锁。

基于数据库表

要实现分布式锁,最简单的方式就是之间创建一张锁表,然后通过操作表中的数据来实现。当我们要锁住某个资源或方法时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

基于数据库表增删是最简单的方式,首先创建一张锁的表主要包含下列字段:方法名,时间戳等字段。

创建这样一张数据库表:

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_TIMESTAMPCOMMENT '保存数据时间,自动生成',  
 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'

基于数据库排他锁

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

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

####### 1. 获得锁

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)
            {        e.printStack();
            }    
             //为空或者抛异常的话都表示没有获取到锁
            sleep(1000);   
        }    return false;
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

2. 释放锁

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


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

}

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

存在的问题

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

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

当然也有方式解决上面的问题

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

总结

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

优点

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

缺点

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

使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。

使用zookeeper实现分布式锁

讲得很好,看这篇就够了

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

背景知识(节点知识)

节点代表一个客户端在服务器下面创建的一个标志的东西
在描述算法流程之前,先看下zookeeper中几个关于节点的有趣的性质:

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

总体结构

在介绍使用Zookeeper实现分布式锁之前,首先看当前的系统架构图

结构图

算法总体流程

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

  1. 客户端连接zookeeper,并在/locker下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。
  2. 客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听/lock的子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁;
  3. 执行业务代码;
  4. 完成业务流程后,删除对应的子节点释放锁。(定义了释放锁的操作In ZooKeeper, the lock releasing in normal circumstances means to delete the temporary nodes)

步骤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的子节点删除消息。

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

  1. 客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。(唯一的临时有序节点)

  2. 客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁;(getChildren(/mylock)

  3. 执行业务代码;

  4. 完成业务流程后,删除对应的子节点释放锁。

    可以总结如下

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

算法总体流程

总体流程图

伪代码

1. 获取锁

public void lock(){ 
    path = Create temporary sequence nodes under the parent node 
    while(true){ 
        children = Obtain all the nodes under the parent node 
        If (path is the smallest unit in children){ 
            indicates the node is obtained 
            return; 
        }else{ 
            Add a watcher to monitor whether the previous node exists 
            wait(); 
        } 
    } 
} 
​```
//先创建临时节点,然后判断是否是最小的节点,如果是获得锁,否则在前一个节点处watch监听
Content in watcher{
    notifyAll();
}

2.释放锁

public void release{
    Delete the node create above
}

All unlock() does is explictly delete this process’s node which notifies all the other waiting processes and allows the next one in line to go. Because the nodes are EPHEMERAL, the process can exit without unlocking and ZooKeeper will eventually reap its node allowing the next process to execute. This is a good thing because it means if your process ends prematurely without you having a chance to call unlock() it will not block the remaining processes. Note that it is best to explicitly call unlock() if you can, because it is much faster than waiting for ZooKeeper to reap your node

zookeeper 如何解决数据库方式存在的问题

  1. 锁无法释放?

    使用zookeeper可以有效解决锁无法释放的问题。在获取锁之前,客户端会在ZK服务端zookeeper/locker/node_序号(临时), 一旦客户端获得锁之后挂掉(Session连接断开),那么这个临时节点就会自动删除掉,释放锁。其他客户端就可以再次获得锁

  2. 非阻塞锁?

    使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,如果获取锁不成功,就在前面一个节点注册watch监听器;一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。

  3. 不可重入?

    使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。

  4. 单点问题?

    单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。(保证高可用)

zookeeper开源客户端

Curator 和zkclient等客户端情况

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

缺点

  1. zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。
  2. ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。并发问题,可能存在网络抖动,客户端和ZK集群的session连接断了,zk集群以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了

redis实现分布锁

In Redis, you usually can use the SETNX command to implement distributed locks.

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。(同样zookeeper 形式)

代码

1.获得锁

public void lock(){ 
    for(){ 
        ret = setnx lock_ley (current_time + lock_timeout) 
        if(ret){ 
            //The lock is obtained 
            break; 
        } 
        //The lock is not obtained 
        sleep(100); 
    } 
} 

释放锁

  1. 释放锁

    public void release(){
        del lock_key
    }
    

优点

相对于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,存取速度快很多。而且很多缓存是可以集群部署的,可以解决单点问题。基于缓存的锁有好几。

缺点

实现复杂

Java 客户端

Redisson 链接

redis实现分布式锁和zookeeper实现分布式锁的区别?

redis和zookeeper锁的区别

Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系。其次Redis提供一些命令SETNX,GETSET,可以方便实现分布式锁机制

Redis分布式锁,必须使用者自己间隔时间轮询去尝试加锁,当锁被释放后,存在多线程去争抢锁,并且可能每次间隔时间去尝试锁的时候,都不成功,对性能浪费很大。
Zookeeper分布锁,首先创建加锁标志文件,如果需要等待其他锁,则添加监听后等待通知或者超时,当有锁释放,无须争抢,按照节点顺序,依次通知使用者。

从上面可以知道,基于zookeeper的锁,client的都有节点序号(有序节点的特性),按照序号来, 都是注册在locker 下面下的node_n, 不存在多个竞争的方式

Redis实现分布式锁服务时,有可能存在master崩溃导致多个节点获取锁的问题

总结

Zookeeper实现简单,但效率较低;Redis实现复杂,但效率较高。

相对于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,存取速度快很多。而且很多缓存是可以集群部署的,可以解决单点问题。基于缓存的锁有好几。
三种方案的比较

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)

Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)

缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库

实现分布锁的原则

猜你喜欢

转载自blog.csdn.net/qq_28350997/article/details/83066674