【分布式进阶】我们来填填Redis分布式锁中的那些坑。

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

  大家好,我是Coder哥,最近在准备面试鸽了一段时间,面试告一段落了,今天我们来聊一下基于Redis锁中的那些坑。这篇分析比较全面,记得点赞收藏哟!!!

  在分布式系统开发过程中,分布式锁是我们必须要掌握的基本技能,分布式锁的实现方式有很多种,redis, zk, mysql, etcd等等,最常见还是通过Redis来实现,Redis速度是比较快也比较方便的,但是我看到很多用Redis来实现的分布式锁都或多或少的存在一定的缺陷,今天我们就这点来聊聊Redis实现分布式锁中的那些坑。

  本文就以最常用的Redis实现为例,分别从代码的实现层面分布式架构层面,由浅入深一步一步来看看分布式锁的实现中有哪些坑。一来能帮你排坑,二来也能从中学习分布式锁的思想,面试的时候能从容忽悠面试官。

代码实现层面

分布式锁演进-阶段一

我们使用redis的setnx命令来实现分布式加锁,setnx(key)意思是:

  1. 如果不存在,setnx会把key 存到redis里面(加锁成功),返回true。
  2. 如果存在key, 说明已经有人给key上锁了(加锁失败),返回false。

下面我们来看一下代码:

public String redis1() {
        // 每个实例进来先要进行加锁,key值为"good_lock",value随机生成
        String uuid = UUID.randomUUID().toString();
        try {
          // 设置锁来占位
            Boolean good_lock = stringRedisTemplate.opsForValue().setIfAbsent("good1_lock", uuid);
            if (!good_lock) {
                return "抢锁失败";
            }
            // 加锁成功,执行业务
            // 获取商品1的库存 并减一
            String goods1 = stringRedisTemplate.opsForValue().get("good1");
            Integer goods1num = Strings.isNullOrEmpty(goods1) ? 0 : Integer.parseInt(goods1);
            if (goods1num > 0) {
                int realNum = goods1num - 1;
                stringRedisTemplate.opsForValue().set("good1",String.valueOf(realNum));
                return "购买成功";
            }
            return "库存不足,购买失败";
        } finally {
            //释放锁
            stringRedisTemplate.delete("good1_lock");
        }
  }
复制代码

这段代码问题在哪?我们可以想象一下场景,一个服务抢到锁后,在执行业务的时候突然断电了,后面的锁就得不到释放,那这个key依然在Redis里面,等下个实例获取同样的锁的时候就永远获取不到了。

问题: setnx占好了位,程序在执行业务的过程中断电宕机了, 没有执行删除锁逻辑,这就造成了死锁

解决: 设置过期时间和占位必须是原子的。redis支持使用setnx ex

分布式锁演进-阶段二

在上面的代码中,如果程序在运行期间,机器突然挂了,代码层面根本就没有走到finally代码块,也就是说在宕机前,锁并没有被删除掉,这样的话,就没办法保证解锁,所以这里需要给key 加一个过期时间,在Redis中设置过期时间有两种办法:

  1. stringRedisTemplate.expire("good1_lock",30, TimeUnit.SECONDS)
  2. stringRedisTemplate.opsForValue().setIfAbsent("good1_lock", uuid, 30, TimeUnit.SECONDS)

第一种方式是单独设置过期时间,就是说需要先设置setIfAbsent("good1_lock", uuid),然后再设置过期时间,所以这是两步操作,不具备原子性,也会出问题: 比如:设置完锁后宕机了,还没来得及设置过期时间,这样依然会导致这个锁一直存在的问题

第二种方式在加锁的同时就进行了设置过期时间,所以没有问题,这里采用这种方式,代码做如下改动:

// 设置锁来占位
Boolean good_lock = stringRedisTemplate.opsForValue().setIfAbsent("good1_lock", uuid);
复制代码

改为

// 设置锁来占位
Boolean good_lock = stringRedisTemplate.opsForValue().setIfAbsent("good1_lock", uuid, 10, TimeUnit.SECONDS);
复制代码

这种方式解决了服务器宕机无法删除的问题,但是我们再来审视一下这段代码,看看还有问题没: 比如这样一个场景实例1先获取到锁good1_lock, 然后业务处理时间有点长,处理了15s, 在第10s的时候,redis已经把锁释放了,这个时候实例2也获取到锁good1_lock, 那么实例1处理完后,会执行释放锁的操作, 这时会把good1_lock 释放掉,其实这个锁是实例2的锁,也就是说实例1 由于长时间执行导致释放了实例2的锁,这是一个很严重的问题

问题: 实例1 由于长时间执行导致释放了实例2的锁

解决: 谁上的锁,谁才能删除,我们可以通过uuid来判断

分布式锁演进-阶段三

基于阶段二代码的问题,我们可以通过uuid来判断 谁上的锁,谁才能删除

代码如下:

public String redis3() {
    // 每实例进来先要进行加锁,key值为"good_lock",value随机生成
    String uuid = UUID.randomUUID().toString();
    try {
        // 加锁并设置超时时间30秒
        Boolean good_lock = stringRedisTemplate.opsForValue().setIfAbsent("good1_lock", uuid, 10, TimeUnit.SECONDS);
        if (!good_lock) {
            return "抢锁失败";
        }
        // 加锁成功,执行业务
        // 获取商品1的库存 并减一
        String goods1 = stringRedisTemplate.opsForValue().get("good1");
        Integer goods1num = Strings.isNullOrEmpty(goods1) ? 0 : Integer.parseInt(goods1);
        if (goods1num > 0) {
            int realNum = goods1num - 1;
            stringRedisTemplate.opsForValue().set("good1",String.valueOf(realNum));
            return "购买成功";
        }
        return "库存不足,购买失败";
    } finally {
        //释放锁 谁上的锁,谁才能删除
        if (uuid.equals(stringRedisTemplate.opsForValue().get("good1_lock"))) {
            stringRedisTemplate.delete("good1_lock");
        }
    }
}
复制代码

上面的代码规定了谁上的锁,谁才能删除,但finally块的判断和del删除操作并不是原子操作,并发的时候依然会存在数据一致性的问题,比如正好判断是当前值,正要删除锁的时候,锁已经过期,那么接下来的删除操作依然是别人的锁,所以我们需要保证判断和删除操作是原子的

问题: 判断 和删除是两个操作,不是原子的,有一致性问题。

解决: Redis给我们提供通过Lua脚本来执行多个命令来保证多个命令的原子性,所以我们可以通过Lua脚本来保证判断和删除两步操作的原子性。

分布式锁演进-阶段四

基于阶段三的代码,我们做一下改进,删除锁必须保证原子性。使用redis+Lua脚本完成,代码如下:

public String redis4() {
  // 每实例进来先要进行加锁,key值为"good_lock",value随机生成
  String uuid = UUID.randomUUID().toString();
  try {
    // 加锁并设置超时时间30秒
    Boolean good_lock = stringRedisTemplate.opsForValue().setIfAbsent("good1_lock", uuid, 10, TimeUnit.SECONDS);
    if (!good_lock) {
      return "抢锁失败";
    }
    // 加锁成功,执行业务
    // 获取商品1的库存 并减一
    String goods1 = stringRedisTemplate.opsForValue().get("good1");
    Integer goods1num = Strings.isNullOrEmpty(goods1) ? 0 : Integer.parseInt(goods1);
    if (goods1num > 0) {
      int realNum = goods1num - 1;
      stringRedisTemplate.opsForValue().set("good1",String.valueOf(realNum));
      return "购买成功";
    }
    return "库存不足,购买失败";
  } finally {
    try {
      //释放锁 谁上的锁,谁才能删除, redis+Lua脚本来实现
      String script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) " +
        "else " +
        "return 0 " +
        "end";
      RedisScript<String> redisScript = new DefaultRedisScript<>(script);
      String delResult = stringRedisTemplate.execute(redisScript,
                                                     Collections.singletonList("good1_lock"), Collections.singletonList(uuid));
      if ("1".equals(delResult)) {
        System.out.println("del redis lock success !");
      } else {
        System.out.println("del redis lock fail !");
      }
    } catch (Exception e) {
​
    }
  }
}
复制代码

代码演进到第四阶段了,我们解决了加锁、删除锁的原子性问题,也解决了谁上的锁谁删除的问题,但是还有一些场景我们需要考虑,比如:

加锁后,如果超时了,redis会自动清除锁,这样其实业务还没处理完锁就提前释放了,这也是个问题。

分布式锁演进-阶段五

对于上面的场景,通过前面的简单操作是解决不了的,我们可以思考一下怎么处理:

其实这个问题的关键点是锁自动清除,那么解决这个问题的方式就是自动的给锁续命,具体我们可以开个监测锁的线程来检查过期时间,等快过期的时候看看业务是否释放锁,如果释放锁就不做处理,如果没有释放锁,我们去主动的延长锁过期时间,这样就可以解决第一个问题了。这个逻辑自己实现起来还是比较费劲的,刚好Redis有个框架Redisson能帮我们处理这个问题,我们可以直接用这个框架来处理。代码如下:

@Autowired
private StringRedisTemplate stringRedisTemplate;
​
@Autowired
private Redisson redisson;
​
/**
     * 通过Redisson来保证所有的问题
     * @return
     */
public String redis5() {
  RLock lock = redisson.getLock("good1_lock");
  try {
    // 加锁并设置超时时间10秒
    lock.lock(10, TimeUnit.SECONDS); // 等价于 setIfAbsent("good1_lock",uuid+threadId,10,TimeUnit.SECONDS);
    // 加锁成功,执行业务
    // 获取商品1的库存 并减一
    String goods1 = stringRedisTemplate.opsForValue().get("good1");
    Integer goods1num = Strings.isNullOrEmpty(goods1) ? 0 : Integer.parseInt(goods1);
    if (goods1num > 0) {
      int realNum = goods1num - 1;
      stringRedisTemplate.opsForValue().set("good1",String.valueOf(realNum));
      return "购买成功";
    }
    return "库存不足,购买失败";
  } finally {
    //释放锁
    lock.unlock();
  }
}
复制代码

Redisson实现的原理就是,通过看门狗机制,意思是说,每次加锁的时候会开启一个WatchDog线程来监测redis的锁状态,如果释放锁就不做处理,如果没有释放锁,我们去主动的延长锁过期时间,对于Redisson的详细介绍这里就不展开了,有兴趣的可以自行搜索。

分布式架构实现层面

终于快到最后了,我们从基本的实现开始一步一步的分析,到最终的形态,你以为就万事大吉,可以高枕无忧了吗?还是太年轻了,上面都是基于代码层面来实现的,能用代码实现来解决的问题都不算问题,是难不倒程序员的,但是作为一个钻牛角尖的程序员我们还要从架构层面来考虑一下分布式的场景,如下:

众所周知,Redis是基于AP模型的,集群会存在异步复制导致的数据不一致问题,比如:主节点刚set好good1_lock, 此时主节还没来得及同步到其他节点就挂了,这样就造成了锁丢失的问题。

对于上面这个问题咋处理???我们可以思考一下:

既然是分布式层面,用代码已经不太好解决了,我们知道分布式的CAP原则(一致性、可用性、分区容错性),Redis是满足AP(可用性、分区容错性)的分布式系统,那么要解决这个场景的问题,只能用强一致性的CP(一致性、分区容错性)的分布式系统来处理了,所以我们的处理方式有两种:

  1. 我们能不能通过算法来把redis变为CP模型的,那这样就还能用Redis了。
  2. 直接换个CP模型的系统,比如zookeeper.

对于第一种方案 RedLock算法实现

Redisson里面就支持,只不过需要搭建一个多集群平台,我们以主从为例:

redisson1.png

其实这个就是使用自己实现的一个一致性算法 Redlock算法来保证一致性的

RedissonRedLock加锁过程如下:

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

从上面可以看出,使用Redlock算法,确实能解决多实例场景中,假如master节点挂了,导致分布式锁失效的问题。

那么这个模型也有一些缺点:

  1. 资源成本比较高。
  2. 需要加多次锁,增加了时间成本,降低了并发性。

对于第二种方案 换成CP模型的Zookeeper来实现

  zookeeper的集群间数据同步机制是当主节点接收数据后不会立即返回给客户端成功的反馈,它会先与子节点进行数据同步,半数以上的节点都完成同步后才会通知客户端接收成功。并且如果主节点宕机后,根据zookeeper的Zab协议(Zookeeper原子广播)重新选举的主节点一定是已经同步成功的。

所以解决上面的问题我们需要换成zookeeper来实现分布式锁了。具体实现这里就不写了,网上有很多。

总结

对于Redis和zk我们要怎么选呢?

我们来看一下两个组件的特点:

  • zk是保证一致性,会牺牲一定的可用性,而且他的并发能力也远远不如Redis.
  • Redis 是保证可用性,所以在极端情况下会有数据不一致的问题,但是他并发能力高,使用起来方便。

所以,如果是单机情况下,就不存在数据一致性问题了,那肯定是首选Redis, 集群下如果对并发要求比较高的业务也建议选Redis, 如果对并发要求不高但是对数据一致性要求很高可以选 zk。

最后

  其实我们大多数业务场景都是基于CAP模型思想来的,我们需要在一致性与可用性之前进行取舍,比如分布式锁就是这样的,我们两者不可兼得,要么高并发,要么高容错,其他也一样,像分布式事务也是一样的道理。

 感谢各位能看到最后,希望本篇的内容对你有帮助,有什么意见或者建议可以留言一起讨论,看到后第一时间回复,也希望大家能给个赞,你的赞就是我写文章的动力,再次感谢。这篇分析比较全面,记得点赞收藏哟!!!

猜你喜欢

转载自juejin.im/post/7127802158962966559