Redis分布式锁
生产环境用分布式锁的时候,一定是会用开源类库的,比如Redis分布式锁,一般就是用Redisson框架。
1
2
3
4
5
6
//获取锁
RLock lock = redissonClient.getLock("redisson_lock");
//加锁
lock.lock();
//解锁
rLock.unlock();
实现原理
加锁机制
首先会根据hash节点选择一台机器。然后就会发送一段lua脚本到redis上,lua脚本如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//判断锁key是否存在,不存在
if (redis.call('exists', KEYS[1]) == 0) then
//不存在则调用hset命令,设置锁key
redis.call('hset', KEYS[1], ARGV[2], 1);
//设置锁key的生存时间30s
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
//锁key存在,判断客户端ID是否相同
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
//设置锁key的value加1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
//设置锁key的生存时间30s
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
//锁key的剩余生存时间
return redis.call('pttl', KEYS[1]);
复杂的业务逻辑,通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
KEYS[1]
代表的加锁的key。ARGV[1]
代表的就是锁key的默认生存时间,默认30秒。ARGV[2]
代表的是加锁的客户端的ID,类似于这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1
。
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
,通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
1
2
3
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1": 1
}
锁互斥机制
此时,如果客户端2来尝试加锁,执行了同样的一段lua脚本,根据lua脚本逻辑:
- 第一个if判断会执行
exists myLock
,发现myLock这个锁key已经存在了; - 第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID;
- 客户端2会获取到
pttl myLock
返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间; - 此时客户端2会进入一个while循环,不停的尝试加锁;
自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
可重入加锁机制
1
2
3
4
5
6
7
8
9
10
//获取锁
RLock lock = redissonClient.getLock("redisson_lock");
//加锁
lock.lock();
//重入锁
lock.lock();
//解锁
rLock.unlock();
//解锁
rLock.unlock();
- 第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了;
- 第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
,通过这个命令,对客户端1的加锁次数,累加1。此时myLock数据结构变为下面这样:
1
2
3
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1": 2
}
释放锁机制
发送一段lua脚本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//锁已经不存在返回
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
//锁key重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
//锁key重入次数大于0,设置存活时间
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
//锁key重入次数等于0,锁已释放,删除锁key
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
使用lock.unlock()
释放分布式锁的业务逻辑:对myLock数据结构中的加锁次数减1,如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:del myLock
命令,从redis里删除这个key。
缺点
其实上面那种方案最大的问题,就是如果对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。
所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
RedLock算法
假设有一个redis cluster,有5个redis master实例。然后执行如下步骤获取一把锁:
1)获取当前时间戳,单位是毫秒;
2)轮流尝试在每个master节点上创建锁,过期时间较短,一般就几十毫秒;
3)尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1);
4)客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
5)要是锁建立失败了,那么就依次删除这个锁;
6)只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁;
缺点
基于redis多master集群(redis-cluster,或者redis部署多机器,twitter开源的twemproxy做客户端集群分片,豌豆荚开源的codis做集群分片,都行),redlock算法实现过程太复杂繁琐,很脆弱,多节点同时设置分布式锁,但是失效时间都不一样,随着不同的linux机器的时间不同步,以及各种你无法考虑到的问题,很可能出现重复加锁。
所以redlock算法,有两个问题,
- 第一是实现过程和步骤太复杂,上锁的过程和机制很重,很复杂,导致很脆弱,各种意想不到的情况都可能发生;
- 第二是并不健壮,不一定能完全实现健壮的分布式锁的语义;