为您推荐:
精华内容
最热下载
问答
  • 5星
    3.44MB q6115759 2021-03-23 09:49:31
  • 5星
    1.57MB m0_52957036 2020-01-18 15:05:08
  • 5星
    5.06MB xiaohua1992 2021-04-29 10:17:56
  • 5星
    18.25MB ylcto 2020-12-23 13:59:32
  • 前言在分布式系统中,由于redis分布式相对于更简单和高效,成为了分布式的首先,被我们用到了很多实际业务场景当中。但不是说用了redis分布式,就可以高枕无忧了,如果没有用好或者用对...

    前言

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

    但不是说用了redis分布式锁,就可以高枕无忧了,如果没有用好或者用对,也会引来一些意想不到的问题。

    今天我们就一起聊聊redis分布式锁的一些坑,给有需要的朋友一个参考。

    853aeea86c9ace282b9de0777e788233.png

    1 非原子操作

    使用redis的分布式锁,我们首先想到的可能是setNx命令。

    if (jedis.setnx(lockKey, val) == 1) {
       jedis.expire(lockKey, timeout);
    }

    容易,三下五除二,我们就可以把代码写好。

    这段代码确实可以加锁成功,但你有没有发现什么问题?

    加锁操作和后面的设置超时时间是分开的,并非原子操作

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

    那么,有没有保证原子性的加锁命令呢?

    答案是:有,请看下面。

    2 忘了释放锁

    上面说到使用setNx命令加锁操作和设置超时时间是分开的,并非原子操作。

    而在redis中还有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命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。

    nice

    使用set命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。

    分布式锁更合理的用法是:

    1. 手动加锁

    2. 业务操作

    3. 手动释放锁

    4. 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。

    大致流程图如下:e3fafe532303cc5237809bac4aad1679.png那么问题来了,如何释放锁呢?

    伪代码如下:

    try{
      String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
      if ("OK".equals(result)) {
          return true;
      }
      return false;
    } finally {
        unlock(lockKey);
    }

    需要捕获业务代码的异常,然后在finally中释放锁。换句话说就是:无论代码执行成功或失败了,都需要释放锁。

    此时,有些朋友可能会问:假如刚好在释放锁的时候,系统被重启了,或者网络断线了,或者机房断点了,不也会导致释放锁失败?

    这是一个好问题,因为这种小概率问题确实存在。

    但还记得前面我们给锁设置过超时时间吗?即使出现异常情况造成释放锁失败,但到了我们设定的超时时间,锁还是会被redis自动释放。

    但只在finally中释放锁,就够了吗?

    3 释放了别人的锁

    做人要厚道,先回答上面的问题:只在finally中释放锁,当然是不够的,因为释放锁的姿势,还是不对。

    哪里不对?

    答:在多线程场景中,可能会出现释放了别人的锁的情况。

    有些朋友可能会反驳:假设在多线程场景中,线程A获取到了锁,但如果线程A没有释放锁,此时,线程B是获取不到锁的,何来释放了别人锁之说?

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

    我想这个时候,线程B肯定哭晕在厕所里,并且嘴里还振振有词。

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

    不知道你们注意到没?在使用set命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:requestId,为什么要需要记录requestId呢?

    答:requestId是在释放锁的时候用的。

    伪代码如下:

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

    在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同,如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。

    换句话说就是:自己只能释放自己加的锁,不允许释放别人加的锁。

    这里为什么要用requestId,用userId不行吗?

    答:如果用userId的话,对于请求来说并不唯一,多个不同的请求,可能使用同一个userId。而requestId是全局唯一的,不存在加锁和释放锁乱掉的情况。

    此外,使用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]);

    这是redisson框架的加锁代码,写的不错,大家可以借鉴一下。

    有趣,下面还有哪些好玩的东西?

    4 大量失败请求

    上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。

    在秒杀场景下,会有什么问题?

    答:每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。

    如何解决这个问题呢?

    此外,还有一种场景:

    比如,有两个线程同时上传文件到sftp,上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做任何控制,直接并发的创建目录,第二个线程必然会失败。

    这时候有些朋友可能会说:这还不容易,加一个redis分布式锁就能解决问题了,此外再判断一下,如果目录已经存在就不创建,只有目录不存在才需要创建。

    伪代码如下:

    try {
      String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
      if ("OK".equals(result)) {
        if(!exists(path)) {
           mkdir(path);
        }
        return true;
      }
    } finally{
        unlock(lockKey,requestId);
    }  
    return false;

    一切看似美好,但经不起仔细推敲。

    来自灵魂的一问:第二个请求如果加锁失败了,接下来,是返回失败,还是返回成功呢?

    主要流程图如下:

    c6fe83a2c5ff7f51df44537e366fc939.png显然第二个请求,肯定是不能返回失败的,如果返回失败了,这个问题还是没有被解决。如果文件还没有上传成功,直接返回成功会有更大的问题。头疼,到底该如何解决呢?

    答:使用自旋锁

    try {
      Long start = System.currentTimeMillis();
      while(true) {
         String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
         if ("OK".equals(result)) {
            if(!exists(path)) {
               mkdir(path);
            }
            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毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

    好吧,学到一招了,还有吗?

    5 锁重入问题

    我们都知道redis分布式锁是互斥的。假如我们对某个key加锁了,如果该key对应的锁还没失效,再用相同key去加锁,大概率会失败。

    没错,大部分场景是没问题的。

    为什么说是大部分场景呢?

    因为还有这样的场景:

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

    需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加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);
      }
    }

    如果你直接这么用,看起来好像没有问题。但最终执行程序之后发现,等待你的结果只有一个:出现异常

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

    这下子,大家知道出现什么问题了吧?

    没错,递归方法其实只执行了第一层递归就返回了,其他层递归由于加锁失败,根本没法执行。

    那么这个问题该如何解决呢?

    答:使用可重入锁

    我们以redisson框架为例,它的内部实现了可重入锁的功能。

    古时候有句话说得好:为人不识陈近南,便称英雄也枉然。

    我说:分布式锁不识redisson,便称好锁也枉然。哈哈哈,只是自娱自乐一下。

    由此可见,redisson在redis分布式锁中的江湖地位很高。

    伪代码如下:

    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可重入锁的实现原理。

    加锁主要是通过以下脚本实现的:

    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,则返回过期时间。

    释放锁主要是通过以下脚本实现的:

    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,则可以删除锁,然后发消息通知等待线程抢锁。

    再次强调一下,如果你们系统可以容忍数据暂时不一致,有些场景不加锁也行,我在这里只是举个例子,本节内容并不适用于所有场景。

    6 锁竞争问题

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

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

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

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

    6.1 读写锁

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

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

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

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

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

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

    • 读与写互斥

    • 写与写互斥

    6.2 锁分段

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

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

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

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

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

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

    f534d41b39e65c8a1f9d800997ec4cce.png如此一来,在多线程环境中,可以大大的减少锁的冲突。以前多个线程只能同时竞争1把锁,尤其在秒杀的场景中,竞争太激烈了,简直可以用惨绝人寰来形容,其后果是导致绝大数线程在锁等待。现在多个线程同时竞争100把锁,等待的线程变少了,从而系统吞吐量也就提升了。

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

    7 锁超时问题

    我在前面提到过,如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。

    有些朋友可能会说:到了超时时间,锁被释放了就释放了呗,对功能又没啥影响。

    答:错,错,错。对功能其实有影响。

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

    为了保证某个方法,或者段代码的互斥性,即如果线程A执行了某段代码,是不允许其他线程在某一时刻同时执行的,我们可以用synchronized关键字加锁。

    但这种锁有很大的局限性,只能保证单个节点的互斥性。如果需要在多个节点中保持互斥性,就需要用redis分布式锁。

    做了这么多铺垫,现在回到正题。

    假设线程A加redis分布式锁的代码,包含代码1和代码2两段代码。34d465c2a2f0c976189bca5361135fa5.png由于该线程要执行的业务操作非常耗时,程序在执行完代码1的时,已经到了设置的超时时间,redis自动释放了锁。而代码2还没来得及执行。

    ba7553ab6fd6a7ef8548959098d29ab6.png

    此时,代码2相当于裸奔的状态,无法保证互斥性。假如它里面访问了临界资源,并且其他线程也访问了该资源,可能就会出现数据异常的情况。(PS:我说的访问临界资源,不单单指读取,还包含写入)

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

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

    我们可以使用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秒之后,业务方法还是没有执行完,就不再续期了。

    8 主从复制的问题

    上面花了这么多篇幅介绍的内容,对单个redis实例是没有问题的。

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

    具体是什么问题?

    假设redis现在用的主从模式,1个master节点,3个slave节点。master节点负责写数据,slave节点负责读数据。5762ddc4da75e00d869f3aa7f6366307.png本来是和谐共处,相安无事的。redis加锁操作,都在master上进行,加锁成功后,再异步同步给所有的slave。

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

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

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

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

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

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

    RedissonRedLock解决问题的思路如下:

    1. 需要搭建几套相互独立的redis环境,假如我们在这里搭建了5套。

    2. 每套环境都有一个redisson node节点。

    3. 多个redisson node节点组成了RedissonRedLock。

    4. 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。

    在这里我们以主从为例,架构图如下:77cf5d1a9c7edfa1a3c98297d910bcd9.png

    RedissonRedLock加锁过程如下:

    1. 获取所有的redisson node节点信息,循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。

    2. 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。

    3. 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。

    4. 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。

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

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

    1. 需要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比。

    2. 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。

    由此可见,在实际业务场景,尤其是高并发业务中,RedissonRedLock其实使用的并不多。

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

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

    • 一致性(Consistency)

    • 可用性(Availability)

    • 分区容错性(Partition tolerance)

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

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

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

    其实,在我们绝大多数分布式业务场景中,使用redis分布式锁就够了,真的别太较真。因为数据不一致问题,可以通过最终一致性方案解决。但如果系统不可用了,对用户来说是暴击一万点伤害。

    推荐:

    主流Java进阶技术(学习资料分享)

    f2ba2fa784642ef8533cd679a5c29c39.png

    PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!

    展开全文
    weixin_38405253 2021-10-07 00:22:01
  • 为什么要用 我待过的一家k12教育公司,我们当时有个业务场景是这样的。业务这边要给学生排课,偶尔会反馈学生的课时明明充足的但是却提示课时不足,等再刷新一遍页面却发现学生的课时已经不够了。更可怕的是,...

    为什么要用锁

    我待过的一家k12教育公司,我们当时有个业务场景是这样的。业务这边要给学生排课,偶尔会反馈学生的课时明明充足的但是却提示课时不足,等再刷新一遍页面却发现学生的课时已经不够了。更可怕的是,偶尔会有学生的课时被扣成负数(公司被白嫖课时)。再比如下面这个例子

    上面的这俩个问题都是并发给我们的业务带来的问题。解决这个问题的核心就是,同一时间只能允许有一个请求来对这些敏感(重要)的数据进行读写操作。所以这个时候就要使用到分布式锁来限制程序的并发执行。

    setnx有哪些问题

    我们先来看看用Redis如何实现分布式锁,想必大家都很熟悉。比如针对我文章开头讲的学生排课的问题我们就可以这样加锁

    这就是我们常规使用setnx来实现锁的方式。

    现在我们假设有这样一个场景。A请求拿到锁到了第2步给学生排课的时候程序挂了,没有释放锁。那么这个锁就成了死锁,下一个操作同一个学生的请求永远就拿不到锁,那么这个学生就没法被排课了。这个时候都需要手动去把锁释放掉。

    为了解决死锁的问题,我们给锁加一个过期时间。

    加上过期时间之后,如果A请求没有主动释放锁,在锁过期之后也会主动释放,这样B请求一样可以获取锁处理业务逻辑。但是如果在加过期时间的时候也就是在第1步和第2步之间程序崩溃。那还是会出现死锁的问题。这个问题的根源就在于setnx和expire这两条指令不是原子指令。所以如果setnx和expire能够要么全部执行要么一个都不执行那该多好。

    为此在Redis2.8之前社区涌现了一大批扩展包来解决该问题。官方为了治理该乱象,在2.8版本中加入了set指令的扩展参数使得setnx和expire指令可以一起执行,所以现在我们使用分布式锁应该是这样了

    这样看起来已经很完美了,已经达成了我们的期望“setnx和expire能够要么全部执行要么一个都不执行那该多好”。我们再假设现在有如下场景:

    A请求现在获取到了锁,锁的超时时间设置的是5秒。到了第2步执行业务逻辑,结果因为某些原因5秒之后业务逻辑还没有执行完,此时锁由于超时自动释放了。这个时候B请求也来了,拿到锁之后开始执行业务逻辑。A请求这个时候业务逻辑执行完了,开始执行第三步,释放了锁。而这个时候锁是B请求拿到的,结果被A请求释放了。那么C请求就可以拿到锁了。这个时候B请求和C请求就会导致并发问题了。所以可以从这个例子看出来,在分布式锁中过期时间的设置非常重要,如果设置的时间小于这个接口的响应时间那么仍然会产生并发问题。所以我们可以参考接口响应时长的监控来设置锁的过期时间。

    Redlock

    我们上述的方案都是基于单点的Redis的实现方式。单点的Redis实现分布式锁基本上可以满足95%的业务场景。剩下的5%就是对数据一致性要求极其严苛并且对于锁丢失的0容忍的业务场景。这个时候就得考虑Redlock了。至于单点的Redis即使通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,如果数据主从数据同步不及时那么势必会有数据丢失,那么就会出现锁丢失的情况。

    假设存在多个Redis实例,这些节点是完全独立的,不需要使用复制或者任何协调数据的系统,我们假设有5个Redis master节点,客户端为了取到锁,步骤将会变成这样:

    1. 以毫秒为单位获取当前的服务器时间
    2. 尝试使用相同的key和随机值来获取锁,客户端对每一个机器获取锁时都应该有一个超时时间,比如锁的过期时间为10s,那么获取单个节点锁的超时时间就应该为5到50毫秒左右,他这样做的目的是为了保证客户端与故障的机器连接不耗费多余的时间!超时间时间内未获取数据就放弃该节点,从而去下一个Redis节点获取。
    3. 获取完成后,获取当前时间减去步骤一获取的时间,当且仅当客户端从半数以上(这里是3个节点)的Redis节点获取到锁且获取锁的时间小于锁额超时时间,则证明该锁生效!
    4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
    5. 如果获取锁的机器不满足半数以上,或者锁的超时时间计算完毕后为负数等异常操作,则系统会尝试解锁所有实例,即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁

    所以我们看出,redlock其实是比单点Redis看起来更加可靠的锁。

    如果你跟我一样是Node.js程序员那么正好有第三方库redlock直接使用。

    我们真的需要redlock吗

    关于redlock其实也有另外一种声音,Martin Kleppmann(剑桥大学的研究员,从事数据库、分布式系统和信息安全交叉领域的TRVE DATA项目)写过一篇blog发表了关于对redlock的一些看法,感兴趣的可以看看。Redis作者Salvatore也对这篇文章的疑问做出了一些回应,还挺有意思的。作者的blog主要的观点如下:

    分布式锁的用途无非两种:

    • 效率:使用锁可以避免不必要地做同样的工作两次(例如一些昂贵的计算)。如果锁定失败并且两个节点最终完成相同的工作,结果是成本略有增加(您最终向 AWS 支付的费用比其他情况多 5 美分)或带来轻微的不便(例如,用户最终两次收到相同的电子邮件通知)。
    • 正确性:使用锁可以防止并发进程相互干扰并破坏系统状态。如果锁定失败并且两个节点同时处理同一条数据,则结果是文件损坏、数据丢失、永久性不一致、给患者服用的药物剂量错误或其他一些非常严重的问题。

    如果是为了效率,则根本没有必要承担 Redlock 的成本和复杂性,锁丢失导致多发几次邮件和运行 5 个 Redis 服务器的成本相比,最好只使用单个 Redis 实例。如果你使用的是单个 Redis 实例,Redis 节点突然断电或者崩溃,或者出现其他问题,这个时候当然会丢失锁。但是如果你只是将锁用作效率优化,而且这种崩溃不会经常发生,那没什么大不了的。这种“没什么大不了”的场景是 也恰好是Redis优秀的地方。至少如果依赖单个 Redis 实例,那么查看系统的每个人都能够更方便的定位问题。

    如果是为了正确性,那么严格来讲,redlock根本不具有强一致的严格性。举了一些例子

    1. 时序和系统时钟做出了危险的假设,对每台服务器的时钟强依赖。因为有系统有GC的存在,做GC的时整个服务器是夯住的,时间也就停滞了,所以我们不能够对时钟有强依赖。
    2. 没有令牌。客户端每次获取锁的时候服务端没有下发令牌,服务端应该校验每次操作的时候客户端的令牌要与服务端当前的令牌一致才难操作锁。

    作者主要就是以上这些个观点,如果感兴趣的还是推荐去看看原文吧。

    结束

    有朋友私信我推荐几本关于Redis的书,以下两本我都读过。简单说下

    《Redis深度历险》这本书讲的比较的浅一点,不会讲一些很底层原理。是更偏向实践和开阔视野。比如布隆过滤器如何使用,bitmap的使用场景等等。

    《Redis设计与实践》这本书讲的比较的深,读完这本书对redis的一些疑问基本上消除了。比如说Redis为什么快。一般人可能说到内存 IO多路复用这些。但是这本书开篇就会跟你讲Redis的几种数据结构底层设计的精妙之处,这也是redis快的很重要一个原因,所以非常值得一读。

    展开全文
    mxy2404830 2022-01-23 15:11:25
  • 最近在参加学校安排的实训任务,我们小组需完成一套分布式&微服务跨境电商,虽然这题目看起来有点老套,并且队友多是 ...在昨天 review 队友代码的过程中,发现了我们组分布式的写法似乎有点问题,实现代码如...

    最近在参加学校安排的实训任务,我们小组需完成一套分布式&微服务跨境电商,虽然这题目看起来有点老套,并且队友多是 Java 技术栈,所以我光荣(被迫)
    的成为了一名前端,并顺路使用 PHP 的 Swoole 帮助负责服务器端的同学编写了几个微服务模块。在小组成员之间的协作中,还是出现了不少有趣的火花。

    在昨天 review 队友代码的过程中,发现了我们组分布式锁的写法似乎有点问题,实现代码如下:

    加锁部分
    [外链图片转存失败(img-2iS5MxTk-1565594493393)(http://www.zzfly.net/wp-content/uploads/2019/08/image.png)]

    解锁部分
    在这里插入图片描述

    主要原理是使用了 redis 的 setnx 去插入一组 key-value,其中 key 要上锁的标识(在项目中是锁死用户 userId),如果上锁失败则返回 false。但是根据二段锁的思路,仔细思考会存在这么一个有趣的现象:

    假设微服务 A 的某个请求对 userId = 7 的用户上锁,则微服务 A 的这个请求可以读取这个用户的信息,且可以修改其内容 ;其他模块只能读取这个用户的信息,无法修改其内容。
    假设微服务 A 的当前请求对 userId = 7 的用户解锁,则所有模块可以读取这个用户的信息,且可以修改其内容
    如此一来:

    • 若微服务模块 A 接收到另一个需要修改 userId = 7 的用户 的请求时,假设这个用户还在被锁状态下,这次请求可以修改它吗?(可以,解个锁就行)
    • 若微服务模块 B 接收到另一个需要修改 userId = 7 的用户 的请求时,假设这个用户还在被锁状态下,这次请求可以修改它吗?(可以,解个锁就行)
    • 若微服务模块 A 执行上锁的请求中途意外崩掉,其他用户还能修改信息吗? (可以,解个锁就行)

    很明显,这三点并不是我们所希望的。那么如何实现分布式锁才是最佳实践呐?

    ##一个好的分布式锁需要实现什么

    • 由某个模块的某次请求上锁,并且只有由这个模块的这次请求解锁(互斥,只能有一个微服务的某次请求持有锁)
    • 若上锁模块的上锁请求超时执行,则应自动解锁,并还原其所做修改(容错,就算 一个持有锁的微服务宕机也不影响最终其他模块的上锁 )

    我们应该怎么做
    综上所述,我们小组的分布式锁在实现模块互斥的情况下,忽略的一个重要问题便是“请求互斥”。我们只需要在加锁时,key-value 的值保存为当前请求的 requestId ,解锁时加多一次判断,是否为同一请求即可。

    那么这么修改之后,我们可以高枕无忧了吗?

    是的,够用了。因为我们开发环境 Redis 是统一用一台服务器上的单例,采用上述方式实现的分布式锁并没有什么问题,但在准备部署到生产环境下时,突然意识到一个问题:如果实现主从读写分离,redis 多机主从同步数据时,采用的是异步复制,也便是一个“写”操作到我们的 reids 主库之后,便马上返回成功(并不会等到同步到从库后再返回,如果这种是同步完成后再返回便是同步复制),这将会造成一个问题:

    假设我们的模块 A中 id=1 的请求上锁成功后,没同步到从库前主库被我们玩坏了(宕机),则 redis 哨兵将会从从库中选择出一台新的主库,此时若模块 A 中 id=2 的请求重新请求加锁,将会是成功的。

    技不如人,我们只能借助搜索引擎划水了(大雾),发现这种情况还真的有通用的解决方案:redlock。

    怎么实现 Redlock 分布式安全锁

    首先 redlock 是 redis 官方文档推荐的实现方式,本身并没有用到主从层面的架构,采用的是多态主库,依次去取锁的方式。假设这里有 5 台主库,整体流程大致如下:

    加锁

    1. 应用层请求加锁
    2. 依次向 5 台 redis 服务器发送请求
    3. 若有超过半数的服务器返回加锁成功,则完成加锁,如果没有则自动执行解锁,并等待一段随机时间后重试。(客观原因加锁失败:网络情况不好、服务器未响应等问题, 等待一段随机时间后重试可以避开“蜂拥而进”的情况造成服务器资源占用瞬时猛增 )
    4. 如有其中任意一台服务器已经持有该锁,则加锁失败, 等待一段随机时间后重试。 (主观原因加锁失败:已经被被别人锁上了)

    解锁

    直接向 5 台服务器发起请求即可,无论这台服务器上是不是已经有锁。
    整体思路很简单,但是实现起来仍有许多值得注意的地方。在向这 5 台服务器发送加锁请求时,由于会带上一个过期时间以保证上文所提到的“自动解锁(容错性) ”,考虑到延时等原因,这 5 台机自动解锁的时间不完全相同,因此存在一个加锁时间差的问题,一般而言是这么解决的:

    1. 在加锁之前,必须在应用层(或者把分布式锁单独封装成一个全局通用的微服务亦可)2. 记录请求加锁的时间戳 T1
    2. 完成最后一台 redis 主库加锁后,记录时间戳 T2
    3. 则加锁所需时间为 T1 – T2
    4. 假设资源自动解锁的时间为 10 秒后,则资源真正可利用的时间为 10 – T1 + T2。若
      可利用时间不符合预期,或者为负数,你懂的,重新来一遍吧。
      如果你对锁的过期时间有着更加严格的把控,可以把 T1 到第一台服务器加锁成功的时间单独记录,再在最后的可用时间上加上这段时间即可得到一个更加准确的值
      现在考虑另一个问题,如果恰好某次请求的锁保存在了三台服务器上,其中这三台都宕机了(怎么这么倒霉… TAT),那此时另一个请求又来请求加锁,岂不又回到最初我们小组所面临的问题了?很遗憾的说,是的,在这种问题上官方文档给出的答案是:启用AOF持久化功能情况会得到好转 ?

    关于性能方面的处理, 一般而言不止要求低延时,同时要求高吞吐量,我们可以按照官方文档的说法, 采用多路传输同时对 5 台 redis 主库进行通信以降低整体耗时,或者把 socket 设置成非阻塞模式 (这样的好处是发送命令时并不等待返回,因此可以一次性发送全部命令再进行等待整体运行结果,虽然本人认为通常情况下如果本身网络延迟极低的情况下作用不大,等待服务器处理的时间占比会更加大)

    若有任何疑问,可以移步我的博客:http://www.zzfly.net/redis-redlock/ 留言讨论

    展开全文
    winyuan789 2019-08-12 15:23:50
  • 下面具体一下这两种持久化方式。 为什么需要持久化 因为对redis的数据操作都是在内存中的,因此如果redis宕机或者重启后,没有持久化机制,就无法恢复原先数据,持久化的作用就是为了保存数据,更快的做数据恢复,...

    redis中持久化有两种,一种是RDB持久化,另一种是AOF持久化,如果同时开启的话,会先进行AOF持久化,默认情况下redis.conf配置中AOF持久化是关闭的。下面具体聊一下这两种持久化方式。

    为什么需要持久化

    因为对redis的数据操作都是在内存中的,因此如果redis宕机或者重启后,没有持久化机制,就无法恢复原先数据,持久化的作用就是为了保存数据,更快的做数据恢复,提高系统的可用性。

    RDB持久化

    RDB持久化的目的:将Redis在内存中的数据库状态保存在磁盘上,避免数据丢失。
    RDB既可以手动执行,也可以根据服务器配置选项定期执行。
    RDB文件是一个经过压缩的二进制文件,通过文件可以还原数据库状态。
    通过执行save/bgsave命令生成rdb文件。

    那这两个命令有什么区别吗?

    1. save会阻塞redis服务器进程,直到RDB文件创建完成为止,在服务器进程阻塞期间,服务器不能处理任何命令请求。
    2. bgsave会fork出一个子进程,由子进程创建RDB文件,父进程继续处理客户端命令。
    pid=fork()
    if pid==0: //子进程创建rdb文件
     rdbSave()
     signal_parent() //创建rdb文件完成后向父进程发送信号
    elif pid>0:
     handle_request_and_wait_signal() //父进程继续处理客户端命令请求,通过轮询等待子进程的消息
    else:
     handle_fork_error()
    

    载入RDB文件没有专门的命令,只要redis服务器在启动时检测到RDB文件存在,就会自动载入。载入期间,服务器一直处于阻塞状态,知道载入完成。

    服务器在处理SAVE,BGSAVE,BGREWRITEAOF命令会有所不同

    1. 在BGSAVE命令执行期间,SAVE会被拒绝执行,这是为了避免父子进程同时调用rdbSave()方法,防止竞争条件
    2. BGSAVE执行期间,客户端发送的BGSAVE也会被拒绝执行。
    3. BGSAVE执行期间,客户端发送的BGREWRITEAOF会在它执行完之后再执行;如果BGREWRITEAOF正在执行,那么客户端发送的BGSAVE命令将会被拒绝执行。因为这两个命令实际工作都是由子进程执行,所以这两个命令在操作方面并没有冲突的地方,不能同时执行时因为两个并发执行写磁盘会严重影响性能。

    RDB配置

    save 90 1 # 表示90秒内至少执行过1次修改,就会触发RDB写磁盘 其他配置同理
    save 300 10
    save 60 1000
    

    怎样检查保存条件是否已满足才去执行BGSAVE?
    答:服务器周期性曹组函数ServerCron默认每个100ms就会执行一次,该函数用于对正在运行的服务器进行维护,其中一项工作就是检查save所设置的保存条件是否满足,如果满足才会去执行持久化。

    使用od -c dump.rdb /od -cx dump.rdb命令可以打印rdb文件,-c参数是以ASCII编码打印,-x是以16进制打印文件

    rdb文件结构

    在这里插入图片描述

    AOF持久化

    AOF持久化:将redis服务器的写命令保存到磁盘,以此记录数据库的状态。
    被写入的命令是以Redis请求协议格式保存的存文本格式。
    AOF持久化的实现分为命令追加,文件写入,文件同步。
    当AOD持久化功能开启的时候,服务器执行一个写命令之后就会以协议格式将这条写命令追加到服务器转改的aof_buf缓冲区的末尾。

    具体执行什么时候执行还是由ServerCron监听,他来处理定时任务。
    因为Redis服务器进程本身就是一个事件循环,循环处理文件事件和时间事件。文件事件时接收并处理客户端的命令请求,时间事件时负责执行ServerCron定时任务。

    flushAppendOnlyFile函数决定是否将aof_buf缓冲区的命令写入到AOF文件中,他的行为通过配置文件中的appendfsync选项设置。

    • always:将缓冲区的所有内容写入并同步到AOF文件
    • everysec:如果距上次同步时间到现在超过1s,就对AOF进行同步
    • no:只负责将缓冲区的写命令写入,不负责同步,何时同步由操作系统决定。
      如果服务器宕机,会出现安全性问题
      no: 丢失所有数据 持久化效率高
      everysec:丢失前1s的数据,持久化效率高
      always:丢失一个事件循环命令,持久化效率低

    使用AOF文件还原数据库状态的过程

    1. 服务器启动载入程序
    2. 创建伪客户端
    3. 从AOF文件分析并读取一条写命令
    4. 伪客户端执行写命令
    5. 是否执行完所有写命令:否->继续执行,是->载入完毕

    AOF重写
    文件重写的目的:为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能,将一个新的AOF文件不包含荣誉命令,替代旧的大文件。

    实现原理
    首先从数据库读出现在的键值,然后用一条命令去记录键值对,代替之前多条命令记录。
    注意:为了避免在重写过程中造成客户端输入缓冲区溢出,重写程序需要处理集合,有序集合,哈希,列表键等,先检查所包含的元素数量是否超过redis.h/REDIS_AOF_REWRITE_TIMES_PER_CMD常量的值,如果超过限制,需要多条添加元素的命令。

    由于AOF是单线程,重写也是单独一个线程调用重写函数,执行时间较长,可能导致服务器阻塞无法处理客户端的请求,因此redis将AOF重写程序放到子进程中执行,在子进程进行重写期间,服务器进程还可以继续处理客户端发来的命令请求,子进程带有进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。

    问题:
    但是因为子进程在进行重写期间,服务器还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而造成数据库状态和重写后的AOF文件中记录的状态不一致。
    解决方法:
    使用一个AOF重写缓冲区,AOF重写缓冲区在子进程创建之后开始使用,当Redis服务器执行完一个写命令后,会同时将写命令放入AOF缓冲区(aof_buf)和AOF重写缓冲区,当子进程完成AOF重写之后,会向父进程发一个信号,告诉他我已经重写完了,父进程收到这个信号之后,会调用一个信号处理函数,让AOF重写缓冲区中的数据全部写入到新的AOF文件中,对新文件重命名,原子地覆盖旧的文件。

    参考:《Redis设计与实现 黄建宏》

    展开全文
    qq_43672652 2021-05-29 11:42:58
  • m0_53462955 2021-09-17 15:30:37
  • qq_37191690 2020-09-02 18:33:01
  • weixin_43889841 2021-01-13 18:31:20
  • yyx3214 2019-09-06 17:14:22
  • Try_harder_every_day 2018-05-10 15:16:47
  • weixin_57907028 2021-05-05 14:51:01
  • fuzhongmin05 2021-07-30 16:50:22
  • qq_41563912 2021-08-19 18:15:57
  • wufaliang003 2020-04-29 10:51:32
  • 374KB weixin_38501826 2021-01-07 15:25:00
  • weixin_44907128 2021-12-26 16:12:04
  • qq_35190492 2020-04-13 22:18:06
  • xl465564754 2021-10-22 15:20:11
  • weixin_44907128 2021-12-19 11:01:41
  • yyx3214 2019-09-05 21:23:37
  • tianruirui 2020-10-12 08:15:49
  • shuangyueliao 2019-04-16 23:13:29
  • weixin_44907128 2021-12-30 21:31:47

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 11,674
精华内容 4,669
关键字:

聊聊redis锁

redis 订阅