关于Redis分布式锁安全性的思考(上)

·  阅读 1291

  分布式应用进行逻辑处理时经常会遇到并发问题,这个时候就要使用到分布式锁来限制程序的并发执行。分布式锁的实现方式有很多种,ZooKeeperRedis还有MySql的排他锁等等,网上相关的文章也是层出不穷。怎么说呢,个人感觉:凡是跟分布式沾点边的东西,就很难找到一种完美的解决方案。各有优缺点吧,我们在选型的时候吧跟找对象一样,适合自己的才是最好的。

  我刚接触redis不久的时候,有一个需求是写一个过滤重复请求的AOP。于是就有了下面这段代码,今天偶然间review了下,虽然当时注意到了一些细节,但还是有满多槽点的。当然这段代码已经上线一年了,而我也不在那家公司了(手动狗头)。我想通过这段代码,和一些刚刚接触redis的朋友分享一下,设计分布式锁应该注意哪些问题。

从一段线上代码思考如何设计redis锁

private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(10);

    private static final String DELIMITER = "|";

    @Autowired
    private StringRedisTemplate template;


    /**
     * 延迟unlock
     *
     * @param lockKey   key
     * @param uuid      client(最好是唯一键的)
     * @param timeout   超时时间
     * @param unit      时间单位
     */
    public boolean lock(String lockKey, final String uuid, long timeout, final TimeUnit unit) {
        final long milliseconds = Expiration.from(timeout, unit).getExpirationTimeInMilliseconds();
        final long currentTimeMillis = System.currentTimeMillis();
        boolean success = template.opsForValue().setIfAbsent(lockKey, ( currentTimeMillis + milliseconds) + DELIMITER + uuid);
        if (success) {
            //上锁成功
            template.expire(lockKey, timeout, unit);
        } else {
            String oldVal = template.opsForValue().getAndSet(lockKey, (currentTimeMillis + milliseconds) + DELIMITER + uuid);
            final String[] oldValues = oldVal.split(Pattern.quote(DELIMITER));
            if (Long.parseLong(oldValues[0]) + 1 <= currentTimeMillis) {
                //临界区间,判断锁是否失效,失效重新获取锁
                template.expire(lockKey, timeout, unit);
                return true;
            }
        }
        return success;
    }

    /**
     * 延迟unlock
     *
     * @param lockKey   key
     * @param uuid      client(最好是唯一键的)
     * @param delayTime 延迟时间
     * @param unit      时间单位
     */
    public void unlock(final String lockKey, final String uuid, long delayTime, TimeUnit unit) {
        if (StringUtils.isEmpty(lockKey)) {
            return;
        }
        if (delayTime <= 0) {
            doUnlock(lockKey, uuid);
        } else {
            EXECUTOR_SERVICE.schedule(() -> doUnlock(lockKey, uuid), delayTime, unit);
        }
    }

    /**
     * @param lockKey key
     * @param uuid    client(最好是唯一键的)
     */
    private void doUnlock(final String lockKey, final String uuid) {
        String val = template.opsForValue().get(lockKey);
        final String[] values = val.split(Pattern.quote(DELIMITER));
        if (values.length <= 0) {
            return;
        }
        //确保当前线程占有的锁不会被其它线程释放
        if (uuid.equals(values[1])) {
            template.delete(lockKey);
        }
    }
复制代码

1.1 基本设计思路

  • 获取锁

  获取锁实际就是在redis里面占一个“坑”,当一个线程先抢到了这个“坑”,下一个需要进这个坑位的线程就在外边等着。这里我们会使用setnx(set if not exists) 指令,对应代码中的方法是template.opsForValue().setIfAbsent(key,value)。意思是我拿一个key看下redis里面有没有,如果没有,就创建一个把value设置进去,如果有了就拜拜。这里返回true我们就认为线程是第一个访问的,抢到了redis锁,返回false说明前面已经有人再用了。

  正常情况我们在获取锁后,执行业务逻辑,然后在释放锁。如果执行业务逻辑时发生了异常,可能就走不到释放锁的操作,会造成死锁,消耗客户端资源。所以在拿到锁以后,我们可以通过expire设置一个过期时间,即使出现异常也能保证锁在有效时间后会自动失效,最终无效的key被redis回收。

  • 释放锁

  释放锁执行del指令就可以了,因为前面我们设置了过期时间的缘故,这里我们可以写一个定时job,等到失效时间过了来执行删除操作即可

1.2 缺陷

  上面提到了我们通过setnxexpire指令来获取锁,通过del指令来释放锁,这是我们设计redis锁的基本思路,但同样存在一些问题。

  • setnxsetex 并不是原子性操作

  如果在 setnxexpire 之间服务器进程突然挂掉了,会导致 expire 得不到执行,也会造成死锁。这种问题的根源就在于 setnxexpire 是两条指令而不是原子指令。

  • 删除也不是绝对安全的

  线程A在获取锁后,执行业务逻辑,但是业务逻辑执行的时间太长了,锁已经失效了。这个时候线程B重新持有了锁,开始执行业务逻辑。A线程开始执行释放锁操作,把B的锁释放了。

1.3 解决思路

  上面两个问题是我在写代码之前就有了解到的,我来聊一下体现在代码里的解决思路。

  • setnxsetex 并不是原子性操作

  这个问题其实在Redis2.6.12之前都是通过lua脚本解决的。Redis 2.6.12版本中作者加入了 set 指令的扩展参数,使得 setnxexpire指令可以一起执行,彻底解决了分布式锁的乱象。

  那么大家肯定会问了,说好的lua脚本呢?你的代码里怎么没有呢?

  说到这里,我不得不说一下我的心酸史。起初我也是网上找了一个lua脚本的demo,kuangkuang就给干上去了,测了下也没啥问题,当时还觉得自己挺吊。等到上线的时候,接口跌停了。我们当时的redis是在k8s里的,跟测试环境也不一样。版本比较低,不支持lua脚本,尼玛我当时就尿了,回滚了代码。后来查阅资料才知道,从 Redis 2.6.0后才支持 lua 脚本的执行 。所以说朋友们,我们在项目了引入什么新鲜东西的时候,一定要注意实际的生产环境呀!! 还有个教训就是和redis有关的操作都try catch下吧,等你们redis出故障的时候,你会来感谢我的。

  不能用lua脚本,保证不了原子性,我想了一个补偿方案,曲线救国。假设线程A在设置expire的时候失败了,线程B进来会抢不到锁。这个时候如果我们能知道线程A是什么时候访问的,自己来判断下它是否过期,如果过期了,我们就认为这个锁是无效的,把它给B线程用就好了。所以在代码中,我们的value记录了锁的过期时间。下一个线程进来时通过getAndSet获取上一次的value值拿来做比对,在将自己的value写入redis中。getAndSet是一个原子操作,就这样完成了替换。当然在对B线程设置过期时间时依然会存在原子性问题,那就下一次补偿吧,我也没招了。后来我们把redis从容器中拿了出来,升级了版本,就不存在这个问题了。

  • 删除也不是绝对安全的

  解决这个问题,首先要保证锁的唯一性。就是获取的锁和释放的锁应该是独有的,所以在代码中我们加入UUID作为锁的标识。在删除时我们去对比下UUID,如果匹配上了在进行删除。但是这里又会出现另外一个问题:获取和删除不是原子的呀!所以释放锁,一定要使用lua脚本。保证其原子性。

  哦,兄弟们可能又要问了,为啥我的代码里没有。哈哈版本不支持,然后因为我做的需求是过滤重复请求,对于重复的请求挡掉就可以了,不需要让他们阻塞,所以当时就没有对释放锁这块进行处理。我补上好吧!

  //释放锁lua脚本
  private static final String RELEASE_LOCK_LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  
  private void doUnlock2(final String lockKey, final String uuid) {

          // 指定 lua 脚本,并且指定返回值类型
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(RELEASE_LOCK_LUA_SCRIPT,Long.class);
        try {
            template.execute(redisScript, Collections.singletonList(lockKey),uuid);
        } catch (Exception e) {
            e.printStackTrace();
        }
  }

复制代码

   在看了我的当时的心路历程后,相信兄弟们对redis锁已经有点概念了。其实上面考虑的情况都是单机版Redis存在的问题,稍加注意都可以解决,集群情况下仍存在的隐患。其实说句实话,很多东西是把非常极端的情况考虑了进去,至少这垃圾代码在线上跑了一年多倒是没啥事故,但我们做技术还是要严谨些,考虑的全面些。

设计Redis锁你需要注意

2.1 必须设置过期时间

锁必须要设置一个过期时间。否则的话,当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了。

2.2 执行exprie之前客户端崩溃了怎么办

要看下redis的版本,2.6.0以上的版本就可以通过lua脚本合并setnxexprie解决。2.6.12以后set命令增加了EX,PX,NX和XX选项支持了过期时间的设置。

2.3 保证value值的唯一性

设置一个随机字符串是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能某个客户端因为阻塞等原因,可能会误删其他客户端正在持有的锁。

2.4 释放锁必须使用lua脚本

释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:GET、判断和DEL,用Lua脚本来实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:

  1. 客户端1获取锁成功。
  2. 客户端1访问共享资源。
  3. 客户端1为了释放锁,先执行GET操作获取随机字符串的值。
  4. 客户端1判断随机字符串的值,与预期的值相等。
  5. 客户端1由于某个原因阻塞住了很长时间。
  6. 过期时间到了,锁自动释放了。
  7. 客户端2获取到了对应同一个资源的锁。
  8. 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

实际上,在上述第三个问题和第四个问题的分析中,如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生

2.5 尴尬的超时时间设置问题

超时设置成多少合适呢?如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。看来真是个两难的问题,个人不建议使用redis锁处理太复杂的业务逻辑。

2.6 如果Sentinel集群的主节点挂了怎么办?

在 Sentinel 集群中,Master节点挂掉时,Slave节点会取而代之,但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

针对这个问题,antirez设计了Redlock算法,用来解决Redis分布式锁存在的一致性问题。不过引入Redlock也会存在需要创建多实例的成本问题,如果业务并不是很需要高可用,可以忽略failover引起的问题。

下一篇我会介绍一下Redlock算法以及优秀的开源解决方案Redission,我知道兄弟们可能对setnx+Lua脚本的代码忍不了了,Redission会帮我们解决这个问题的,敬请期待吧~

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改