Redis分布式锁

Redis分布式锁

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

Redis分布式锁常见问题:

  • 非原子操作
  • 忘记释放锁
  • 释放了其他人的锁
  • 大量失败请求
  • 锁重入问题
  • 锁竞争问题
  • 锁超时问题
  • 主从复制问题

加锁:

// 此方式setNx命令设置锁和设置超时时间是分开的,非原子操作
if (jedis.setnx(lockKey, val) == 1) {
   jedis.expire(lockKey, timeout);
}

// 使用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:过期时间

分布式锁的合理使用方式:

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

释放锁

// 在finally块里释放锁,即使因系统宕机锁也会因设置的超时时间而释放
try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}  

但仍可能会出现释放了别人的锁的问题:

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

解决方案:根据业务场景确定requestId,使用requestId来设置lockKey.(自己只能释放自己的锁)

lua脚本加锁操作:

// redisson框架加锁代码:
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:秒杀场景:每1W个请求,有1个成功,再1W个请求,有1个成功;(不合理,合理场景应该是:1W个请求,成功1个,失败的部分应继续参与竞争)

解决方案:自旋锁,失败后休眠一段时间继续发起新一轮尝试(根据业务场景设置休眠时间尝试次数)

锁重入问题

递归加锁场景中的问题需使用可重入锁解决

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

redisson可重入锁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
  1. 先判断如果锁名不存在,则加锁。
  2. 接下来,判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次值就加1。
  3. 如果锁名存在,但值不是requestId,则返回过期时间。

redisson释放锁lua脚本:

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

锁竞争问题

通过控制锁的粒度来提升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();
}

锁超时问题

线程A获取锁执行业务由于耗时过多导致超时释放了锁,线程B开始执行,此时线程A仍在执行,会导致意想不到的情况

解决方案:锁在达到超时时间后需要给锁自动续期

// 可以使用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秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。

主从复制问题

对于哨兵模式的redis使用分布式锁问题:

刚加上锁后master节点还未来得及同步到从节点就挂了

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

RedissonRedLock解决问题的思路如下:

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

RedissonRedLock加锁过程如下:

  1. 获取所有的redisson node节点信息,循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。
  2. 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。
  3. 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。
  4. 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。

不过也引出了一些新的问题:

  1. 要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比。
  2. 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。

在分布式环境中,CAP是绕不过去的。

CAP指的是在一个分布式系统中:

  • 一致性(Consistency)
  • 可用性(Availability)
  • 分区容错性(Partition tolerance)

这三个要素最多只能同时实现两点,不可能三者兼顾。

如果你的实际业务场景,更需要的是保证数据一致性。那么请使用CP类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。

如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用AP类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。

posted @ 2021-10-17 17:32  Abserver  阅读(166)  评论(0)    收藏  举报