1.Redis分布式锁方案的八种问题

pull/3/head
liruyu 3 years ago
parent c0bd15de86
commit 583ff35c39

@ -2045,22 +2045,13 @@ public void unlock() {
## Redis
https://mp.weixin.qq.com/s?__biz=MzAwMDg2OTAxNg==&mid=2652055114&idx=1&sn=f4d73fa2e294d633224f4d94a0667e70&chksm=8105d1bdb67258ab458389bd23d8e0211d34835f9da3745a0e6c244ec943911220db0c011665&mpshare=1&scene=23&srcid=1014HUmkIFVfi7rEQQ6bmuqH&sharer_sharetime=1634173594040&sharer_shareid=0f9991a2eb945ab493c13ed9bfb8bf4b%23rd
### 分布式锁的问题
### 锁的问题
#### 非原子操作
`加锁操作`和后面的`设置超时时间`是分开的,并`非原子操作`。解决方案:
- **set命令**
- **LUA脚本**
#### 忘了释放锁
在redis中还有`set`命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。
**方案一set命令**
```java
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
@ -2070,7 +2061,7 @@ if ("OK".equals(result)) {
return false;
```
其中:
在redis中还有`set`命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。其中:
- `lockKey`:锁的标识
- `requestId`请求id
@ -2078,7 +2069,29 @@ return false;
- `PX`:设置键的过期时间为 millisecond 毫秒
- `expireTime`:过期时
使用`set`命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。分布式锁更合理的流程如下:
**方案二LUA脚本**
```lua
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end
return redis.call('pttl', KEYS[1]);
```
#### 忘了释放锁
加锁之后,每次都要达到了超时时间才释放锁,不会有点不合理。如果不及时释放锁,会有很多问题。合理流程如下:
![Redis释放锁流程](images/Solution/Redis释放锁流程.jpg)
@ -2086,13 +2099,13 @@ return false;
```java
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
unlock(lockKey);
}
```
@ -2100,26 +2113,268 @@ try{
#### 释放了别人的锁
自己只能释放自己加的锁,不允许释放别人加的锁。
**方案一requestId方案**
伪代码如下:
```java
if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;
```
**方案二LUA脚本方案**
```lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
```
#### 大量失败请求
在秒杀场景下会有什么问题每1万个同时请求有1个成功。再1万个同时请求有1个成功。如此下去直到库存不足。这就变成均匀分布的秒杀了跟我们想象中的不一样应该是谁先来谁得到
**解决方案:自旋锁**
在规定的时间比如500毫秒内自旋不断尝试加锁说白了就是在死循环中不断尝试加锁如果成功则直接返回。如果失败则休眠50毫秒再发起新一轮的尝试。如果到了超时时间还未加锁成功则直接返回失败。
```java
try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
// 创建订单
createOrder();
return true;
}
long time = System.currentTimeMillis() - start;
if (time>=timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally{
unlock(lockKey,requestId);
}
return false;
```
#### 锁重入问题
假设需要获取一颗满足条件的菜单树。需要在接口中从根节点开始递归遍历出所有满足条件的子节点然后组装成一颗菜单树。在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下每次都可能获取最新的数据这里可以加redis分布式锁。在递归方法中递归遍历多次每次都是加的同一把锁。递归第一层当然是可以加锁成功的但递归第二层、第三层...第N层不就会加锁失败了
递归方法中加锁的伪代码(会出现异常)如下:
```java
private int expireTime = 1000;
public void fun(int level,String lockKey,String requestId){
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(level<=10){
this.fun(++level,lockKey,requestId);
} else {
return;
}
}
return;
} finally {
unlock(lockKey,requestId);
}
}
```
**基于Redisson实现可重入锁**
伪代码如下:
```java
private int expireTime = 1000;
public void run(String lockKey) {
RLock lock = redisson.getLock(lockKey);
this.fun(lock,1);
}
public void fun(RLock lock,int level){
try{
lock.lock(5, TimeUnit.SECONDS);
if(level<=10){
this.fun(lock,++level);
} else {
return;
}
} finally {
lock.unlock();
}
}
```
#### 锁竞争问题
如果有大量需要写入数据的业务场景使用普通的redis分布式锁是没有问题的。但如果有些业务场景写入的操作比较少反而有大量读取的操作。这样直接使用普通的redis分布式锁会不会有点浪费性能
**读写锁**
读写锁的特点:
- **读与读是共享的,不互斥**
- **读与写互斥**
- **写与写互斥**
我们以redisson框架为例它内部已经实现了读写锁的功能。读锁的伪代码如下
```java
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
//业务操作
} catch (Exception e) {
log.error(e);
} finally {
rLock.unlock();
}
```
写锁的伪代码如下:
```java
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
//业务操作
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}
```
将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。
**锁分段**
此外,为了减小锁的粒度,比较常见的做法是将大锁:`分段`。
比如在秒杀扣库存的场景中现在的库存中有2000个商品用户可以秒杀。为了防止出现超卖的情况通常情况下可以对库存加锁。如果有1W的用户竞争同一把锁显然系统吞吐量会非常低。
为了提升系统性能我们可以将库存分段比如分为100段这样每段就有20个商品可以参与秒杀。
在秒杀的过程中先把用户id获取hash值然后除以100取模。模为1的用户访问第1段库存模为2的用户访问第2段库存模为3的用户访问第3段库存后面以此类推到最后模为100的用户访问第100段库存。
![Redis分布式锁-分段锁](images/Solution/Redis分布式锁-分段锁.png)
**注意**:将锁分段虽说可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。
#### 锁超时问题
如果线程A加锁成功了但是由于业务功能耗时时间很长超过了设置的超时时间这时候redis会自动释放线程A加的锁。
**解决方案:自动续期**
自动续期的功能是获取锁之后开启一个定时任务每隔10秒判断一下锁是否存在如果存在则刷新过期时间。如果续期3次也就是30秒之后业务方法还是没有执行完就不再续期了。
我们可以使用`TimerTask`类,来实现自动续期的功能:
```java
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//自动续期逻辑
}
}, 10000, TimeUnit.MILLISECONDS);
```
获取锁之后自动开启一个定时任务每隔10秒钟自动刷新一次过期时间。这种机制在redisson框架中有个比较霸气的名字`watch dog`,即传说中的`看门狗`。当然自动续期功能我们还是优先推荐使用lua脚本实现比如
```lua
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
```
**需要**在实现自动续期功能时还需要设置一个总的过期时间可以跟redisson保持一致设置成30秒。如果业务代码到了这个总的过期时间还没有执行完就不再自动续期了。
#### 主从复制的问题
如果redis存在多个实例。比如做了主从或使用了哨兵模式基于redis的分布式锁的功能就会出现问题。
比如锁A刚加锁成功master就挂了还没来得及同步到slave上。这样会导致新master节点中的锁A丢失了。后面如果有新的线程使用锁A加锁依然可以成功分布式锁失效了。
**解决方案RedissonRedLock**
RedissonRedLock解决问题的思路如下
1. 需要搭建几套相互独立的redis环境假如我们在这里搭建了5套
2. 每套环境都有一个redisson node节点
3. 多个redisson node节点组成了RedissonRedLock
4. 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合
在这里我们以主从为例,架构图如下:
![RedissonRedLock](images/Solution/RedissonRedLock.png)
RedissonRedLock加锁过程如下
1. 获取所有的redisson node节点信息循环向所有的redisson node节点加锁假设节点数为N例子中N等于5
2. 如果在N个节点当中有N/2 + 1个节点加锁成功了那么整个RedissonRedLock加锁是成功的
3. 如果在N个节点当中小于N/2 + 1个节点加锁成功那么整个RedissonRedLock加锁是失败的
4. 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败
从上面可以看出使用Redlock算法确实能解决多实例场景中假如master节点挂了导致分布式锁失效的问题。但也引出了一些新问题比如
- 需要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比
- 如果有N个redisson node节点需要加锁N次最少也需要加锁N/2+1次才知道redlock加锁是否成功。显然增加了额外的时间成本有点得不偿失
**场景选择**
在实际业务场景尤其是高并发业务中RedissonRedLock其实使用的并不多。在分布式环境中CAP是绕不过去的一致性Consistency、可用性Availability、分区容错性Partition tolerance
- 如果你的实际业务场景,更需要的是**保证数据一致性**那么请使用CP类型的分布式锁
比如zookeeper它是基于磁盘的性能可能没那么好但数据一般不会丢
- 如果你的实际业务场景,更需要的是**保证数据高可用性**。那么请使用AP类型的分布式锁
比如redis它是基于内存的性能比较好但有丢失数据的风险
其实在绝大多数分布式业务场景中使用redis分布式锁就够了真的别太较真。因为数据不一致问题可以通过最终一致性方案解决。但如果系统不可用了对用户来说是暴击一万点伤害。
### LUA+SETNX+EXPIRE

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Loading…
Cancel
Save