本文主要参考了以下的内容
https://zhuanlan.zhihu.com/p/113382277
http://redis.cn/topics/distlock.html
https://mp.weixin.qq.com/s/Z_xriP-jc2Bnmdcm0l5xzg
再次感谢大佬们的无私奉献,给我们这种菜鸟分享知识
1.set命令+lua脚本实现
关键点1.原子命令加锁
在最开始的redis中,是不支持原子命令加锁操作的,所以set key和设置key的过期时间是需要2个命令的,而在2.6.12版本后,可以通过下面的命令,来实现原子命令加锁操作
SET key random_value NX PX 30000
关键点2.上面几个命令的含义
- random_value :是由客户端生成的一段随机的字符串,它要保证在足够长的时间内所有客户端的所有获取锁请求都是唯一的
- NX : NX 表示只有当要设置的key不存在时,才能够set成功,这保证了只有第一个客户端才能够获取锁,而其他客户端在其没有释放锁前是无法获取到锁的
- PX 30000 : 表示这个锁有一个30秒的自动过期时间,30秒只是个例子,你可以选取一个合适的过期时间
关键点3.value为何需要一个随机的字符串?
value的值设置为随机数是为了更安全的释放锁,释放锁时要检查key是否存在,且key对应的值是否一样,而value可以设置一个唯一的客户端ID,或者用UUID这种随机数。
当解锁的时候,先获取value判断是否是当前进程加的锁,再去删除释放锁
所以可以看到这里有获取、判断、删除三个操作
伪代码
String uuid = xxxx;
// 伪代码,具体实现看项目中用的连接工具
// 有的提供的方法名为set 有的叫setIfAbsent
set Test uuid NX PX 3000
try{
// biz handle....
} finally {
// unlock
/**
这里的get()获取和del()删除不是一个原子操作,
在多线程环境下容易出现问题
*/
if(uuid.equals(redisTool.get('Test')){
redisTool.del('Test');
}
}
关键点4.为何需要lua脚本(执行释放锁)
由上面的代码我们可以知道,get和del不是原子操作,在多线程环境下是会出现问题的,所以,我们需要lua脚本来实现原子操作.
由于lua脚本内,无论你写得多复杂,执行也是一个命令(eval/evalsha)去执行的,即要么一起成功,要么一起失败,能够保证操作的原子性
-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的Test和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1]
then
-- 执行删除操作
return redis.call('del', KEYS[1])
else
-- 不成功,返回0
return 0
end
整个加锁,解锁流程代码
/**
* 加锁
*
* @param id
* @return
*/
public boolean lock(String id) {
Long start = System.currentTimeMillis();
try {
for (; ; ) {
//SET命令返回OK ,则证明获取锁成功
String lock = jedis.set(LOCK_KEY, id, params);
if ("OK".equals(lock)) {
return true;
}
//否则循环等待,在timeout时间内仍未获取到锁,则获取失败
long l = System.currentTimeMillis() - start;
if (l >= timeout) {
return false;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
jedis.close();
}
}
/**
* 解锁
*
* @param id
* @return
*/
public boolean unlock(String id) {
String script =
"if redis.call('get',KEYS[1]) == ARGV[1] then" +
" return redis.call('del',KEYS[1]) " +
"else" +
" return 0 " +
"end";
try {
String result = jedis.eval(script, Collections.singletonList(LOCK_KEY), Collections.singletonList(id)).toString();
return "1".equals(result) true : false;
} finally {
jedis.close();
}
}
2.Redisson(Red Lock-红锁 实现分布式锁)
对应一个单点的redis,其实就可以实现分布式锁了,但是,万一这个节点挂了?所以为了保证redis的高可用,不可能使用单点的redis,一般会使用集群模式
Redis集群方式共有三种:主从模式,哨兵模式,cluster(集群)模式
但是,在集群模式下也会出现一些问题,由于节点之间是采用异步通信.如果你刚才在master节点上加了锁,但数据又没有同步到slaver节点,而此时的master节点正好挂掉了,它上面的锁就木得了,等到新的master起来的时候,(主从模式的手动切换或者哨兵模式的一次 failover 的过程),就有可能再起获取到同样的锁,出现了一个锁被拿了2次的情况
锁都被拿了两次了,也就不满足安全性了。一个安全的锁,不管是不是分布式的,在任意一个时刻,都只有一个客户端持有。
Red Lock(红锁)介绍
为了解决上面的问题,Redis 的作者提出了名为 Redlock 的算法。
在 Redis 的分布式环境中,我们假设有 N 个 Redis
Master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。
前面已经描述了在单点 Redis 下,怎么安全地获取和释放锁,我们确保将在 N 个实例上使用此方法获取和释放锁。
在下面的示例中,我们假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以保证他们不会同时宕机。
从官网上我们可以知道,一个客户端如果要获得锁,必须经过下面的五个步骤:
步骤
- 获取当前unix时间,已毫秒为单位
- 依次从N个实例(这例子是5个)中,使用相同的key和随机的value去获取锁.
注意: 在这个步骤里,为redis设置锁时,要设置一个网络连接和响应超时时间。
例如:你的锁滴失效时间是10秒,则响应超时时间应该设置在5-50毫秒(ms)之间。这样的话可以避免服务器已经挂了,但是客户端还傻傻地在等服务器响应。如果服务器没有在规定的时间内响应,客户端就应该尽快向下一个redis实例进行尝试。 - 客户端使用当前时间减去开始获取锁的时间(步骤1中记录的时间)就可以得到获取锁的时间,当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 注意(重点):如果取得了锁,key的真正有效时间是有效时间减去获取锁用去的时间(步骤3记录的时间)
- 如果由于某些原因导致我们获取锁失败,(没有从至少 (n/2)+1 个redis实例取得锁或者取锁的时间已经超过了有效时间),客户端应该在所有的实例上面进行解锁 (即使这些实例并没有成功获取到锁)
通过上面的步骤我们可以知道,只要大多数的节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。
但是,这还是不能解决故障重启后带来的安全性问题.如下面的场景:
我们有A,B,C 3个节点
- 客户端1在 A , B上加锁成功了,但在C上失败
- 这个时候节点B挂了,而且由于持久化策略,导致客户端1在B上加的锁没有被持久化下来.而当节点B重新上线后,客户端2此时申请同一把锁,而且在节点B,C上加锁成功(此时它向A申请锁是必然失败的)
- 这个时候就出现了同一把锁同时被客户端1和客户端2同时持有
为何持久化策略无法避免重启的数据丢失?
在redis中,如果是采用aof默认情况下是每秒写一次磁盘,即fsync操作,因此最坏的情况下可能会丢失一秒的数据
当然你也可以用(fsync=always)即每次操作都写入一次磁盘,但这样的话会严重影响redis的性能,违反了原来的设计理念(不会真有人用fsync=always 吧)
另外,即使你使用了fsync=always,由于实际的系统环境是极其复杂的(如网络延迟等),这都已经脱离 Redis 的范畴了。上升到服务器、系统问题了。
所以总的来说:由于节点重启引发的锁失效问题,总是有可能出现的 (墨菲定律)。
延迟重启(delayed restarts)
为了解决这一问题,Redis 的作者又提出了延迟重启(delayed restarts)的概念。
意思就是当一个节点挂了,不要立即重启它,而是要等待一定的时间(等它凉透了?)再重启,而且等待时间应该大于锁的过期时间(TTL).这样的目的是保证这个节点在重启之前参与过的所有锁都已经过期了.
但是有个问题:在等待期间,该节点是不对外工作的,所以如果大多数的节点都挂掉了,进入了等待,就会导致了系统不可用,因为在此期间,任何锁都没有办法成功被加锁
释放锁操作
释放锁操作是需要向所有的节点发起释放锁操作,这样的目的是为了让某些节点,它们在加锁期间确实有收到加锁请求并且set成功,但是由于网络波动或者别的原因,导致返回给客户端的相应丢包了,导致客户端以为没有加锁成功.即让所有的节点都释放锁.所以,释放锁的时候要向所有节点发起释放锁的操作。