redis分布式锁及会出现的问题解决

一、redis实现分布式锁的主要原理:

1.加锁

最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为 “lock_sale_商品id” 。而value设置成什么呢?我们可以姑且设置成1。加锁的伪代码如下:
setnx(key,1)

当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。

2.解锁

有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令,伪代码如下:

del(key)

释放锁之后,其他线程就可以继续执行setnx命令来获得锁。

3.锁超时

锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。

所以,setnx的key必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx不支持超时参数,所以需要额外的指令,伪代码如下:

expire(key, 30)

二、加锁的代码

/**
 * 尝试获取分布式锁
 * @param jedis redis客户端
 * @param lockkey 锁
 * @param requestid 请求标识
 * @param expiretime 超期时间
 * @return 是否获取成功
 */
public static void wronggetlock1(jedis jedis, string lockkey, string requestid, int expiretime) {

 long result = jedis.setnx(lockkey, requestid);
 if (result == 1) {
  // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
  jedis.expire(lockkey, expiretime);
 }

}

上面的代码有一个致命的问题,就是加锁和设置过期时间不是原子操作。

那么会有两种极端情况:

一种是在并发情况下,两个线程同时执行setnx,那么得到的结果都是1,这样两个线程同时拿到了锁。

别一种是如代码注释所示,即执行完setnx,程序崩溃没有执行过期时间,那这把锁就永远不会被释放,造成了死锁。

之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。正确的代码如下:

/**
 * 尝试获取分布式锁
 * @param jedis redis客户端
 * @param lockkey 锁
 * @param requestid 请求标识
 * @param expiretime 超期时间
 * @return 是否获取成功
 */
public static boolean trygetdistributedlock(jedis jedis,string lockkey, string requestid, int expiretime) {

    string result = jedis.set(lockkey, requestid, "nx", "px", expiretime);
    if ("ok".equals(result)) {
      return true;
    }
    return false;

}

这个set()方法一共有五个形参:

第一个为key,我们使用key来当锁,因为key是唯一的。

第二个为value,我们传的是requestid,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是,通过给value赋值为requestid,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestid可以使用uuid.randomuuid().tostring()方法生成。

第三个为nxxx,这个参数我们填的是nx,意思是set if not exist,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

第四个为expx,这个参数我们传的是px,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。

第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

二、解锁的代码

public static void wrongreleaselock1(jedis jedis, string lockkey) {
  jedis.del(lockkey);
}

这段代码的问题是容易导致误删,假如某线程成功得到了锁,并且设置的超时时间是30秒。如果某些原因导致线程a执行的很慢很慢,过了30秒都没执行完,这时候锁过期自动释放,线程b得到了锁。

随后,线程a执行完了任务,线程a接着执行del指令来释放锁。但这时候线程b还没执行完,线程a实际上删除的是线程b加的锁。

怎么避免这种情况呢?可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁。

至于具体的实现,可以在加锁的时候把当前的线程id当做value,并在删除之前验证key对应的value是不是自己线程的id。

public static void wrongreleaselock2(jedis jedis, string lockkey, string requestid) {
    
  // 判断加锁与解锁是不是同一个客户端
  if (requestid.equals(jedis.get(lockkey))) {
    // 若在此时,这把锁突然不是这个客户端的,则会误解锁
    jedis.del(lockkey);
  }

}

但是,这样做又隐含了一个新的问题,判断和释放锁是两个独立操作,不是原子性。

解决方案就是使用lua脚本,把它变成原子操作,代码如下:

public class redistool {

  private static final long release_success = 1l;

  /**
   * 释放分布式锁
   * @param jedis redis客户端
   * @param lockkey 锁
   * @param requestid 请求标识
   * @return 是否释放成功
   */
  public static boolean releasedistributedlock(jedis jedis, string lockkey, string requestid) {

    string script = "if redis.call('get', keys[1]) == argv[1] then return redis.call('del', keys[1]) else return 0 end";
    object result = jedis.eval(script, collections.singletonlist(lockkey), collections.singletonlist(requestid));

    if (release_success.equals(result)) {
      return true;
    }
    return false;

  }

}

三、续约问题

上面加锁最后的代码就完美了吗?假想这样一个场景,如果过期时间为30s,a线程超过30s还没执行完,但是自动过期了。这时候b线程就会再拿到锁,造成了同时有两个线程持有锁。这个问题可以归结为”续约“问题,即a没执行完时应该过期时间续约,执行完成才能释放锁。怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续约”。

其实,后面解锁出现的删除非自己锁,也属于“续约”问题。

四、集群同步延迟问题

用于redis的服务肯定不能是单机,因为单机就不是高可用了,一量挂掉整个分布式锁就没用了。

在集群场景下,如果a在master拿到了锁,在没有把数据同步到slave时,master挂掉了。b再拿锁就会从slave拿锁,而且会拿到。又出现了两个线程同时拿到锁。

基于以上的考虑,redis 的作者也考虑到这个问题,他提出了一个 redlock 的算法。

这个算法的意思大概是这样的:假设 redis 的部署模式是 redis cluster,总共有 5 个 master 节点。

通过以下步骤获取一把锁:

  • 获取当前时间戳,单位是毫秒。
  • 轮流尝试在每个 master 节点上创建锁,过期时间设置较短,一般就几十毫秒。
  • 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点(n / 2 +1)。
  • 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了。
  • 要是锁建立失败了,那么就依次删除这个锁。
  • 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。

但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。

这个问题的根本原因就是redis的集群属于ap,分布式锁属于cp,用ap去实现cp是不可能的。

五、redisson

redisson是架设在redis基础上的一个java驻内存数据网格(in-memory data grid)。充分的利用了redis键值数据库提供的一系列优势,基于java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。

redisson通过lua脚本解决了上面的原子性问题,通过“看门狗”解决了续约问题,但是它应该解决不了集群中的同步延迟问题。

总结

redis分布式锁的方案,无论用何种方式实现都会有续约问题与集群同步延迟问题。总的来说,是一个不太靠谱的方案。如果追求高正确率,不能采用这种方案。

但是它也有优点,就是比较简单,在某些非严格要求的场景是可以使用的,比如社交系统一类,交易系统一类不能出现重复交易则不建议用。

到此这篇关于redis分布式锁及会出现的问题解决的文章就介绍到这了,更多相关redis分布式锁内容请搜索www.887551.com以前的文章或继续浏览下面的相关文章希望大家以后多多支持www.887551.com!

(0)
上一篇 2022年3月21日
下一篇 2022年3月21日

相关推荐