Redis分布式锁

Redis分布式锁(单Redis实例)

注: 本章适用于单Redis实例的分布式锁。而且分为两个版本,分两个版本的原因是因为Redis2.6.12版本之后SET命令才支持 NX、EX等参数

Redis分布式锁_第1张图片

以下代码为伪代码

版本一 (适用于Redis2.6.12版本之前)

//Redis锁名称
private static final String REDIS_LOCK_KEY_PREFIX = "lock";
//锁有效期
private static final String long TIMEOUT = 5000;

public void DistributedLock() {
    
    //尝试获取锁
    Long lockResult = reids.setnx(REDIS_LOCK_KEY_PREFIX, System.currentTimeMillis() + TIMEOUT);
    
    //获取到分布式锁
    if (null != lockResult && 1 == lockResult) {
        
        //设置锁有效期,获取到锁之后设置锁有效期,避免死锁
        redis.expire(REDIS_LOCK_KEY_PREFIX, TIMEOUT);
        
        //to do someting
        
    } else {//没有获取到锁,但是如果原先的锁过期或者原先的锁已经失效,我们也要获取到
        
        //获取锁有效期时间戳
        String lockValue = redis.get(REDIS_LOCK_KEY_PREFIX);
        
        //当前锁已失效
        if (StringUtils.isNotBlank(lockValue) && System.currentTimeMillis > lockValue) {
            
            //使用getset尝试再次获取锁(使用getset而不使用del后再SETNX可以避免客户端多个客户端同时获取到锁,下面会详细说明)
            String getsetResult = redis.getset(REDIS_LOCK_KEY_PREFIX, String.valueOf(System.currentTimeMillis + TIMIEOUT));
            
            //getset获取不到值说明原先的锁已经释放可以重新获取到锁
            //getset获取到的值与锁有效期时间戳相等则说明锁已经失效但是客户端还在占有锁。什么情况会失效呢?
            //因为获取到锁之后才会为锁设置有效期,这个过程不是原子操作。当客户端获取到锁之后突然宕机,此时			//就没有为锁设置上过期时间,就会导致一直占用该锁。所以此处要判断是否发生了死锁
            if (StringUtils.isBlank(getsetResult) || (StringUtils.isNotBlank(getsetResult) && getsetResult.equal(lockValue))) {
                
                 //设置锁有效期,获取到锁之后设置锁有效期,避免死锁
        		redis.expire(REDIS_LOCK_KEY_PREFIX, TIMEOUT);
        
        		//to do someting
            } else {
                log.error("获取分布式锁失败");
            }
             
        } else {
            log.error("获取分布式锁失败");
        }     
        
    }   
}

//释放分布式锁 (该版本有问题,可能会打破互斥性,下面会有plus版本)
public void releaseLock() {
    if (redis.get(REDIS_LOCK_KEY_PREFIX) > System.currentTimeMillis()) {
        redis.del(REDIS_LOCK_KEY_PREFIX);
    }
}

//释放分布式锁Plus版本,将releaseLock方法中的两个命令使用Lua脚本保证原子性
public void releaseLockPlus() {
    
    String luaString = "if redis.call("get", KEYS[1]) > ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end";
    
    redis.eval(luaString);    
}

Q&A
    
Q1:为什么不使用 SET key value [expiration EX seconds|PX milliseconds] [NX|XX]  这个指令来实现key的自动过期呢,反而放到应用代码判断key是否过期?
A1:我们的分布式锁开发的时候SET命令还不支持NX、PX,所以才想出这种办法来实现key过期,NX、PX在2.6.12以后开始支持;
    
Q2:已经判断了当前key对应的时间戳已经过期了,为什么还要使用getset再获取一次呢,直接使用set指令覆盖不可以吗?
A2:这里其实牵扯到并发的一些事情,如果直接使用set,那有可能多个客户端会同时获取到锁,如果使用getset然后判断旧值是否过期就不会有这个问题,设想一下如下场景:
1.C1加锁成功,不巧的是,这时C1意外的奔溃了,自然就不会释放锁;
2.C2,C3尝试加锁,这时key已存在,所以C2,C3去判断key是否已过期,这里假设key已经过期了,所以C2,C3使用set指令去设置值,那两个都会加锁成功,这就闯大祸了;如果使用getset指令,然后判断下返回值是否过期或者判断下返回值是否与死锁的时间戳相等就可以避免这种问题,假如C2跑的快,那C3判断返回的时间戳并没有过期或者与死锁的时间戳不相等,自然就加锁失败;
    
Q3:为什么释放锁时还需要判断key是否过期呢,直接del不是性能更高吗?
A3:考虑这样一种场景:
**1.**C1获取锁成功,开始执行自己的操作,不幸的是C1这时被阻塞了;
**2.**C2这时来获取锁,由于C1被阻塞了很长时间,所以key对应的value已经过期了,这时C2通过getset加锁成功;
**3.**C1尘封了太久终于被再次唤醒,对于释放锁这件事它可是认真的,伴随着一波del操作,悲剧即将发生;
**4.**C3来获取锁,好家伙,居然一下就成功了,接着就是一波操作猛如虎,接着就是一堆的客诉过来了;
为什么会这样呢?回想C1被唤醒以后的事情,居然敢直接del,C2活都没干完呢,锁就被C1给释放了,这时C3来直接就加锁成功,所以为了安全起见C3释放锁时得分成两步:1.判断value是否已经过期 2.如果已过期直接忽略,如果没过期就执行del。这样就真的安全了吗?安全了吗?安全了吗?假如第一步和第二步之间相隔了很久是不是也会出现锁被其他人释放的问题呢?是吧?是的!有没有别的解决办法呢?听说借助lua就可以解决这个问题了。

版本二 (适用于Redis2.6.12版本之后)

​ Redis2.6.12版本起,SET命令支持多参数并且是原子操作,所以之前的 SETNX和SETEX可以使用一个SET命令的多个参数实现原子操作,可以有效避免之前死锁问题

//Redis锁名称
private static final String REDIS_LOCK_KEY_PREFIX = "lock";

//锁有效期
private static final long TIMEOUT = 5000;

private static final int EXPIRE_TIME = 5;

//获取分布式锁
public void getDistributedLock() {
    
    //使用UUID为锁模拟一个唯一标识作为value,目的是保证可以安全的释放锁
    String token = UUID.random();
    
    boolean lock = redis.set(REDIS_LOCK_KEY_PREFIX, token, "NX", "EX", EXPIRE_TIME);
    
    //获取到锁
    if (lock) {
        //to do something     
        return;
    } else {
        log.error("获取分布式锁失败");
    }  
}

//释放锁
public void releaseLock() {
     String luaString = "if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end";
    
    redis.eval(luaString);    
}


分布式锁注意事项

一个可靠的分布式锁应该具备以下特性:

**1.互斥性:**作为锁,需要保证任何时刻只能有一个客户端(用户)持有锁

2.可重入: 同一个客户端在获得锁后,可以再次进行加锁

**3.高可用:**获取锁和释放锁的效率较高,不会出现单点故障

**4.自动重试机制:**当客户端加锁失败时,能够提供一种机制让客户端自动重试

**Redis分布式锁需要注意的问题 : **

  1. 超时问题

    Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。为了避免这个问题, Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决
        
        解决办法:
        	1. 获取锁时执行SET操作时,在value中设置一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,所以需要使用Lua脚本解决
    
  2. 可重入性 (不推荐使用)

    	不推荐使用可重入锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不使用可重入锁
    	可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。 Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数
    
  3. Redis集群环境下分布式锁 (不建议在Redis集群环境下使用分布式锁)

    	上面案例实现了分布式锁,不过在集群环境下,这种方式是有缺陷的,它不是绝对安全的。
        比如在 Sentinel 集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。
        如果你很在乎高可用性,希望挂了一台 redis 完全不受影响,那就应该考虑 redlock。不过代价也是有的,需要更多的 redis 实例,性能也下降了,代码上还需要引入额外的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌
    
  4. Redis锁的过期时间小于业务的执行时间该如何续期?

    这个暂时没有实现,据说有一个叫Redisson的家伙解决了这个问题
    

你可能感兴趣的:(Redis,redis,分布式锁)