Redis实现分布式锁的核心原理以及利弊

INFO

作者: 编程界的小学生

日期: 2021/09/06

修订: 初版,未修订。2021/09/06

版权: 内部资料,切勿泄漏,违者必究。

一、关键点

  • 原子性
  • 过期时间
  • 释放锁
  • 锁续期

1、原子性

比如如下就不具有原子性,就是错误的代码:

boolean lockStatus = stringRedisTemplate.opsForValue().setIfAbsent(orderId, userId);
stringRedisTemplate.expire(lockId, 30L, TimeUnit.SECONDS);

那怎么改正确呢?用Redis的setnx命令,一个命令代替两个:

boolean lockStatus = stringRedisTemplate.opsForValue().setIfAbsent(orderId, userId, 30L, TimeUnit.SECONDS);

或者有lua脚本将两个命令放到一个lua里,然后redis执行lua,相当于只执行了一个命令。这样就保证原子性了。

2、过期时间

锁要带过期时间,否则会有死锁的风险。

比如:如果上锁成功了,还没释放呢,服务宕机了,这把锁将永驻,服务起来后再去抢占锁的时候发现已经有锁了,无法抢占,但是这把锁又永远得不到释放,死锁了。

所以记得要用setnx设置过期时间,或者set+expire放到lua里进行设置。

3、锁续期

为什么需要续期?假设锁设置了3s,但是业务代码执行了4s还没执行完,那锁过期了,其他线程在请求接口的时候又加上了锁(redis里又setnx值了),这时候不就并发执行了吗?相当于还是线程不安全!所以需要锁续期。

可以起个线程续期,上锁的时候就起个线程进行死循环续期,检查时间过了二分之一了就给他重新续期为上锁时间。比如设置的锁是4s,检查超过2s了还没执行完,那就重新给这个锁续期为4s。

也就是说锁永远都在释放锁的时候才进行过期?那为啥还要设置过期时间?这个上面说了,防止死锁。

4、释放锁

要正确释放锁,啥叫正确?看个例子:

问题:释放锁可能释放了别人的锁,比如锁设置了3s,但是业务代码执行了4s还没执行完,那锁过期了,其他线程在请求接口的时候又加上了锁(redis里又setnx值了),然后第一个执行了4s的线程运行完了,释放了第二个线程加的锁,这时候其他线程又能抢锁了,这不安全!

方案:

  • 锁续期,可以有效防止没执行完的话,其他线程无法加锁。
  • key肯定是相同的,因为同一个数据嘛,key不相同的话那就不需要分布式锁了,操作的都是不同的东西, 所以需要从value入手,解锁前先判断下这个key的value是不是自己加的,value不能是线程id,因为分布式环境线程id会重复,所以可以换成类似userid等业务主键,也可以换成随机数,因为加锁解锁都在一个方法里,解锁的时候是可以得到这个随机数的。

下面对这两种方案做个伪代码演示:

续期

private volatile boolean isRunning;
// 抢锁成功
if (RESULT_OK.equals(client.setNxPx(key, value, ttl))) {
    // 续期
    renewalTask = new RenewTask(new IRenewalHandler() {
        @Override
        public void callBack() throws LockException {
            // 刷新值
            client.expire(key, ttl <= 0 ? 10 : ttl);
        }
    }, ttl);
    // 设置为后台线程
    renewalTask.setDaemon(true);
    renewalTask.start();
}

// 续期线程的逻辑
@Override
public void run() {
    while (isRunning) {
        try {
            // 1、续租,刷新值
            call.callBack();
            LOGGER.info("续租成功!");
            // 2、三分之一过期时间续租
            TimeUnit.SECONDS.sleep(this.ttl * 1000 / 3);
        } catch (InterruptedException e) {
            close();
        } catch (LockException e) {
            close();
        }
    }
}

public void close() {
    isRunning = false;
}

正确解锁

// 判断订单id的锁是自己上的方可释放。
if((userId).equals(stringRedisTemplate.opsForValue().get(orderId))) {
    stringRedisTemplate.delete(orderId);
}

这样行吗?肯定不行的,因为判断里的redis获取操作和del操作是非原子的,如果你设置了超时时间,那么你也的业务在超时时间内没有执行完,那么这个锁就会被释放,其他线程拿到锁—以上恰好发生在get之后,del之前,会删除其他的锁,那么是不是就脏读了呢?但是如果有续期的话就不存在此问题。但还是尽量用lua脚本,lua脚本如下:

// 如果get的值等于传进来的值,就给他del
if redis.call("get",KEYS[1])==ARGV[1] then
  return redis.call("del",KEYS[1])
else
  return 0
end 

二、Redis实现分布式锁的几种方式

  • 单机
  • 哨兵
  • 集群
  • 红锁

1、单机

直接单机上锁,这台机器挂了就GG了,整个业务系统都获取不到锁了,单点故障!

2、哨兵

既然单点故障,那我搞个哨兵,Sentinel,自动主从切换。这下稳了吧?但是会有如下新问题:

锁写到Master后,还没同步到Slave呢,Master挂了。Slave选举成了Master,但是Slave里没有锁,其他线程再次能上锁了。不安全。

3、集群

集群只是做了slot分片,锁还是只写到一个Master上,所以和Sentinel哨兵模式有同样的问题。

4、红锁

也称RedLock,非常著名!是Redis实现分布式锁相对最安全可靠的一种手段。

他的核心思路是:搞几个独立的Master,比如5个。然后挨着个的加锁,只要超过一半以上(这里是5 / 2 + 1 = 3个)那就代表加锁成功,然后释放锁的时候也逐台释放。这样的好处在于一台Master挂了的话,还有其他的,所以不耽误,看起来好像完美解决了上面的问题。但是并不是100%安全,后面会说。

具体细节为:

  • 获取当前的时间(毫秒)
  • 使用相同的key和随机数在N个Master节点上获取锁,这里获取锁的尝试时间要远远小于锁的超时时间,就是为了防止某个Master挂了后我们还在不断的获取锁,导致被阻塞的时间过长。也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。
  • 只有在大多数节点(一般是【(2/n)+1】)上获取到了锁,而且总的获取时间小于锁的超时时间的情况下,认为锁获取成功了。
  • 如果锁获取成功了,锁的超时时间就是最初的锁超时时间减获取锁的总耗时时间。
  • 如果锁获取失败了,不管是因为获取成功的节点的数目没有过半,还是因为获取锁的耗时超过了锁的释放时间,都会将已经设置了key的master上的key删除。

需要注意两点:

  • Redis多个Master所在的机器时间必须同步。
  • Redis红锁机器挂了的话要延迟启动1min(大于锁超时时间就行),因为:如果三台Master,写入2台成功了,加锁成功,但是挂了一个,还保留了一个Master可用,释放锁的时候自然挂了的那个不会执行del,当他瞬间再次启动的时候会发现锁还在(因为还没到过期时间),可能造成未知的问题。所以让Redis延迟启动。

主要存在的问题:

  • 实现原理异常复杂,相信大家也看到了。
  • 依然是不安全的加锁方式。比如:给5个Master都加了锁,失效时间是3s,但是因为加锁的时候可能因为网络抖动或者其他情况导致只给3台机器加完锁就到3s了,失效了。后面2台还没加锁呢,前面3个已经失效了。但是这时候其他线程又进行上锁发现前面3个无锁正常上锁,因为是过半原则,3个认为加锁成功。这就导致了两个线程同时加锁成功,前3个是后面线程的锁,后两个是最开始线程的锁,这不乱套了吗?线程也不安全了!或许你会说开watchDog续期,那好像是没问题了,但是我换个问题,我不是到期了,而是挂了一台,还没同步到Slave呢,Slave升级为Master了,其他线程发现这个Slave上没有锁,依然可以加锁成功3台,半数以上。还是并发了,不安全。那怎么办?不要Slave了嘛?RedLock太麻烦啦!

有国外大佬提了两个问题推翻RedLock的绝对安全性,当然Redis作者肯定不认同他的说法,但又无法证实。感兴趣的可以搜搜看看,很好玩的。

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

三、Redis实现分布式锁的利弊

1、优点

  • 大多数互联网企业都在用Redis
  • Redisson客户端类库将锁的所有用法都封装了(可重入锁、读写锁、公平锁等等),用户调用就完事了,所以他支持的比较友好。

2、缺点

  • 太麻烦
  • 不是100%安全

四、总结

所以我认为不管你用Redis的哪种方式来实现分布式锁,都不是100%安全的,那就不用Redis做分布式锁了吗?不然,我觉得取决于业务吧,如果你业务要求必须,100%不能出问题,那用zk/etcd来实现吧。但是据我了解,至少80%的互联网公司都不这么强烈要求,大对数还是Redis分布式锁,即使用zk来实现的也可能不是业务上100%要求不能出现问题。比如你项目就没用zk,只用了Redis,那完全没必要搭建一套zk来做分布式锁,Redis的红锁也能保证高可用,几乎不会出现问题的。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

【原】编程界的小学生

没有打赏我依然会坚持。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值