从零开始SpringCloud Alibaba实战(87)——redis分布式锁设计原则及最佳实践

文章目录

    • 前言
    • 分布式锁设计注意的几个问题
      • setNx
      • set命令
      • 不要释放别人的锁
      • 高并发下的自旋锁
      • 锁重入问题
      • 锁竞争问题
        • 读写锁
        • 锁分段
      • 锁超时问题
      • 主从复制的问题

前言

现如今大部分项目为微服务构建的分布式项目,分布式项目一个难点就是并发控制,另一个难点是事务,下次讲解

在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被用到了很多业务场景当中。

尤其是分布式配置中心:nocos等的出现,让zookeeper的地位越来越低了。zookeeper分布式锁复杂度更高,同时效果也更好,想把它使用好也不容易。

今天redis用的越来越多,来分析如何更好地设计一个接近完美的分布式锁方案。

分布式锁设计注意的几个问题

设计原则

  • 手动加锁
    • 业务操作
  • 手动释放锁
  • 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。

setNx

设计分布式锁首先想到的是setNx
伪代码:
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}

看似没有问题,但如果加锁成功但服务突然挂掉,超时时间没有设置,这样有可能造成永远都不会超时
加锁操作和后面的设置超时时间是分开的,并非原子操作。

假如加锁成功,但是设置超时时间失败了,该lockKey就变成永不失效。假如在高并发场景中,有大量的lockKey加锁成功了,但不会失效,有可能直接导致redis堆积越来越多的无效key。

set命令

伪代码
String result = jedis.set(lockKey, requestId, “NX”, “PX”, expireTime);
if (“OK”.equals(result)) {
return true;
}
return false;

其中:

lockKey:锁的标识
requestId:请求id
NX:只在键不存在时,才对键进行设置操作。
PX:设置键的过期时间为 millisecond 毫秒。
expireTime:过期时间
set命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。

加锁成功了,如何释放锁呢?
伪代码
try{
String result = jedis.set(lockKey, requestId, “NX”, “PX”, expireTime);
if (“OK”.equals(result)) {
处理业务逻辑

处理完释放锁
}

} finally {
unlock(lockKey);
}

不要释放别人的锁

场景
假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。

在使用set命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:requestId 值要唯一
requestId是在释放锁的时候用的。

if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;

此外,使用lua脚本,也能解决 释放了别人的锁的问题:
这是最常用的

if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0 
end

lua脚本能保证查询锁是否存在和删除锁是原子操作,用它来释放锁效果更好一些。

说到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]);

高并发下的自旋锁

如果不加自旋锁
如秒杀,1万个请求,来请求锁,只有1个成功。再1万个请求,再有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了

伪代码

try {
  Long start = System.currentTimeMillis();
  while(true) {
     String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
     if ("OK".equals(result)) {
        if(exists(库存)) {
          秒杀
        }
        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;

在规定的时间,比如500毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

锁重入问题

假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。

需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。

加redis分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层…第N层,不就会加锁失败了?

递归方法中加锁的伪代码如下:

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);
  }
}

问题:
从根节点开始,第一层递归加锁成功,还没释放说,就直接进入第二层递归。因为requestId作为key的锁已经存在,所以第二层递归大概率会加锁失败,然后返回到第一层。第一层接下来正常释放锁,然后整个递归方法直接返回了。

使用redisson框架可重入锁伪代码

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();
  }
}

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) 
then  
  redis.call('hincrby', KEYS[1], ARGV[2], 1); 
  redis.call('pexpire', KEYS[1], ARGV[1]); 
  return nil; 
end;
return redis.call('pttl', KEYS[1]);

其中:

KEYS[1]: 锁名
ARGV[1]: 过期时间
ARGV[2]: uuid + “:” + threadId,可认为是requestId
先判断如果锁名不存在,则加锁。
然后判断判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次就加1。
如果锁名存在,但值不是requestId,则返回过期时间。
释放锁主要是通过以下脚本实现的:

if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 
then 
  return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1)
if (counter > 0) 
then 
    redis.call('pexpire', KEYS[1], ARGV[2]);       return 0; 
 else 
   redis.call('del', KEYS[1]); 
   redis.call('publish', KEYS[2], ARGV[1]); 
   return 1; 
end; 
return nil

先判断如果锁名和requestId值不存在,则时间返回。
如果锁名和requestId值存在,则重入锁减1。
如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。
如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。

锁竞争问题

如果有大量写入的场景,使用普通的redis分布式锁是没有问题的。

但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。直接使用普通的redis分布式锁,性能会不会不太好?

我们都知道,锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。

所以,提升redis分布式锁性能的第一步,就是要把锁的粒度变细。

读写锁

众所周知,加锁的目的是为了保证,在并发环境中读写数据的安全性,即不会出现数据错误或者不一致的情况。

但在绝大多数实际业务场景中,一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题,我们没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行,这样可以提升系统的性能。

我们以redisson框架为例,它内部已经实现了读写锁的功能。

读锁的伪代码如下:

RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
    rLock.lock();
    //业务操作
} catch (Exception e) {
    log.error(e);
} finally {
    rLock.unlock();
}

写锁的伪代码如下:

RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
    rLock.lock();
    //业务操作
} catch (InterruptedException e) {
   log.error(e);
} finally {
    rLock.unlock();
}

将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。

下面总结一个读写锁的特点:

读与读是共享的,不互斥
读与写互斥
写与写互斥

锁分段

此外,为了减小锁的粒度,比较常见的做法是将大锁:分段。

在java中ConcurrentHashMap,就是将数据分为16段,每一段都有单独的锁,并且处于不同锁段的数据互不干扰,以此来提升锁的性能。

放在实际业务场景中,我们可以这样做:

比如在秒杀扣库存的场景中,现在的库存中有2000个商品,用户可以秒杀。为了防止出现超卖的情况,通常情况下,可以对库存加锁。如果有1W的用户竞争同一把锁,显然系统吞吐量会非常低。

为了提升系统性能,我们可以将库存分段,比如:分为100段,这样每段就有20个商品可以参与秒杀。

在秒杀的过程中,先把用户id获取hash值,然后除以100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存,模为3的用户访问第3段库存,后面以此类推,到最后模为100的用户访问第100段库存。

需要注意的地方是:将锁分段虽说可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。

锁超时问题

前面提到过,如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。
影响后续临界资源操作。

通常我们加锁的目的是:为了防止访问临界资源时,出现数据异常的情况。比如:线程A在修改数据C的值,线程B也在修改数据C的值,如果不做控制,在并发情况下,数据C的值会出问题。

假设线程A加redis分布式锁的代码,包含代码1和代码2两段代码。由于该线程要执行的业务操作非常耗时,程序在执行完代码1的时,已经到了设置的超时时间,redis自动释放了锁。而代码2还没来得及执行。此时,代码2无法保证互斥性。假如它里面访问了临界资源,并且其他线程也访问了该资源,可能就会出现数据异常的情况。

如果达到了超时时间,但业务代码还没执行完,需要给锁自动续期。

我们可以使用TimerTask类,来实现自动续期的功能:

Timer timer = new Timer(); 
timer.schedule(new TimerTask() {
    @Override
    public void run(Timeout timeout) throws Exception {
      //自动续期逻辑
    }
}, 10000, TimeUnit.MILLISECONDS);
        

获取锁之后,自动开启一个定时任务,每隔10秒钟,自动刷新一次过期时间。这种机制在redisson框架中,有个比较霸气的名字:watch dog,即传说中的看门狗。

当然自动续期功能,我们还是优先推荐使用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秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。

自动续期的功能是获取锁之后开启一个定时任务,每隔10秒判断一下锁是否存在,如果存在,则刷新过期时间。如果续期3次,也就是30秒之后,业务方法还是没有执行完,就不再续期了。

主从复制的问题

如果redis存在多个实例。比如:做了主从,或者使用了哨兵模式,基于redis的分布式锁的功能,就会出现问题。

具体是什么问题?

假设redis现在用的主从模式,1个master节点,3个slave节点。master节点负责写数据,slave节点负责读数据。

本来是和谐共处,相安无事的。redis加锁操作,都在master上进行,加锁成功后,再异步同步给所有的slave。

突然有一天,master节点由于某些不可逆的原因,挂掉了。

这样需要找一个slave升级为新的master节点,假如slave1被选举出来了。

如果有个锁A比较悲催,刚加锁成功master就挂了,还没来得及同步到slave1。

这样会导致新master节点中的锁A丢失了。后面,如果有新的线程,使用锁A加锁,依然可以成功,分布式锁失效了。

那么,如果解决这个问题呢?

redisson框架为了解决这个问题,提供了一个专门的类:RedissonRedLock,使用了Redlock算法。

RedissonRedLock解决问题的思路如下:

需要搭建几套相互独立的redis环境,假如我们在这里搭建了3套。
每套环境都有一个redisson node节点。
多个redisson node节点组成了RedissonRedLock。
环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。

RedissonRedLock加锁过程如下:

循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。
如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。
如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。
如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。
从上面可以看出,使用Redlock算法,确实能解决多实例场景中,假如master节点挂了,导致分布式锁失效的问题。

但也引出了一些新问题,比如:

需要额外搭建多套环境,申请更多的资源,需要评估一下,经费是否充足。
如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。
由此可见,在实际业务场景,尤其是高并发业务中,RedissonRedLock其实使用的并不多。

你可能感兴趣的:(项目实战,redis,java)