Redis分布式锁

Redis分布式锁

Redis分布式锁演进-阶段一

直接使用setnx 加锁,执行完业务逻辑后调用del 释放锁

在这里插入图片描述

缺点:
如果setnx成功, 还没来得及释放锁, Client就宕机挂了, 这就会导致死锁 ;

解决:
设置锁的自动过期 ,即使 Client 宕机或其他原因没有删除锁,锁到期也会自动释放;

Redis分布式锁演进-阶段二

为了解决第一个阶段的缺陷,我们用setnx获取锁,然后用expire对其设置一个过期时间,即使 Client 宕机或其他原因没有删除锁,过期时间一到锁也会自动释放

在这里插入图片描述

缺点:
setnx和expire设置过期时间是两个方法,不能保证原子性,如果在setnx之后,还没来得及expire, Client 就宕机了,还是会出现死锁的问题

Redis分布式锁演进-阶段三

redis官方在2.6.12:增加了EX,PX 选项,保证了setnx+expire的原子性;

使用方法:set key value ex 5 nx

在这里插入图片描述

缺点:

如果 在锁有效期内,业务逻辑还未执行完,过期时间一到锁自动释放,其他Client 可能重新获取了这个同样key的锁, 执行完业务直接进行删除,有可能把其他Client 持有的锁给删了。

解决办法:

在获取锁时候,要设一个随机值 (类似token令牌),在删除锁时进行比对,如果是自己加的锁,才能删除;


Redis分布式锁演进-阶段四

在获取锁时候,将value设一个随机值,在删除锁时先get key进行比对,如果value与获取锁时设置的值相等,则证明是自己加的锁,才能进行删除;

在这里插入图片描述

问题:

判断value值是否相等 和 删除锁 是两个步骤,不能保证原子性,如果判断校验完,正要删除时,锁过期失效了,其他Client 可能正好重新获取了该锁, 那么我们删除的就可能是别人的锁;

解决:
通过lua脚本进行判断+删除操作保证原子性



最终版本

分布式锁的本质是对共享资源串行化处理,分布式锁应满足以下特点:

互斥性:任一时刻,同一把锁只能有一个 client 拿到

安全性:对于同一把锁,加锁和解锁的必须是同一个Client

避免死锁:不能让一个资源永久加锁 ,需要一种机制确保 加锁的Client出现 异常不能释放锁的情况下, 锁能够自动释放

  1. 比如一个业务执行时间很长,客户端A获取的锁(键key)已经过期自动释放了,此时客户端B重新获取了这个同样key的锁,但是当业务执行完之后客户端A直接释放锁,就会删除了客户端B加的锁。

    所以在获取锁时候,要设一个随机值,在删除锁时进行比对,如果是自己加的锁,才能删除;

  2. 避免获取锁的客户端 因为某些原因宕机未能释放锁,导致资源处于永久加锁的状态 , 在加锁的时候给锁设置一个过期时间,时间到了锁自动释放。

实现

  1. 加锁和设置锁过期时间这两步操作,可以使用Redis提供的命令保证原子性 ,避免client获取到锁后 宕机导致 无法进行下一步设置过期时间,产生死锁

    SET key value [EX seconds|PX milliseconds] [NX|XX]

  2. 设置获取锁的超时时间,在超时时间内重试获取,超过这个时间则放弃获取锁

  3. 通过lua脚本进行判断+删除操作保证原子性

    Redis 使用单个 Lua 解释器去运行所有脚本,当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。

   //基于jedis和lua脚本来实现
    private static  final String LOCK_SUCCESS = "OK";
    private static  final Long RELEASE_SUCCESS = 1L;
    private static  final String SET_IF_NOT_EXIST = "NX";
    private static  final String SET_WITH_EXPIRE_TIME = "EX";
	private long acquireTimeout = 2000;
    Logger log = Logger.getLogger("RedisLock");


    /**
     * 获取锁操作
     * @return UUID随机值
     */
    public String acquire() {
        try {
            // 获取锁的超时时间,超过这个时间则放弃获取锁
            long end = System.currentTimeMillis() + acquireTimeout;
            // 随机生成一个 value值,用作释放锁时的身份校验
            String identify =  UUID.randomUUID().toString();
            while (System.currentTimeMillis() < end) {
                String result = jedis
                        .set(lockKey, identify, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
                if (LOCK_SUCCESS.equals(result)) {
                    return identify;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (Exception e) {
            log.error("acquire lock due to error", e);
        }

        return  null;
    }

    /**
     * 释放锁操作
     * @param identify 获取锁时返回的随机值
     * @return
     */
    public boolean release(String identify) {
        if (identify == null) {
            return  false;
        }
        //通过lua脚本进行比对删除操作,保证原子性
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = new Object();
        try {
            result = jedis.eval(script, Collections.singletonList(lockKey),
                    Collections.singletonList(identify));
            if (RELEASE_SUCCESS.equals(result)) {
                log.info("release lock success, requestToken:{}", identify);
                return  true;
            }
        } catch (Exception e) {
            log.error("release lock due to error", e);
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        log.info("release lock failed, requestToken:{}, result:{}", identify, result);
        return  false;
    }

问题

  • 主从架构-主节点故障问题:
    上面的实现只要一个master节点就能搞定,但是为了保证Redis高可用,一般会搭建主从架构,如果加锁成功后,锁从master复制到slave的时候,master节点挂了,slave节点升级为master,也是会出现同一资源被多个client加锁的。
  • 执行时间超过了锁的过期时间:
    为了不出现资源一直加锁的情况,设置了一个兜底的过期时间,时间到了锁自动释放,但是,如果在这期间任务并没有做完怎么办?由于GC或者网络延迟等导致的任务时间变长,很难保证任务一定能在锁的过期时间内完成。锁过期失效 ,导致同一时刻多个Client可以获取到锁, 一个任务被多个客户端执行;
主从架构-主节点故障问题

发生步骤:

  1. 客户端A在master节点实例中对资源X加锁
  2. 在将锁数据从master 节点实例传输到slave节点实例前,master 节点崩溃
  3. slave节点实例升级为master
  4. 客户端B获取相同资源X的锁,而该资源X的锁已经被客户端A持有了。

因为新升级为master的 slave节点中不存在 资源X的锁数据,所以客户端B 在新升级的master实例中 对相同资源X 获取锁是可以成功的,此时就会出现同一资源被多个client加锁的情况;

思考

出现上述主从架构- 主节点故障问题,原因是锁数据信息只保存在单个master节点实例中,并且Redis 主从复制是异步的;

RedLock 算法分析

为了解决上述 发生主从切换时,锁数据丢失的问题,Redis 作者提出了一种基于 Redis 实现分布式锁的方式-Redlock;

redLock算法虽然是需要多个master节点实例,但是这些master节点都是独立部署的单个实例,没有主从关系

RedLock作者指出,之所以要用单独的master节点实例,是为了避免redis从节点 异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据 复制给从节点就宕机了,导致锁数据的丢失;

​ RedLock算法认为,只要(N/2) + 1个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁。流程为:

  1. 客户端以毫秒为单位获取当前时间
  2. 客户端依次向N个节点请求加锁,根据一定的超时时间来推断是不是跳过该节点
  3. 客户端用当前时间中减去在步骤1中获取的时间戳,来计算获取锁所花费的时间 ,如果获取锁所花费的总时间小于锁有效时间(TTL),则认为已获取锁。
  4. 如果认定加锁成功,锁真正的有限时间则为 初始有效时间减去经过的时间(步骤3中获取锁所花费的总时间)
  5. 如果 client 申请锁失败了,那么它会尝试在所有节点实例上执行释放锁的操作,重置状态

性能、崩溃恢复和 fsync

如果我们的节点没有持久化机制,client 从 5 个 master 中的 3 个处获得了锁,然后其中一个重启了,这是注意 整个环境中又出现了 3 个 master 可供另一个 client 申请同一把锁! 违反了互斥性。

解决办法: 开启AOF持久化,因为 Redis 的过期机制是语义层面实现的,所以在 server 挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。

但是考虑突然断电的情况,AOF部分命令没来得及刷回磁盘直接丢失了,除非我们配置刷回策略为 fsnyc = always,但这会损伤性能。

解决这个问题的方法:,当一个节点重启之后,我们规定在 max TTL 期间它是不可用的(宕机那一刻所有锁中最大的TTL),这样它就不会干扰原本已经申请到的锁,等到它 crash 前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。

小结

  1. 开启AOF ,配置刷回策略为 fsnyc = always,即使宕机 也不会丢失数据,但性能低;
  2. 节点延迟重启,当节点重启后,设置该节点 max TTL 期间它是不可用的,这样即使不开启AOF持久化,也不会对节点宕机前设置的锁造成干扰。

任务执行时间超过锁的TTL

在加锁的时候,我们一般都会给一个锁的TTL,这是为了防止加锁后client宕机,锁无法被释放的问题。但是这种用法都会面临一个问题,就是没法保证client的执行时间一定小于锁的TTL。

图片

Client1获取到锁;

Client1开始任务,然后发生了STW的GC,时间超过了锁的过期时间;

Client2 获取到锁,开始了任务;

Client1的GC结束,继续任务,这个时候Client1和Client2都认为自己获取了锁,都会处理任务,从而发生错误。

因为GC或网络延迟等原因导致任务的执行时间远超预期,锁过期失效 ,导致多个线程获取到锁, 一个任务被多个线程执行。

这个问题是所有分布式锁都要面临的问题,包括基于zookeeper和DB实现的分布式锁,这是锁过期失效了和client不知道锁失效了之间的矛盾

思考

在获取锁成功后,给锁加一个watchdog,watchdog会起一个定时任务,在锁没有被释放且快要过期的时候会续期。(Redisson的解决方案)

不过这种做法也无法百分百保证同一时刻只有一个client获取到锁,如果续期失败,比如获取到锁的 client 和 redis 集群失联了,导致未能在锁过期前续期; 只要续期失败,就会造成同一时刻有多个client获得锁了。

总结:

在极端情况下,分布式锁不一定是安全的。

  1. 用 Redis 控制共享资源并且还要求数据安全要求较高的话,最终的保底方案是对业务数据做幂等控制,这样一来,即使出现多个客户端获得锁的情况也不会影响数据的一致性。
  2. 对数据一致性要求高(CP)的场景,使用zookeeper;

相比zookeeper, Redis 没有相应的数据一致性协议,无法保证主从节点间数据的一致性;

Redis 注重的是网络分区情况下的 可用性(AP),zookeeper注重的是网络分区情况下的 一致性(CP);


参考:

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

https://redis.io/topics/distlock
http://antirez.com/news/101

https://mp.weixin.qq.com/s/GppIPkxAegUfVN5aXci4-Q
https://mp.weixin.qq.com/s/5nR69t4gilJvehDVtQR4GA

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值