锁的基本实现
单Redis节点分布式锁
加锁:
使用 SET key value [EX seconds ][PX milliseconds][NX|XX] 命令
-
EX second
:设置键的过期时间为second
秒。SET key value EX second
。 -
PX millisecond
:设置键的过期时间为millisecond
毫秒。SET key value PX millisecond
。 -
NX
:只在键不存在时,才对键进行设置操作。SET key value NX
效果等同于SETNX key value
。 -
XX
:只在键已经存在时,才对键进行设置操作。
正确代码:
/**
* 尝试获取分布式锁
* @param lockKey 锁key
* @param lockvalueRandomStr 唯一性字符串(解锁时客户端要验证是否是自己持有的字符串,防止误解锁)
* @param expireTime 超期时间 (PX-毫秒)
*/
public boolean tryLock(String lockKey, String lockvalueRandomStr, int expireTime) {
String result = jedis.set(lockKey, lockvalueRandomStr, "NX", "PX", expireTime);
return "OK".equals(result);
}
- 加锁value要具有唯一性,解锁时客户端要验证是否是自己持有的字符串,防止误解锁
错误代码:
public boolean tryLock(String lockKey, String lockvalueRandomStr, int expireTime) {
Long result = jedis.setnx(lockKey, lockvalueRandomStr);
if (result == 1) {// 若在这里程序突然崩溃,则无法设置过期时间,锁将无法消除,即发生死锁
jedis.expire(lockKey, expireTime);
}
}
解锁:
正确代码:
public boolean releaseLock(String lockKey, String lockvalueRandomStr) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script,
Collections.singletonList(lockKey),
Collections.singletonList(lockvalueRandomStr));
return "OK".equals(result);
}
- 编写lua脚本语法,逻辑为:先调用get方法验证value是否为参数value,如果是才执行删除(解锁本质上包含三步操作:‘GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。 概括为:解锁时需判断是否是锁的持有者发起的操作)
- 使用redis.eval()函数执行lua脚本,命令集的执行具有原子性
错误代码:
1、不判断,直接del
jedis.del(lockKey);
2、判断持有者与解锁分开进行
public void releaseLock(String lockKey, String lockvalueRandomStr) {
// 判断加锁与解锁是不是同一个客户端
if (lockvalueRandomStr.equals(jedis.get(lockKey))) {
// 刚刚判断完是锁持有者来解锁,但在此时锁正好过期了并被其他客户端持有,此时的del操作会把其他客户端正常持有的锁给释放掉!
jedis.del(lockKey);
}
}
小结
- 加锁、解锁必须使用原子操作命令,不能分步执行
- 锁的值应该具有唯一性,解锁时通过验证唯一性来判断是否为锁持有者来解锁
- 锁必须设置过期时间,否则可能导致锁无法解除而发生死锁现象
单Redis节点的分布式锁存在的问题:
1、锁只作用于一个节点,单节点失效导致锁无法使用,即使主从、高可用集群依然存在异常情况:
在Redis的master节点上拿到了锁;
但是这个加锁的key还没有同步到slave节点;
master故障,发生故障转移,slave节点升级为master节点;
锁丢失,其他客户端可获取锁,导致多客户端持有锁。
2、锁失效时间设置影响锁的效果
- 锁时间短,客户端A没有执行完,锁过期,客户端B持有了锁
- 锁时间长,客户端A崩溃,锁没释放,其他客户端迟迟获取不到锁
分布式锁算法Redlock
基于单Redis节点的分布式锁在failover的时候会产生解决不了的安全性问题,Redlock算法基于N个完全独立的Redis节点(通常情况下N可以设置成5)。
- 获取当前时间(毫秒数)。
- 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
- 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
- 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
- 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。
由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。单Redis节点的分布式锁节点失效不可用问题,在Redlock中可以很大程度上避免。
注意一个问题,即锁释放时候要向所有节点发出请求,因为可能存在获取锁时,服务器已经SET了,但返回时网络异常,导致客户端不知道该节点已经成功,如果不去释放这个节点,会导致这个节点过期时间内不可用。
RedLock算法存在的问题:
1、节点挂掉并重启,仍然可能导致多客户端持有锁
假设共5个Redis节点:A, B, C, D, E
- 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
- 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。(持久化有延迟)
- 节点C重启后,客户端2锁住了C, D, E,获取锁成功。
(此类异常的解决办法是延迟重启,即节点挂了不马上重启,而是等一会(锁过期时间),这样在重启时候,之前其他节点获取到的锁都会过期,重启后对现有锁就不会有影响。)
2、锁过期时间依赖于时钟,时钟可能发生跳跃,导致个别节点锁快速过期,可能导致多客户端持有锁
假设共5个Redis节点:A, B, C, D, E
- 客户端1从Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与D和E通信失败。
- 节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。
- 客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。
- 客户端1和客户端2现在都认为自己持有了锁。
3、客户端长时间阻塞导致锁过期问题仍无法解决
对于这个问题有人提出一种方案,获取锁时同时生成一个递增(或唯一)token,此token存在公共存储上,同时返回给客户端,当客户端每次想操作共享资源时,要用token去公共存储上判断是否是当前这个,如果不是则认为是锁已经过期,正被其他客户端持有,拒绝访问。
- 客户端A获取到锁,产生token,等于33
- 客户端A阻塞至锁过期
- 客户端B获取到锁,产生token,等于34
- 客户端A恢复过来,请求共享资源,发现已经不是33了,拒绝,从而避免冲突
但这个只能解决一部分问题,一是看阻塞到了哪里二是不可能所有操作都能检查token有效性,比如发短信操作是一个最小单元,且只能有一个客户端去发,客户端A判断是否能发短信,刚判断完就阻塞,客户端B获取锁,发了短信,客户端A恢复,由于短信服务是最小操作单元,已经不能在判断了,然后就发了,导致发了两次。