Redis 基础篇
# Redisson--红锁(Redlock)--使用、原理
# 为什么要使用Redis红锁(Redlock)
实现Redis分布式锁的最简单的方法就是在Redis中创建一个Key,这个key有一个失效时间(TTL),以保证锁最终会被自动释放调。当客户端释放资源(解锁)的 时候,会删除掉这个key。从表面上看似乎效果不错,但有一个严重的单点失败问题:如果Redis挂了怎么办?通过增加一个slave节点解决这个问题通常是行不通 的,这样做,不能实现资源的独享,因为Redis的主从同步通常是异步的。在这种场景(主从结构)中存在明显的竞态:
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了
- slave节点被晋级为master节点
- 客户端B从新的master获取到锁,这个锁对应的资源之前已经被客户端A已经获取到了。安全失效!
# Redis红锁简介及原理
Redis中针对这种情况,引入了红锁的概念。红锁采用主节点过半机制,即获取锁或者释放锁成功的标志为:在过半的节点上操作成功。
在Redis的分布式环境中,假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在Redis单实例下 怎么安全地获取和释放锁。我们确保将在每(N)个实例上使用此方法获取和释放锁。在这个样例中,我们假设有5个Redis master节点,这是一个比较合理的设置。
为了获取到锁,客户端应该执行一下操作:
- 获取当前Unix时间,以毫秒为单位
- 依次尝试从N个实例,使用相同的key和随机值获取锁
- 向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间
- 例如锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间,这样可以避免服务器端Redis已经挂掉的情况,客户端还在等待响应结果。如果服务器端 没有在规定时间内响应,客户端应尽快尝试另外一个Redis示例。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)得到获取锁使用的时间
- 仅当从大多数(这里是3个节点)的Redis节点都取到锁,且使用的时间小于锁失效时间时,锁才算获取成功
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁使用的时间
- 如果因为某些原因,获取锁失败(没有在至少
N/2 + 1
个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有Redis实例上进行解锁
# Redis红锁实战及源码
Redisson实现了红锁算法,使用步骤如下:
- 引入maven
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.0</version>
</dependency>
2
3
4
5
- 引入代码
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://172.0.0.1:5379").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
/**
* 获取多个 RLock 对象
*/
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);
/**
* 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
*/
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
/**
* 尝试获取锁
* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
*/
boolean res = redLock.tryLock((long) waitTimeout, (long) leaseTime, TimeUnit.SECONDS);
if (res) {
// 成功获得锁,在这里处理业务
}
} catch (Exception e) {
throw new RuntimeException("aquire lock fail");
} finally {
// 无论如何,最后都要解锁
redLock.unlock();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- 核心源码
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long newLeaseTime = -1;
if (leaseTime != -1) {
newLeaseTime = unit.toMillis(waitTime)*2;
}
long time = System.currentTimeMillis();
long remainTime = -1;
if (waitTime != -1) {
remainTime = unit.toMillis(waitTime);
}
long lockWaitTime = calcLockWaitTime(remainTime);
/**
* 1. 允许枷锁失败节点个数限制 (N-(N/2 + 1))
*/
int failedLocksLimit = failedLocksLimit();
/**
* 2. 遍历所有节点通过EVAL命令执行lua加锁
*/
List<RLock> acquiredLocks = new ArrayList<>(locks.size());
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
/**
* 3. 对节点尝试加锁
*/
try {
if (waitTime == -1 && leaseTime == -1) {
lockAcquired = lock.tryLock(0;)
} else {
long awaitTime = Math.min(lockWaitTime, remainTime);
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (Exception e) {
// 抛出异常表示获取锁失败
lockAcquired = false;
}
if (lockAcquired) {
/**
* 4. 如果获取到锁则添加到已获取锁集合中
*/
acquiredLocks.add(lock);
} else {
/**
* 5. 计算已经申请锁失败的节点是否已经达到 允许加锁失败节点个数限制 (N-(N/2 + 1))
* 如果已经达到,就认定最终申请锁失败,则没有必要继续从后面的节点申请了
* 因为 Redlock 算法要求至少 (N/2 + 1) 个节点都加锁成功,才算最终锁申请成功
*/
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
if (failedLocksLimit == 0) {
unlockInner(acquiredLocks);
if (waitTime == -1 && leaseTime == -1) {
return false;
}
failedLocksLimit = failedLocksLimit();
acquiredLocks.clear();
// reset iterator
while (iterator.hasPrevious()) {
iterator.previous();
}
} else {
failedLocksLimit--;
}
}
/**
* 6. 计算 目前从各个节点获取锁已经消耗的总时间,如果已经等于最大等待时间,则认定最终申请锁失败,返回false
*/
if (remainTime != -1) {
remainTime -= System.currentTimeMillis() - time;
time = System.currentTimeMillis();
if (remainTime <= 0) {
unlockInner(acquiredLocks);
return false;
}
}
}
if (leaseTime != -1) {
List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
for (RLock rLock : acquiredLocks) {
RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
futures.add(future);
}
for (RFuture<Boolean> rFuture : futures) {
rFuture.syncUninterruptibly();
}
}
/**
* 7. 如果逻辑正常执行完则认为最终申请锁成功,返回true
*/
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# Redis 分布式锁
分布式锁在很多场景中是非常有用的原语,不同的进程必须以独占资源的方式实现资源共享就是一个典型的例子。有很多分布式锁的库和描述怎么实现分布式锁管理 器(DLM)的博客,但是每个库的实现方式都不太一样,很多库的实现方式为了简单降低了可靠性,而有的使用了稍微复杂的设计。这个页面提供了一个使用Redis 实现分布式锁的规范算法。本页提出了一种算法,叫Redlock,这种实现比普通的单实例实现更安全。
# 安全和活性失效保障
分布式锁算法只需具备三个特性就可以实现一个最低保障的分布式锁。
- 安全属性(Safety property):独享(相互排斥)。任意时刻,只有一个客户端持有锁。
- 活性A(Liveness property A):无死锁。即便持有锁的客户端崩溃(crashed)或者网络被分裂(gets partitioned),锁仍然可以被获取。
- 活性B(Liveness property B):容错。只要大部分Redis节点都活着,客户端就可以获取和释放锁。
# 为什么基于故障转移的实现还不够
大多数基于Redis的分布式锁现状和实现方法:
实现Redis分布式锁的最简单的方法就是在Redis中创建一个key,这个key有一个失效时间(TTL),以保证锁最终会被自动释放掉(活性A)。当客户端释放资源 (解锁)的时候,会删除掉这个key。
从表面上看,似乎效果不错,但有一个问题:这个架构中存在一个严重的单点失败问题。如果Redis挂了怎么办?或许可能会说,可以通过增加一个slave节点解决 这个问题,但这通常是行不通的。这样做,并不能实现资源的独享,因为Redis的主从同步通常是异步的。
在这种场景(主从架构)中存在明显的竞态:
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了
- slave节点被晋级为master节点
- 客户端B取得了同一个资源被客户端A已经获取到另外一个锁。安全失效!
# 单Redis实例实现分布式锁的正确方法
在尝试克服上述单实例设置的限制之前,先讨论一下在这种简单情况下实现分布式锁的正确做法,实际上这是一种可行的方案,尽管存在竞态,结果仍然是可接受的, 另外,这里讨论的单实例加锁方法也是分布式加锁算法的基础。获取锁使用命令:
SET resource_name my_random_value NX PX 30000
这个命令仅在不存在key的时候才能被执行成功(NX
选项),并且这个key有一个30秒的自动失效时间(PX
属性)。这个key的值是my_random_value
(一个随机值),这个值在所有客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样。
value的值必须是随机数主要是为了更安全的释放锁,释放锁的时候使用脚本告诉Redis:只有key存在并且存储的值和指定的值一样才能告诉删除成功。可以通过 一下Lua脚本实现:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
2
3
4
5
使用这种方式释放锁可以避免删除别的客户端获取成功的锁。举个例子:客户端A取得资源锁,但是紧接着被一个其他操作阻塞了,当客户端A运行完毕其他操作后
要释放锁时,原来的锁早已超时并且被Redis自动释放,并且在这期间资源锁又被客户端B再次获取到。如果仅使用DEL
命令将key删除,那么这种情况就会把
客户端B的锁给删除掉。使用Lua脚本就不会存在这种情况,因为脚本仅会删除value等于客户端A的value的key(value相当于客户端的一个签名)。
这个随机字符串应该怎么设置?我认为它应该是从/dev/urandom产生的一个20字节随机数,但是应该可以找到比这种方法代价更小的方法,只要这个数在任务中 是唯一的就行。例如一种安全可行的方法是使用/dev/urandom作为RC4的种子和源产生一个伪随机流;一种更简单的方法是把以毫秒为单位的unix时间和客户端 ID拼接起来,理论上不是完全安全,但是在多数情况下可以满足需求。
key的失效时间,被称作锁定有效期。它不仅是key自动失效时间,而且还是一个客户端持有锁多长时间后可以被另外一个客户端重新获得。
截止到目前,已经有较好的方法获取锁和释放锁。基于Redis单实例,假设这个单实例总是可用,这种方法已经足够安全。现在扩展一下,假设Redis没有总是可用 的保障。
# Redlock算法
在Redis的分布式环境中,假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在Redis单实例下 怎么安全地获取和释放锁。我们确保将在每(N)个实例上使用此方法获取和释放锁。在这个样例中,我们假设有5个Redis master节点,这是一个比较合理的设置, 所以我们需要在5台机器上面或者5台虚拟机上面运行这些实例,这样保证他们不会同时宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位
- 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于 锁的失效时间。例如锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在等待响应结果。 如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用 的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少(N/2 + 1)个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁 (即便某些Redis实例根本就没有加锁成功)。
# 这个算法是异步的么?
算法基于这样一个假设:虽然多个进程之间没有时钟同步,但每个进程都以相同的时钟频率前进,时间差相对于失效时间来说几乎可以忽略不计。这种假设和我们的 真实世界非常接近:每个计算机都有一个本地时钟,我们可以容忍多个计算机之间有较小的时钟漂移。
从这点来说,我们必须再次强调我们的互相排斥规则:只有在锁的有效时间(在步骤3计算的结果)范围内客户端能够做完它的工作,锁的安全性才能得到保证 (锁的实际有效时间通常要比设置的短,因为计算机之间有时钟漂移的现象)。
# 失败时重试
当客户端无法取到锁时,应该在一个随机延迟后重试,防止多个客户端在同时抢夺同一资源的锁(这样会导致脑裂,没有人会取到锁)。同样,客户端取得大部分Redis 实例锁所花费的时间越短,脑裂出现的概率就会越低(必要的重试),所以,理想情况下,客户端应该同时(并发地)向所有Redis发送SET命令。
需要强调,当客户端从大多数Redis实例获取锁失败时,应该尽快地释放(部分)已经成功取到的锁,这样其他客户端就不比非得等到锁过完有效时间才能取到 (然而,如果已经存在网络分裂,客户端已经无法和Redis实例通信,此时就只能等待key的自动释放了,等于被惩罚了)。
# 释放锁
释放锁比较简单,向所有的Redis实例发送释放锁命令即可,不用关心之前有没有从Redis实例成功获取到锁。
# 安全争议
这个算法安全吗?可以从不同的场景讨论一下。
让我们假设客户端从大多数Redis实例取到了锁。所有的实例都包含同样的key,并且key的有效时间也一样。然而,key肯定是在不同的时间被设置上的,所以key
的失效时间也不是精确的相同。我们假设第一个设置key时间是T1(开始向第一个server发送命令前时间),最后一个设置的key时间是T2(得到最后一台server
台的答复后的时间),我们可以确认,第一个server的key至少会存活MIN_VALIDITY = TTL - (T2 - T1) - CLOCK_DRIFT
。所有其他的key的存活时间,
都会比这个key时间晚,所以可以肯定,所有key的失效时间至少是MIN_VALIDITY
。
当大部分实例的key被设置后,其他的客户端将不能再取到锁,因为至少(N/2 + 1)个实例已经存在key。所以,如果一个锁被(客户端)获取后,客户端自己也不能 再次申请到锁(违反互相排斥属性)。然而我们也想确保,当多个客户端同时抢夺一个锁时不能两个都成功。
如果客户端在获取到大多数Redis实例锁,使用的时间接近或已经大于失效时间,客户端将认为锁是失效的锁,并且将释放已经获取到的锁,所以只需要在有效时间
范围内获取到大部分锁这种情况。在上面已经讨论过有争议的地方,在MIN_VALIDITY
时间内,将没有客户端再次取得锁。所以只有一种情况,多个客户端会在
相同时间取得(N/2 + 1)实例锁,那就是取得锁的时间大于失效时间(TTL time),这样取得的锁也是无效的。
# 活性争议
系统的活性安全基于三个主要特性:
- 锁的自动释放(因为key失效了):最终锁可以再次被使用。
- 客户端通常会将没有获取到的锁删除,或者锁被取到后,使用完后,客户端会主动(提前)释放锁,而不是等到锁失效另外的客户端才能取到锁。
- 当客户端重试获取锁时,需要等待一段时间,这个时间必须大于从大多数Redis实例成功获取锁使用的时间,以最大限度避免脑裂。
然而,当网络出现问题时系统在失效时间(TTL)内就无法服务,这种情况下我们的程序就会为此付出代价。如果网络持续的有问题,可能会出现死循环。这中情况 发生在客户端取到一个锁还没有来得及释放锁就被网络隔离。
如果网络一直没有恢复,这个算法会导致系统不可用。
# 性能,崩溃恢复和Redis同步
很多用户吧Redis当做分布式锁服务器,使用获取锁和释放锁的响应时间,每秒钟可用执行多少次acquire/release
操作作为性能指标。为了达到这一要求,
增加Redis实例当然可以降低响应延迟(也可以在网络方面做优化,使用非阻塞模型,一次发送所有的命令,然后异步的读取响应结果,假设客户端和redis服务器
之间的RTT都差不多)。
然而,如果想使用可以从备份中恢复的redis模式,有另外一种持久化情况也需要考虑:
我们考虑这样一种场景,假设我们的redis没有使用备份。一个客户端获取到了3个实例的锁。此时,其中一个已经被客户端取到锁的redis实例被重启,在这个 时间点,就可能出现3个节点没有设置锁,此时如果有另外一个客户端来设置锁,锁就可能再次获取到,这样锁的互相排斥的特性就被破坏掉了。
如果我们启用了AOF持久化,情况会好很多。我们可用使用SHUTDOWN
命令关闭然后再次重启。因为Redis到期是语义上实现的,所以当服务器关闭时,实际上
还是经过了时间,所有(保持锁)需要的条件都没有受到影响。没有受到影响的前提是redis优雅的关闭。停电了怎么办?如果redis每秒执行一次fsync
,那么
很有可能在redis重启之后,key已经丢弃。理论上,如果我们想在redis重启地任何情况下都保证锁的安全,我们必须开启fsync=always
的配置。这反过来
将完全破坏与传统上用于以安全的方式实现分布式锁的同一级别的CP系统的性能。
然而情况总比一开始想象的好一些。当一个redis节点重启后,只要它不参与到任意当前活动的锁,没有被当做"当前存活"节点被客户端重新获取到,算法的 安全性仍然是有保障的。
为了达到这种效果,只需要将新的redis实例,在一个TTL时间内,对客户端不可用即可,在这个时间内,所有客户端锁将被失效或自动释放。使用延迟重启可以 在不采用持久化策略的情况下达到同样的安全,然而这样做有时会让系统转化为彻底不可用。比如大部分的redis实例都崩溃了,系统在TTL时间内任何锁都将无法 加锁成功。
# 使算法更加可靠:锁的扩展
如果你的工作可以拆分为许多个小步骤,可以将有效时间设置的小一些,使用锁的一些扩展机制。在工作进行的过程中,当发现锁剩下的有效时间很短时,可以再次 向redis的所有实例发送一个Lua脚本,让key的有效时间延长一点(前提还是key存在并且value是之前设置的value)。
客户端扩展TTL时必须像首次取得锁一样在大多数实例上扩展成功才算再次取得锁,并且在有效时间内再次取到锁(算法和获取锁是非常相似的)。这样做从技术上 将并不会改变算法的正确性,所以扩展锁的过程中仍然需要达到获取到(N/2 + 1)个实例这个要求,否则活性特性之一就会失效。