风澜

V1

2022/06/08阅读:22主题:红绯

自从学了这篇Redis分布式锁,一口气拿了3个offer

Redis分布式锁的大名风澜相信很多同学都听说过,也有绝大部分同学在工作中用到过,但你有考虑过你的Redis分布式锁用的方式正确吗?它会不会有什么问题?本篇内容风澜就和大家一起再深入的了解一下我辈搬砖工与Redis分布式锁之间的孽缘。

@

一、概述

目前市面上的无论是互联网公司,还是传统软件公司,都在推广微服务、分布式架构,它相较于传统单体式架构有着很多优势,比如:高可用性、可扩展性等等。

但是当我们将一个单体应用部署成分布式应用时,就会产生一个并发的问题。原先我们就只需要在代码里使用Synchronized锁就可以防止单节点并发带来的数据安全问题。但是现在却不行了。因为Synchronized只能单机下使用,如果跨JVM,Synchronized锁住的资源无法被其它JVM应用所感知,那么其它应用就还可以获取到这个锁。

某一天小A身体不舒服,来医院看医生。这时好巧不巧,小B今天也来医院看同一个医生。

此时正规的流程应该是怎么样的呢?

  1. 先拿号
  2. 等叫号进诊室
  3. 望闻问切
  4. 出诊室
  5. 下一个人进去

但是如果两个人不按套路来,同时进去了,这个时候你能确保医生所说的病症就是你的病症吗?

所以我们上述的场景是一定要一个锁,把门锁住,没有解锁前,谁都不能进这个诊室。这样才能确保看病的结果是准确的。

这个锁住之后的5个操作,我们也可以称为 原子性操作。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。

二、为什么我们要使用分布式锁

我们在使用一个技术之前,都要问自己为什么要使用这个技术,不用行不行?俗话说的好,好钢用在刀刃上,所以我们要根据我们项目的应用场景来判断是否需要使用这个技术。要是不管三七二十八,乱用的话,会让你的项目显得异常的臃肿,并且伴随着经常出错、难维护的风险。

那么我们在上述内容中已经做了分析,当一个共享资源被多个用户抢占时,就会发生资源的安全与正确性等问题,所以针对这种情况,我们就要使用分布式锁去锁住这个资源,以防止问题的发生。

目前各大厂子间流行的分布式应用就是如此,分布式应用中会经常发生共享资源被多用户、多线程同时访问的情况。

三、Redis分布式锁的应用

1. 从源码的角度分析Redis为什么可以用于分布式锁

分布式锁为什么可以用Redis去做?我相信很多同学在第一次使用的时候都在考虑这个问题。那么我们想一想,如果想用Redis去当一把锁,那么他就要满足本身没有共享资源安全的问题。 然而你去百度一查就知道,Redis是使用单线程执行我们发送给它的命令的,也就是说我们发送给Redis的命令,都是排队按顺序执行的。 所以当一个客户端在给一个资源加锁的时候,只有这个客户端加完锁了,第二个客户端请求才能进来,当然第二个客户端一定会发现资源已经上锁。这就满足了我们对加锁最主要的需求。

Redis的加锁流程: client1查询key存不存在,如果不存在,就插入key与value,加锁成功。 client2查询key存不存在,发现key存在,加锁失败,等待或直接返回。

Redis的单线程执行流程如下图:

具体代码操作如下:所有代码皆为Windows系统环境下debug过程

  • Redis启动的时候会调用server.c的main函数
  • main函数会调用ae.c的aeMain函数
  • aeMain函数里面有一个无限while循环,循环调用操作系统select方法,看有没有新的文件事件产生,如果有就执行。

我们发送到Redis的命令就是一个文件事件

server.c

int main(int argc, char **argv) 
{
   //省略一堆代码

   //主要执行命令的方法
   aeMain(server.el);
   aeDeleteEventLoop(server.el);
   return 0;
}
ae.c

void aeMain(aeEventLoop *eventLoop) 
{
   eventLoop->stop = 0;
   //主要服务不stop,就无限循环
   while (!eventLoop->stop) {
       if (eventLoop->beforesleep != NULL)
           eventLoop->beforesleep(eventLoop);
       //主要执行命令的函数
       aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
   }
}
ae.c

int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
 //执行时间事件先省略

 //调用select 阻塞等待
 numevents = aeApiPoll(eventLoop, tvp);
 /* After sleep callback. */
 if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
  eventLoop->aftersleep(eventLoop);
  for (j = 0; j < numevents; j++) {
   //处理文件事件
  }
  
 //处理已到期的时间时间
    if (flags & AE_TIME_EVENTS)
        processed += processTimeEvents(eventLoop);
}

由上述可知,Redis只有启动时的主线程会无限循环的处理我们发送给它的命令,并没有单独去起新线程,它的请求都是排队处理的,不存在资源竞争问题,由此可见,它可用于分布式锁实现

2. 常用的分布式锁指令

  • SETNX指令 插入一个key value 键值对,如果key不存在就会插入成功,返回1。key存在就插入失败,返回0。 我们猛地一看这个命令好像行,但是有个问题,我们要是给资源上锁之后,一直没解锁,就死锁了咋办?所以就有了下面这个命令。
  • EXPIRE:expire key seconds 可以为key增加一个时间。
  • SETEX 指令:上面的俩命令同时使用,才能为key增加过期时间,不能保证原子性操作。所以就有了这个指令,setex flkey 10 flvalue 可以在插入key-value时同时原子的插入过期时间。(适用于redis版本>=2.0.0)
  • SET命令:当然还有我们的set命令,支持在插入key-value后同时过期时间。如果插入并设置成功,返回OK,反之返回(nil)set flkey flvalue ex 10 nx。(适用于redis版本>=2.6.12)

set命令同时设置超时时间是通过参数实现的,不管是set还是setnx还是setex,最终都是调用了t_string.c的setGenericCommand方法,方法中做了过期时间的判断,详见小编的另一篇文章 ,单步调试Redis源码——set key value方法

2.Java中常用的Redis客户端

需要引入的依赖这里就不提了,百度很容易就能找到。

  • RedisTemplate:

redisTemplate的使用 :redisTemplate.opsForValue().set(key, value, time, TimeUnit); 它的原理其实就是封装的setex方法

  • Jedis:

Jedis的使用: jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

四、遇到的问题及解决方案

1. 超时未解锁

此问题解决方案,上面已经提过,可见上面的setex方法或set方法同时设置过期时间。

2. 锁的时间不够

我们在实际使用中,会有这种情况,就是当你设置一个10秒钟的超时时间,但是你当前需要执行的方法在某一次突然需要11秒才能执行完。这个时候在第10秒的时候就释放锁了,下一个请求就可以获取到锁,但是我们前一个请求没执行完,就可能会导致数据出错。

那么我们有什么解决方案吗?当然有呀。

(1) 将时间设置的大一些

依小编看完全可以设置一周吗。(小编的领导已经拎着搬砖在路上了)

(2) 开启守护线程

我们可以在每次加锁的时候开启一个守护线程,然后守护线程为锁续期,具体流程如下

这样在你执行方法的时候就可以保证一直持有锁, 此时小编的领导已经要把搬砖送给小编了。而且小编的服务器这时也因为创建了太多线程挂掉了

(3) Redission

小编这时也想到了,上面那个守护线程的方案急需改进,这时就想到了我们大名鼎鼎的Redisson,它实现的watch dog可以对锁自动延期,不是小编那种每一次都创建新线程去延期,而是有专门的线程,就只负责这个锁延期功能。

3. 任何线程都可以解锁

小编曾经看到有人写分布式锁的时候,在try catch代码块里进行加锁操作,然后在finally进行释放,但是释放的时候没有做任何判断,不管是谁,是哪一个线程,都能释放锁,如下代码所示。

public void t1() {
      String key = "flKey";
      String value = "flValue";
      try {
            if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){
              throw new RuntimeException("加锁失败");
            }
      }catch (Exception e){
        //省略
      }finally {
      //释放锁
        redisTemplate.delete(key);
      }
  }

上面这个加锁与释放锁逻辑就会导致一个问题,那就是线程1加锁,执行业务逻辑,逻辑还没执行完,这时,线程2来了,发现加锁失败,然后直接走到了finally,进行锁的释放,那这时线程1还没执行完,锁就被线程2释放了,之后来的线程又可以正常获取到锁,就会出问题。

当时这么写的同学我们叫他胖虎,也不知为何,小编之后就再也没见过他。 那么我们应该如何解决这个问题呢?答案很简单,我们加一个标志位,不就OK了吗?

  public void t1() {
      String key = "flKey";
      String value = "flValue";
      boolean isLock = false;
      try {
            if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){
              throw new RuntimeException("加锁失败");
            }
            isLock = true;
            //执行业务逻辑
      }catch (Exception e){
        //省略
      }finally {
        //判断加锁标志位
        if(isLock){
          redisTemplate.delete(key);
        }
      }
  }

4. 在事物提交前解锁

小编再解决完上面的问题不久之后,又发现了一段有问题的代码,点开提交记录一看,果然又是胖虎。

代码如下

   @Transactional
    public void t2(int a, int b) {
      String key = "flKey";
      String value = "flValue";
      boolean isLock = false;
      try {
            if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){
              throw new RuntimeException("加锁失败");
            }
            isLock = true;
            if(a == 1){
             aMapper.update(b);
            }
      }catch (Exception e){
        //省略
      }finally {
        //判断加锁标志位
        if(isLock){
          redisTemplate.delete(key);
        }
      }
  }

小编凭借着这么多年的12K钛合金狗眼,一眼就看穿了这个代码的本质,这finally是在return之前执行的呀,那就是说这个方法是先解锁,然后再提交事务,那恰巧就在这个时候我们解锁了,事务没提交,下一个线程来了,修改了数据,就会产生数据安全问题

于是小编灵机一动将这段代码的业务逻辑、数据库交互逻辑抽了出来,行程一个单独的方法,加锁后调用这个方法,如下所示,这样就可以在事物提交完进行解锁了。

   @Transactional
    public void t2(int a, int b) {
      String key = "flKey";
      String value = "flValue";
      boolean isLock = false;
      try {
            if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){
              throw new RuntimeException("加锁失败");
            }
            //执行数据库更新逻辑,这样就执行完逻辑提交事物后再执行下面的解锁操作了
            ((TestA) AopContext.currentProxy()).processUpdate(a,b);//这里通过代理的形式调用同一个类的方法注解才会生效
      }catch (Exception e){
        //省略
      }finally {
        //判断加锁标志位
        if(isLock){
          redisTemplate.delete(key);
        }
      }
  }

5. 主从切换问题

Redis主从/集群模式下。

  1. 当你的锁刚写入Redis的主节点,返回写入成功。
  2. 锁信息还没来得及复制到从节点时,突然主节点挂掉了,这就会导致你的锁“丢了”。
  3. 当一个新的线程来的时候,正好发生了主从切换,从节点变为主节点。
  4. 由于从节点还没有复制你刚刚写入的锁信息,导致这个新线程又可以成功加锁。

以上的步骤就会导致分布式锁失效,那么我们怎么去解决这个问题呢?

(1) RedLock算法

RedLock算法遵循一个过半原则,当过半的从节点/集群节点都写入了锁信息,才会返回写入成功。这就保证了即时主节点挂了,从节点也能不丢失数据。 但是RedLock算法却会带来额外的性能与时间消耗,也增加了系统复杂性,并且它也不一定是完全可靠的。

这里扩展一个Redis的 Wait 指令。Redis 的主从复制是异步进行的,wait 指令可以让异步复制变成同步复制,确保系统的强一致性 (不严格)。wait 指令是 Redis3.0 版本以后才出现的。

(2) ZK实现的分布式锁

ZK实现分布式锁,是客户端向ZK注册了一个临时顺序节点。。生成临时顺序节点的名字ZK会保证唯一,不会出现重复。当新的注册请求来的时候,ZK先去查节点名有没有注册过,有的话就直接在名字后+1,然后监听前一个注册的节点,当前一个节点释放时,会主动通知当前节点。

  • ZK通过ZAB协议保证数据不出现Redis那种主从切换数据丢失的情况。
  • Redis是主动轮询去查锁有没有释放。ZK是给锁注册一个监听器,当释放时,会进行通知。
  • 不用设置过期时间,如果你的服务挂了,临时顺序节点也会自动释放。

ZK分布式锁这里就不做过多介绍了,感兴趣的同学可自行百度,或等小编后续文章。

五、可重入性

Redis锁的可重入性,小编认为可以将键值对中的value设置为你的机器ID+你的线程ID,这样再次申请锁的时候,可以判断Value值,进而决定是否允许重入。

六、LUA

Redis提供了非常丰富的指令集,但是有些场景下,确实不满足我们的需求。所以Redis提供了LUA脚本来支持我们的个性化场景。并且 Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。 比如我们在用分布式锁的时候,可以将匹配key和删除key都让Redis自己去做,并且要保证两条指令的原子性。

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

执行LUA脚本的时候要通过eval函数进行执行

127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 flKey flValue
(integer) 1

五、公平锁与非公平锁

Redisson中实现了公平锁与非公平锁。这里只提一下,就不做过多介绍了。

//获取公平锁
RLock lock = redissonClient.getFairLock("flKey"
//获取非公平锁
RLock lock = redissonClient.getLock("flKey"

附: 一个Redis命令行体验的在线小玩意

TRY REDIS


求点赞,求关注,求收藏。 如果文章有任何错的地方,请不吝赐教,小编将不胜感激。

分类:

后端

标签:

后端

作者介绍

风澜
V1