Redis实现分布式锁
锁我们都不陌生,在多线程并发的情况下,如何保证某代码块在同一时刻只能被一个线程访问呢,这时候我们就需要用到“锁”,提到锁的实现,我们可能想到了java的synchronized或ReentrantLock,但需要注意的是,synchronized或ReentrantLock只能保证在同一个JVM内的多个线程同步执行,如下图,
那么,在集群分布式环境中,如何保证不同节点的线程同步执行呢?
这种场景就需要引入我们今天的主角——分布式锁。
一、分布式锁的实现有哪些?
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,成功得到了锁:
如果在setnx刚执行成功,还没来得及执行expire指令时,节点1挂了,那么这把锁(其实就是set的这个key)将没有设置超时时间,导致其他线程再也拿不到了。
解决方案: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拿到了锁开始执行。
随后,线程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)
}
但是,这样做又隐含了一个新的问题,判断和释放锁是两步操作,不具备原子性,还是存在隐患,这个问题我们可以用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两个线程在访问代码块,出现了并发,并没有起到加锁的意义。
解决方案:我们可以让获得锁的线程再开启一个守护线程,用来给快要过期的锁”续命“。
当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,每20秒执行一次。
当线程A执行完任务,会显式关掉守护线程。
这种情况下就算节点1 忽然断电,由于线程A和守护线程在同一个进程,守护线程也会宕掉。当锁到了设置的超时时间,没人给它续命,也就自动释放了。
至此,Redis实现分布式锁的基本原理和思路我们已经搞清楚了,我会下一篇文章来写Redis分布式锁的Java实现,感兴趣的童鞋可以持续关注。
↓↓↓更多技术干货 • 扫码关注博主微信公众号↓↓↓