分布式锁简单入门以及三种实现方式介绍

目录

 

一、为什么使用分布式锁

二、分布式锁应该具备哪些条件

三、基于数据库的实现方式

四、基于Redis的实现方式

五、基于zookeeper的实现方式

六、总结


一、为什么使用分布式锁

为了保证一个方法在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。

二、分布式锁应该具备哪些条件

在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行; 
2、高可用的获取锁与释放锁; 
3、高性能的获取锁与释放锁; 
4、具备可重入特性; 
5、具备锁失效机制,防止死锁; 
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

分布式锁通常有三种实现方式:基于数据库实现、基于redis实现、基于zookeeper实现。

三、基于数据库的实现方式

基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

1、想要执行某个方法,就使用这个方法名向表中插入数据:(method_name字段有唯一性约束,且建了索引)

 

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

2、成功插入则获取锁,执行完成后删除对应的行数据释放锁:

delete from method_lock where method_name ='methodName';

使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:

1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取;
5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

四、基于Redis的实现方式

 

1、选用Redis实现分布式锁原因

(1)Redis有很高的性能; 
(2)Redis命令对此支持较好,实现起来比较方便

2、使用命令介绍

(1)SETNX

SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。

(2)expire

expire key timeout:为key设置一个过期时间,单位为second,超过这个时间锁会自动释放,避免死锁。

(3)delete

delete key:根据key删除缓存

在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。

3、实现思想

(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个过期时间,超过该时间则自动释放锁;
(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,根据key删除锁。

综合起来,我们分布式锁实现的第一版伪代码如下:

if(setnx(key,1) == 1){
    expire(key,30)
    try {
        do something ......
    } finally {
        del(key)
    }
}

但是,这样存在以下三个致命问题:

(1)setnx和expire的非原子性

设想一个极端场景,当节点1中的某个线程执行setnx,成功得到了锁,setnx刚执行成功,还未来得及执行expire指令,节点1刚好挂掉了。这样一来,这把锁就没有设置过期时间,变得“长生不老”,别的线程再也无法获得锁了。

解决方法:setnx指令本身是不支持传入超时时间的,幸好Redis 2.6.12以上版本为set指令增加了可选参数,代码如下:

  /**
   * @param nxxx NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key if it already exist.
   * @param expx EX|PX, expire time units: EX = seconds; PX = milliseconds
   * @param time expire time in the units of <code>expx</code>
   * @return Status code reply
   */
  public String set(final String key, final String value, final String nxxx, final String expx, final long time) {
  ……
  }

(2)del 导致误删

 

假如线程A成功得到了锁,并且设置的超时时间是30秒,如果某些原因导致线程A执行的很慢很慢,过了30秒都没执行完,这时候锁过期自动释放,线程B得到了锁。随后,线程A执行完了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。

解决方法:可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁。至于具体的实现,可以在加锁的时候把当前的线程ID当做value,并在删除之前验证key对应的value是不是自己线程的ID。

//加锁:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
//解锁:
if(threadId .equals(redisClient.get(key))){
    del(key)
}

(3)出现并发的可能性

还是刚才第二点所描述的场景,虽然我们避免了线程A误删掉key的情况,但是同一时间有A,B两个线程在访问代码块,仍然是不完美的。

解决方法:我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”。当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,每20秒执行一次。当线程A执行完任务,会显式关掉守护线程。另一种情况,如果节点1忽然断电,由于线程A和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。

五、基于zookeeper的实现方式

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

1、实现思想

(1)在Zookeeper当中创建一个持久节点ParentLock;
(2)当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点Lock1。之后,线程1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁;
(3)如果再有一个线程2前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。线程2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。于是,线程2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态;
(4)这时候,如果线程3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。线程3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。于是,线程3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着线程3同样抢锁失败,进入了等待状态;
(5)这这样一来,线程1得到了锁,线程2监听了Lock1,线程3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的AQS(AbstractQueuedSynchronizer)。

2、释放锁分为两种情况:

(1)任务完成,客户端显示释放

 

当任务完成时,线程1会显示调用删除节点Lock1的指令。

(2)任务执行过程中,客户端崩溃

获得锁的线程1在任务执行过程中,如果系统崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。 由于线程2一直监听着Lock1的存在状态,当Lock1节点被删除,线程2会立刻收到通知。这时候线程2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则线程2顺理成章获得了锁。

3、Zookeeper能不能解决前面提到的问题

 

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

4、代码实现

zookeeper提供了开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁,非常方便粗暴。

public class ZookeeperHelper {

    private CuratorFramework cf;
    
    @PostConstruct
    public void init() {
        //创建zookeeper客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
        client.start();
    }

    public InterProcessSemaphoreMutex getLock(String subPath) {
        String path = "zookeeper_lock" + "/" + subPath;
        return new InterProcessSemaphoreMutex(cf, path);
    }

}

获取和关闭锁如下:

InterProcessSemaphoreMutex lock = zookeeperHelper.getLock(firstCacheKey);
if(lock.acquire(15, TimeUnit.SECONDS)){
    try {
        do something ......
    } finally {
        if (lock.isAcquiredInThisProcess()) {
            lock.release();
        }
    }
}

5、与redis分布式锁的对比优缺点

分布式锁优点缺点
Zookeeper1、有封装好的框架,容易实现
2、有等待锁的队列,大大提升抢锁效率
添加和删除节点性能较低
RedisSet和Del指令性能较高    1、实现复杂,需要考虑超时,原子性,误删等情形
2、没有等待锁的队列,只能在客户端自旋来等待,效率低下

六、总结

1、分布式锁的一些条件:高可用、高性能、可重入、锁失效机制、非阻塞锁特性;

2、分布式锁实现的三种方式:数据库、redis、zookeeper;

3、redis 中 SETNX/expire 存在非原子性,可以使用set方式一把设置;

4、redis可以检查key/value一致性,防止误删;

5、zookeeper利用临时有序节点来设置分布式锁;

6、zookeeper利用Curator框架获取客户端,以及InterProcessMutex类获取分布式锁和释放锁;

7、zookeeper创建分布式锁时,建议粒度不要太小,粒度太小容易创建太多节点,容易满盘;

8、zookeeper实现简单,但是性能低点;redis性能高,但是实现复杂,考虑点多。

参考:
https://mp.weixin.qq.com/s/8fdBKAyHZrfHmSajXT_dnA
https://mp.weixin.qq.com/s/u8QDlrDj3Rl1YjY4TyKMCA

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值