CAP理论和BASE理论

This commit is contained in:
罗祥
2020-05-06 17:43:59 +08:00
parent 29a6cc946b
commit 6ba5a17420
14 changed files with 503 additions and 654 deletions

View File

@@ -1,10 +1,26 @@
# Redis 分布式锁
<nav>
<a href="#一实现原理">一、实现原理</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#11-基本原理">1.1 基本原理</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#12-官方推荐">1.2 官方推荐</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#13--延长锁时效">1.3 延长锁时效</a><br/>
<a href="#二哨兵模式与分布式锁">二、哨兵模式与分布式锁</a><br/>
<a href="#三集群模式与分布式锁">三、集群模式与分布式锁</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#31-RedLock-方案">3.1 RedLock 方案</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#32-低延迟通讯">3.2 低延迟通讯</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#33-持久化与高可用">3.3 持久化与高可用</a><br/>
<a href="#四Redisson">四、Redisson</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#41-分布式锁">4.1 分布式锁</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#42-RedLock">4.2 RedLock</a><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="#43-延长锁时效">4.3 延长锁时效</a><br/>
</nav>
## 一、实现原理
### 1.1 基本原理
JDK 原生的锁可以让不同**线程**之间以互斥的方式来访问共享资源,但如果想要在不同**进程**之间以互斥的方式来访问共享资源JDK 原生的锁就无能为力。此时可以使用 Redis 或 Zookeeper 来实现分布式锁。
JDK 原生的锁可以让不同**线程**之间以互斥的方式来访问共享资源,但如果想要在不同**进程**之间以互斥的方式来访问共享资源JDK 原生的锁就无能为力。此时可以使用 Redis 来实现分布式锁。
Redis 实现分布式锁的核心命令如下:
@@ -12,7 +28,9 @@ Redis 实现分布式锁的核心命令如下:
SETNX key value
```
SETNX 命令的作用是如果指定的 key 不存在,则创建并为其设置值,此时返回状态码 1,否则为 0。如果状态码为 1代表获得该锁此时其他进程再次尝试创建时都回返回 0 ,代表锁已经被占用。当获得锁的进程处理完成业务后,再通过 `del` 命令将该 key 删除,其他进程就可以再次竞争性地进行创建,获得该锁
SETNX 命令的作用是如果指定的 key 不存在,则创建并为其设置值,然后返回状态码 1;如果指定的 key 存在,则直接返回 0。如果返回值为 1代表获得该锁此时其他进程再次尝试创建时由于 key 已经存在,则都会返回 0 ,代表锁已经被占用
当获得锁的进程处理完成业务后,再通过 `del` 命令将该 key 删除,其他进程就可以再次竞争性地进行创建,获得该锁。
通常为了避免死锁,我们会为锁设置一个超时时间,在 Redis 中可以通过 `expire` 命令来进行实现:
@@ -25,8 +43,8 @@ EXPIRE key seconds
```java
Long result = jedis.setnx("lockKey", "lockValue");
if (result == 1) {
// 如果此处程序被异常终止如直接kill -9进程则设置超时的操作就无法进行该锁就会出现死锁
jedis.expire("lockKey", 3);
// 如果此处程序被异常终止如直接kill -9进程则设置超时的操作就无法进行该锁就会出现死锁
jedis.expire("lockKey", 3);
}
```
@@ -44,8 +62,8 @@ SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
- **EX** :设置超时时间,单位是秒;
- **PX** :设置超时时间,单位是毫秒;
- **NX** :当且仅当对应的 Key 不存在时才进行设置;
- **XX**:当且仅当对应的 Key 存在时才进行设置。
- **NX** :当且仅当对应的 Key 不存在时才进行设置;
- **XX**:当且仅当对应的 Key 存在时才进行设置。
这四个参数从 Redis 2.6.12 版本开始支持,因为当前大多数在用的 Redis 都已经高于这个版本,所以推荐直接使用该命令来实现分布式锁。对应的 Jedis 代码如下:
@@ -53,12 +71,13 @@ SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
jedis.set("lockKey", "lockValue", SetParams.setParams().nx().ex(3));
```
此时一条命令就可以完成值和超时时间的设置,并且因为只有一条命令,因此其原子性也得到了保证。但因为引入了超时时间来避免死锁,同时也存在了两个其他问题:
此时一条命令就可以完成值和超时时间的设置,并且因为只有一条命令,因此其原子性也得到了保证。但因为引入了超时时间来避免死锁,同时也引出了其它两个问题:
![redis_分布式锁原理](../pictures/redis_分布式锁原理.png)
<div align="center"> <img src="../pictures/redis_分布式锁原理.png"/> </div>
+ **问题一**:当业务处理的时间超过过期时间后,由于锁已经被释放,此时其他进程就可以获得该锁,这意味着同时有两个进程进入了临界区,此时分布式锁就失效了;
+ **问题**如上图所示,当进程 A 业务处理完成后,此时删除的是进程 B 的锁,进而导致分布式锁又一次失效,进程 B 和 进程 C 同时进入了临界区。
+ **问题**当业务处理的时间超过过期时间后(图中进程 A由于锁已经被释放此时其他进程就可以获得该锁图中进程 B这意味着有两个进程A 和 B同时进入了临界区此时分布式锁就失效了
+ **问题二**:如上图所示,当进程 A 业务处理完成后,此时删除的是进程 B 的锁,进而导致分布式锁又一次失效,让进程 B 和 进程 C 同时进入了临界区。
针对问题二,我们可以在创建锁时为其指定一个唯一的标识作为 Key 的 Value这里假设我们采用 `UUID + 线程ID` 来作为唯一标识:
@@ -87,15 +106,15 @@ jedis.eval(script,
);
```
接着再看问题一,问题一最简单的解决方法是:你可以估计业务的最大处理时间,然后保证设置的过期时间大于最大处理时间。但是由于业务需要面临各种复杂的情况,因此可能无法保证业务每一次都能在规定的过期时间内处理完成,此时可以使用延长锁时效的策略。
接着再看问题一,问题一最简单的解决方法是:你可以估计业务的最大处理时间,然后保证设置的过期时间大于最大处理时间。但是由于业务面临各种复杂的情况,因此可能无法保证业务每一次都能在规定的过期时间内处理完成,此时可以使用延长锁时效的策略。
### 1.3 延长锁时效
延长锁时效的方案如下:假设锁超时时间是 30 秒,此时程序需要每隔一段时间去扫描一下该锁是否还存在,扫描时间需要小于超时时间,通常可以设置为超时时间的 1/3在这里也就是 10 秒扫描一次。如果锁还存在,则重置其超时时间恢复到 30 秒。通过这种方案,只要业务还没有处理完成,锁就会一直有效;而当业务一旦处理完成,程序也会马上删除该锁。
Redis 的 Java 客户端 Redisson 提供的分布式锁就支持延长锁时效的机制,称为 WatchDog直译过来就是 “看门狗” 机制。
Redis 的 Java 客户端 Redisson 提供的分布式锁就支持类似的延长锁时效的策略,称为 WatchDog直译过来就是 “看门狗” 机制。
以上讨论的都是单机环境下的 Redis 分布式锁,而想要保证 Redis 分布式锁是高可用,首先 Redis 得是高可用的Redis 的高可用模式主要有两种:哨兵模式和集群模式。
以上讨论的都是单机环境下的 Redis 分布式锁,而想要保证 Redis 分布式锁是高可用,首先 Redis 得是高可用的Redis 的高可用模式主要有两种:哨兵模式和集群模式。以下分别进行讨论:
@@ -117,38 +136,38 @@ Redis 的 Java 客户端 Redisson 提供的分布式锁就支持延长锁时效
想要在集群模式下实现分布式锁Redis 提供了一种称为 RedLock 的方案,假设我们有 N 个 Redis 实例,此时客户端的执行过程如下:
+ 以毫秒为单位记录当前的时间,作为开始时间;
+ 接着采用和单机版相同的方式,依次尝试在每个实例上创建锁。为了避免客户端长时间与某个故障的 Redis 节点通讯而导致阻塞,这里采用快速轮询的方式:假设创建锁时设置的超时时间为 10 秒,则访问每个 Redis 实例的超时时间可能在 5 到 50 毫秒之间,如果在这个时间内还没有建立通信,则尝试下一个实例;
+ 如果在至少 N/2+1 个实例上都成功创建了锁。并且 `当前时间 - 开始时间 < 锁的超时时间` ,则认为已经获取了锁,锁的有效时间等于 `超时时间 - 花费时间`(如果考虑不同 Redis 实例所在服务器存在时钟漂移,则还需要减去时钟漂移);
+ 接着采用和单机版相同的方式,依次尝试在每个实例上创建锁。为了避免客户端长时间与某个故障的 Redis 节点通讯而导致阻塞,这里采用快速轮询的方式:假设创建锁时设置的超时时间为 10 秒,则访问每个 Redis 实例的超时时间可能在 5 到 50 毫秒之间,如果在这个时间内还没有建立通信,则尝试连接下一个实例;
+ 如果在至少 N/2+1 个实例上都成功创建了锁。并且 `当前时间 - 开始时间 < 锁的超时时间` ,则认为已经获取了锁,锁的有效时间等于 `超时时间 - 花费时间`(如果考虑不同 Redis 实例所在服务器时钟漂移,则还需要减去时钟漂移);
+ 如果少于 N/2+1 个实例,则认为创建分布式锁失败,此时需要删除这些实例上已创建的锁,以便其他客户端进行创建。
+ 该客户端在失败后,可以等待一个随机的时间后,再次进行重试。
以上就是 RedLock 的实现方案,可以看到主要是由客户端来实现的,并不真正涉及到 Redis 集群相关的功能。因此这里的 N 个 Redis 实例并不要求是一个真正的 Redis 集群,它们彼此之间可以是完全独立的,但由于只需要半数节点获得锁就能真正获得锁,因此对于分布式锁功能而言,其仍然是高可用。后面使用 Redisson 来演示 RedLock 时会再次验证这一点。
以上就是 RedLock 的实现方案,可以看到主要是由客户端来实现的,并不真正涉及到 Redis 集群相关的功能。因此这里的 N 个 Redis 实例并不要求是一个真正的 Redis 集群,它们彼此之间可以是完全独立的,但由于只需要半数节点获得锁就能真正获得锁,因此其仍然具备容错性和高可用。后面使用 Redisson 来演示 RedLock 时会再次验证这一点。
### 3.2 低延迟通讯和多路复用
### 3.2 低延迟通讯
实现 RedLock 方案的客户端与所有 Redis 实例进行通讯时,必须要保证低延迟,而且最好能使用多路复用技术来保证一次性将 SET 命令发送到所有 Redis 节点上,并获取到对应的执行结果。假设网络延迟高,此时客户端 A 和 B 分别尝试创建锁:
另外实现 RedLock 方案的客户端与所有 Redis 实例进行通讯时,必须要保证低延迟,而且最好能使用多路复用技术来保证一次性将 SET 命令发送到所有 Redis 节点上,并获取到对应的执行结果。如果网络延迟高,假设客户端 A 和 B 都在尝试创建锁:
```shell
SET key 随机数A EX 3 NX #A客户端
SET key 随机数B EX 3 NX #B客户端
```
假设客户端 A 在一半节点上创建了锁,而客户端 B 在另外一半节点上创建了锁,此时两个客户端都无法获取到锁。如果并发很高,则可能存在多个客户端分别在部分节点上创建了锁,而没有一个客户端的数量超过 N/2+1。这也就是上面过程的最后一步中强调一旦客户端失败后需要等待一个随机时间后再进行重试的原因如果是一个固定时间则所有失败的客户端又同时发起重试情况就还是一样。
此时可能客户端 A 在一半节点上创建了锁,而客户端 B 在另外一半节点上创建了锁,那么两个客户端都无法获取到锁。如果并发很高,则可能存在多个客户端分别在部分节点上创建了锁,而没有一个客户端的数量超过 N/2+1。这也就是上面过程的最后一步中强调一旦客户端失败后需要等待一个随机时间后再进行重试的原因如果是一个固定时间则所有失败的客户端又同时发起重试情况就还是一样。
因此最佳的实现就是客户端的 SET 命令能几乎同时到达所有节点,并几乎同时接受到所有执行结果。 想要保证这一点,低延迟的网络通信极为关键,下文介绍的 Redisson 就采用 Netty 来实现了这一功能。
因此最佳的实现就是客户端的 SET 命令能几乎同时到达所有节点,并几乎同时接受到所有执行结果。 想要保证这一点,低延迟的网络通信极为关键,下文介绍的 Redisson 就采用 Netty 框架来保证这一功能的实现
### 3.3 持久化与高可用
为了保证高可用,所有 Redis 节点需要开启持久化。假设不开启持久化,假设进程 A 获得锁后正在处理业务逻辑,此时节点宕机重启,因为锁数据丢失了,其他进程便可以再次获得该锁,因此所有 Redis 节点都需要开启 AOF 持久化方式。
为了保证高可用,所有 Redis 节点需要开启持久化。假设不开启持久化,假设进程 A 获得锁后正在处理业务逻辑,此时节点宕机重启,因为锁数据丢失了,其他进程便可以再次创建该锁,因此所有 Redis 节点都需要开启 AOF 持久化方式。
AOF 默认的同步机制为 `everysec`,即每秒进程一次持久化,此时能够兼顾性能与数据安全,发生意外宕机的时,最多会丢失一秒的数据。但如果碰巧就是在这一秒的时间内进程 A 创建了锁,此时其他进程可以获得该锁,锁的互斥性也就失效了。要解决这个问题有两种方式:
AOF 默认的同步机制为 `everysec`,即每秒进程一次持久化,此时能够兼顾性能与数据安全,发生意外宕机的时,最多会丢失一秒的数据。但如果碰巧就是在这一秒的时间内进程 A 创建了锁,并由于宕机而导致数据丢失。此时其他进程可以创建该锁,锁的互斥性也就失效了。要解决这个问题有两种方式:
+ **方式一**:修改 Redis.conf 中 `appendfsync` 的值为 always即每次命令后都进行持久化此时降低 Redis 性能,进而也会降低分布式锁的性能,但锁的互斥性得到了绝对的保证;
+ **方式二**:一旦节点宕机了,等到锁的超时时间过了之后才进行重启,此时相当于原有锁自然失效(需要保证业务在自己设定的超时时间内完成),这种方案称为延时重启。
+ **方式一**:修改 Redis.conf 中 `appendfsync` 的值为 `always`,即每次命令后都进行持久化,此时降低 Redis 性能,进而也会降低分布式锁的性能,但锁的互斥性得到了绝对的保证;
+ **方式二**:一旦节点宕机了,需要等到锁的超时时间过了之后才进行重启,此时相当于原有锁自然失效(但你首先需要保证业务在设定的超时时间内完成),这种方案称为延时重启。
@@ -169,27 +188,28 @@ RedissonClient redissonClient = Redisson.create(config);
// 2.创建锁实例
RLock lock = redissonClient.getLock("myLock");
try {
//3.尝试获取分布式锁,第一个参数为等待时间,第二个参数为锁过期时间
boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (isLock) {
// 4.模拟业务处理
//3.尝试获取分布式锁,第一个参数为等待时间,第二个参数为锁过期时间
boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (isLock) {
// 4.模拟业务处理
System.out.println("处理业务逻辑");
Thread.sleep(20 * 1000);
}
Thread.sleep(20 * 1000);
}
} catch (Exception e) {
e.printStackTrace();
e.printStackTrace();
} finally {
//5.释放锁
lock.unlock();
//5.释放锁
lock.unlock();
}
redissonClient.shutdown();
```
此时对应在 Redis 中的数据结构如下:
![redis_分布式锁_cli1](../pictures/redis_分布式锁_cli1.png)
<div align="center"> <img src="../pictures/redis_分布式锁_cli1.png"/> </div>
可以看到 key 就是代码中设置的锁名,而 value 值的类型是 hash其中键 `9280e909-c86b-43ec-b11d-6e5a7745e2e9:13` 的格式为 `UUID + 线程ID`,键对应的值为 1代表加锁的次数。之所以要采用 hash 这种格式,主要是因为 Redisson 创建的锁是具有重入性的,即你可以多次进行加锁:
可以看到 key 就是代码中设置的锁名,而 value 值的类型是 hash其中键 `9280e909-c86b-43ec-b11d-6e5a7745e2e9:13` 的格式为 `UUID + 线程ID` ;键对应的值为 1代表加锁的次数。之所以要采用 hash 这种格式,主要是因为 Redisson 创建的锁是具有重入性的,即你可以多次进行加锁:
```java
boolean isLock1 = lock.tryLock(0, 30, TimeUnit.SECONDS);
@@ -198,9 +218,10 @@ boolean isLock2 = lock.tryLock(0, 30, TimeUnit.SECONDS);
此时对应的值就会变成 2代表加了两次锁
![redis_分布式锁_cli2](../pictures/redis_分布式锁_cli2.png)
<div align="center"> <img src="../pictures/redis_分布式锁_cli2.png"/> </div>
当然和其他重入锁一样,需要保证加锁的次数和解锁的次数一样,才能完全解锁:
当然和其他重入锁一样,需要保证解锁的次数和加锁的次数一样,才能完全解锁:
```java
lock.unlock();
@@ -243,17 +264,17 @@ RLock lock03 = redissonClient03.getLock(lockName);
RedissonRedLock redLock = new RedissonRedLock(lock01, lock02, lock03);
try {
boolean isLock = redLock.tryLock(10, 300, TimeUnit.SECONDS);
if (isLock) {
// 4.模拟业务处理
System.out.println("处理业务逻辑");
Thread.sleep(200 * 1000);
}
boolean isLock = redLock.tryLock(10, 300, TimeUnit.SECONDS);
if (isLock) {
// 4.模拟业务处理
System.out.println("处理业务逻辑");
Thread.sleep(200 * 1000);
}
} catch (Exception e) {
e.printStackTrace();
e.printStackTrace();
} finally {
//5.释放锁
redLock.unlock();
//5.释放锁
redLock.unlock();
}
redissonClient01.shutdown();
@@ -263,7 +284,8 @@ redissonClient03.shutdown();
此时每个 Redis 实例上锁的情况如下:
![redis_分布式锁_cli3](../pictures/redis_分布式锁_cli3.png)
<div align="center"> <img src="../pictures/redis_分布式锁_cli3.png"/> </div>
可以看到每个实例上都获得了锁。
@@ -281,24 +303,24 @@ RedissonClient redissonClient = Redisson.create(config);
// 2.创建锁实例
RLock lock = redissonClient.getLock("myLock");
try {
//3.尝试获取分布式锁,第一个参数为等待时间
boolean isLock = lock.tryLock(0, TimeUnit.SECONDS);
if (isLock) {
// 4.模拟业务处理
System.out.println("处理业务逻辑");
Thread.sleep(60 * 1000);
System.out.println("锁剩余的生存时间:" + lock.remainTimeToLive());
}
//3.尝试获取分布式锁,第一个参数为等待时间
boolean isLock = lock.tryLock(0, TimeUnit.SECONDS);
if (isLock) {
// 4.模拟业务处理
System.out.println("处理业务逻辑");
Thread.sleep(60 * 1000);
System.out.println("锁剩余的生存时间:" + lock.remainTimeToLive());
}
} catch (Exception e) {
e.printStackTrace();
e.printStackTrace();
} finally {
//5.释放锁
lock.unlock();
//5.释放锁
lock.unlock();
}
redissonClient.shutdown();
```
这里我们通过 `config.setLockWatchdogTimeout(30 * 1000)` 将 lockWatchdogTimeout 的值设置为 30000 毫秒(默认值也是 30000 毫秒。lockWatchdogTimeout 只会对那些没有设置锁超时时间的锁生效,所以我们这里调用的是两个参数的 `tryLock()` 方法:
首先 Redisson 的 WatchDog 机制只会对那些没有设置锁超时时间的锁生效,所以我们这里调用的是两个参数的 `tryLock()` 方法:
```java
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
@@ -310,9 +332,9 @@ boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
```
Redisson 的 WatchDog 机制会以 lockWatchdogTimeout 的 1/3 时长为周期(在这里就是 10 秒对所有未设置超时时间的锁进行检查如果业务尚未处理完成也就是锁还没有被程序主动删除Redisson 就会将锁的超时时间重置为 lockWatchdogTimeout 指定的值(在这里就是设置的 30 秒),直到锁被程序主动删除。因此在上面的例子中可以看到,不论将模拟业务的睡眠时间设置为多长,其锁都会存在一定的剩余生存时间,直至业务处理完成。
其次我们通过 `config.setLockWatchdogTimeout(30 * 1000)` 将 lockWatchdogTimeout 的值设置为 30000 毫秒(默认值也是 30000 毫秒)。此时 Redisson 的 WatchDog 机制会以 lockWatchdogTimeout 的 1/3 时长为周期(在这里就是 10 秒对所有未设置超时时间的锁进行检查如果业务尚未处理完成也就是锁还没有被程序主动删除Redisson 就会将锁的超时时间重置为 lockWatchdogTimeout 指定的值(在这里就是设置的 30 秒),直到锁被程序主动删除位置。因此在上面的例子中可以看到,不论将模拟业务的睡眠时间设置为多长,其锁都会存在一定的剩余生存时间,直至业务处理完成。
反之,如果明确的指定了锁的超时时间 leaseTime则以 leaseTime 的时间为准WatchDog 机制对明确指定超时时间的锁不会生效。
反之,如果明确的指定了锁的超时时间 leaseTime则以 leaseTime 的时间为准,因为 WatchDog 机制对明确指定超时时间的锁不会生效。