Redis实现分布式锁

锁我们都不陌生,在多线程并发的情况下,如何保证某代码块在同一时刻只能被一个线程访问呢,这时候我们就需要用到“锁”,提到锁的实现,我们可能想到了java的synchronized或ReentrantLock,但需要注意的是,synchronized或ReentrantLock只能保证在同一个JVM内的多个线程同步执行,如下图,

Redis实现分布式锁

    那么,在集群分布式环境中,如何保证不同节点的线程同步执行呢?

Redis实现分布式锁

    这种场景就需要引入我们今天的主角——分布式锁。

一、分布式锁的实现有哪些?

1、Memcached分布式锁

    利用Memcached的add命令,此命令是原子性操作,只有在key不存在的情况下,才能add成功,也就意味着线程得到了锁。

2、Redis分布式锁

    和Memcached的方式类似,利用Redis的setnx命令,此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。

    其实用setnx命令并不严谨,后面会详细说明。

3、Zookeeper分布式锁

    利用Zookeeper的顺序临时节点,来实现分布式锁和等待队列,Zookeeper设计的初衷,就是为了实现分布式锁服务的。

4、Chubby

    Google公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法。

二、Redis实现分布式锁的思路

    使用分布式锁,我们要考虑三个核心问题:加锁、释放锁、锁超时了怎么办?

1、加锁

    Redis有一个setnx命令,在上篇《超详细的Redis入门教程》中专门列出了Redis的各种常用命令,感兴趣的同学可以去看一下。

    setnx key value,若给定的key不存在,执行set操作,返回1;若给定的Key已经存在,不做任何操作,返回0。key是锁的唯一标识,自定义,value我们姑且设值为1,伪代码如下:

    setnx(key,1),

    当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,表示该线程抢锁失败。

2、释放锁

    当拿到锁的线程执行完任务后就需要释放锁,以便其他线程可以获取锁资源,释放锁的最简单方式是执行del指令,伪代码如下:

    del(key),释放锁之后,其他线程就可以继续执行setnx命令来获得锁。

3、锁超时了怎么办

    如果一个得到锁的线程在执行任务的过程中挂掉了,还没有来得及释放锁,那么这块资源将会永远被锁住,别的线程再也进不来。所以,setnx的key必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。    

    设置key的失效时间我们可以用expire命令:expire(key, 30)。


※ 至此,我们可以简单的写出一段加分布式锁的伪代码:

if(setnx(key,1) == 1){

    expire(key,30)

    try {

        do something ......

    } finally {

        del(key)

    }

}


※ 但是,我们仔细看上面的伪代码,发现存在3个致命的问题:

1、setnx和expire的非原子性

    设想一个极端场景,首先某线程执行setnx,成功得到了锁:

Redis实现分布式锁

    如果在setnx刚执行成功,还没来得及执行expire指令时,节点1挂了,那么这把锁(其实就是set的这个key)将没有设置超时时间,导致其他线程再也拿不到了。

Redis实现分布式锁

    解决方案:Redis 2.6.12以上版本为set指令增加了可选参数,在set的同时可以设置超时时间,即上面setnx(key,1) 、expire(key,30)两步操作可以用一条命令来替代:set(key,1,NX,[EX|PX],30),其中NX表示仅在key不存在时才能设置成功,EX表示单位是秒,PX表示单位是毫秒,解决了加锁非原子性的问题。

2、释放锁时del 误删

    又是一个极端场景,假如线程A成功得到了锁,并且设置的超时时间是30秒。

    如果线程A超过了30秒还没有执行完,这时锁超时将自动释放,接下来线程B拿到了锁开始执行。

Redis实现分布式锁

    随后,线程A执行完了任务,接着执行del指令来释放锁,但这时线程B还没有执行完,我们发现,线程A删除的实际上是线程B加的锁。

Redis实现分布式锁

    解决方案:怎么避免这种情况呢?可以在del释放锁之前加一个判断,验证当前的锁是不是自己加的。具体实现的话,可以在加锁的时候把当前的线程ID当做value,在删除之前验证key对应的value是不是自己线程的ID。

> 加锁:

String threadId

    = Thread.currentThread().getId()

set(key,threadId ,30,NX)

> 释放锁:

if(threadId .equals(redisClient.get(key))){

    del(key)

}

    但是,这样做又隐含了一个新的问题,判断和释放锁是两步操作,不具备原子性,还是存在隐患,这个问题我们可以用Lua脚本来实现:

   String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

      redisClient.eval(luaScript,Collections.singletonList(key),Collections.singletonList(threadId));

    这样一来,验证和删除过程就是原子操作了。

3、有出现并发的风险

    2中描述的场景,虽然我们避免了线程A误删掉key的情况,但我们发现同一时间有A,B两个线程在访问代码块,出现了并发,并没有起到加锁的意义。

    解决方案:我们可以让获得锁的线程再开启一个守护线程,用来给快要过期的锁”续命“。

Redis实现分布式锁

    当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,每20秒执行一次。

Redis实现分布式锁

    当线程A执行完任务,会显式关掉守护线程。

Redis实现分布式锁

    这种情况下就算节点1 忽然断电,由于线程A和守护线程在同一个进程,守护线程也会宕掉。当锁到了设置的超时时间,没人给它续命,也就自动释放了。

    至此,Redis实现分布式锁的基本原理和思路我们已经搞清楚了,我会下一篇文章来写Redis分布式锁的Java实现,感兴趣的童鞋可以持续关注。

 

↓↓↓更多技术干货 • 扫码关注博主微信公众号↓↓↓

Redis实现分布式锁