Redission作为分布式锁使用与原理

实现分布式锁的关键点

  1. 原子性

比如如下就不具有原子性

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

可以改为

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

  1. 过期时间

锁要带过期时间,否则会有死锁的风险。
比如:如果上锁成功了,还没释放呢,服务宕机了,这把锁将永驻,服务起来后再去抢占锁的时候发现已经有锁了,无法抢占,但是这把锁又永远得不到释放,死锁了。
所以记得要用setnx设置过期时间,或者set+expire放到lua里进行设置。

  1. 锁续期

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

  1. 释放锁

要正确释放锁,啥叫正确?看个例子:
问题:释放锁可能释放了别人的锁,比如锁设置了3s,但是业务代码执行了4s还没执行完,那锁过期了,其他线程在请求接口的时候又加上了锁(redis里又setnx值了),然后第一个执行了4s的线程运行完了,释放了第二个线程加的锁,这时候其他线程又能抢锁了,这不安全!
方案:
锁续期,可以有效防止没执行完的话,其他线程无法加锁。
key肯定是相同的,因为同一个数据嘛,key不相同的话那就不需要分布式锁了,操作的都是不同的东西, 所以需要从value入手,解锁前先判断下这个key的value是不是自己加的,value不能是线程id,因为分布式环境线程id会重复,所以可以换成类似userid等业务主键,也可以换成随机数,因为加锁解锁都在一个方法里,解锁的时候是可以得到这个随机数的。

redisTemplate简单实现的分布式锁

使用redisTemplate实现的分布式锁是这样的

  boolean lock = false;
        try {
    
    
            lock = redisTemplate.opsForValue().setIfAbsent(lockUserBehaviorKey, "", 60, TimeUnit.SECONDS);
            if (!lock) {
    
    
                log.warn("获取redis锁失败:{}", new Date());
                return;
            }

        } catch (Exception e) {
    
    
            log.error("发生异常:{}", e);
        } finally {
    
    
            if (lock) {
    
    
                redisTemplate.delete(lockUserBehaviorKey);
            }
        }

那么这么简单的实现redis存在什么问题?
很容易看出来不满足锁续期的要求,也不满足可重入,也可能会被其他线程解锁,是存在bug的
当然我们也可以自行实现可重入锁,阻塞锁等,例如redis分布式锁,然而redission都已经为我们提供了这些功能的锁

redission分布式锁


Redisson 锁的加锁机制如上图所示,线程去获取锁,获取成功则执行lua脚本,保存数据到redis数据库如果获取失败: 一直通过while循环尝试获取锁(可自定义等待时间,超时后返回失败),获取成功后,执行lua脚本,保存数据到redis数据库Redisson提供的分布式锁是支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么redisson会自动给redis中的目标key延长超时时间,这在Redisson中称之为 Watch Dog 机制。同时 redisson 还有公平锁、读写锁的实现。

public void test() throws Exception{
    
    
        RLock lock = redissonClient.getLock("guodong");    // 拿锁失败时会不停的重试
        // 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s
        lock.lock();
        // 尝试拿锁10s后停止重试,返回false 具有Watch Dog 自动延期机制 默认续30s
        boolean res1 = lock.tryLock(10, TimeUnit.SECONDS); 
        // 没有Watch Dog ,10s后自动释放
        lock.lock(10, TimeUnit.SECONDS);
        // 尝试拿锁100s后停止重试,返回false 没有Watch Dog ,10s后自动释放
        boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);
        Thread.sleep(40000L);
        lock.unlock();
    }

2.Wath Dog的自动延期机制
如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,加入一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,Redisson给出了自己的答案,就是 watch dog 自动延期机制
Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。
默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期。
3.可重入加锁机制
Redisson可以实现可重入加锁机制的原因,我觉得跟两点有关:

1、Redis存储锁的数据类型是 Hash类型
2、Hash数据类型的key值包含了当前线程信息。


这里表面数据类型是Hash类型,Hash类型相当于我们java的 <key,<key1,value>> 类型,这里key是指 ‘redisson’
它的有效期还有9秒,我们再来看里们的key1值为 078e44a3-5f95-4e24-b6aa-80684655a15a:45 它的组成是:
guid + 当前线程的ID。后面的value是就和可重入加锁有关。

上面这图的意思就是可重入锁的机制,它最大的优点就是相同线程不需要在等待锁,而是可以直接进行相应操作。

4 Redis分布式锁的缺点
Redis分布式锁会有个缺陷,就是在Redis哨兵模式下:
客户端1 对某个 master节点 写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生master节点宕机,主备切换,slave节点从变为了 master节点由于redis主从,是异步同步,可能会丢失数据
这时 客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。
这时系统在业务语义上一定会出现问题, 导致各种脏数据的产生 。
Redission这套方式也有问题,比如说 redis存在主从同步的带来的脏数据的问题,然后给我说道更新一代的架构是Redlock
Redlock:全名叫做 Redis Distributed Lock;即使用redis实现的分布式锁;
也称RedLock,非常著名!是Redis实现分布式锁相对最安全可靠的一种手段。
他的核心思路是:搞几个独立的Master,比如5个。然后挨着个的加锁,只要超过一半以上 这里是5/2+1 = 3)那就代表加锁成功,然后释放锁的时候也逐台释放。这样的好处在于一台Master挂了的话,还有其他的,所以不耽误,看起来好像完美解决了上面的问题。但是并不是100%安全,后面会说
具体细节为

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

需要注意两点:

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

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

源码分析

下这个方法

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    
    
    long threadId = Thread.currentThread().getId();
    // 获取锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    if (ttl == null) {
    
     // 获取成功
        return;
    }

    // 异步订阅redis chennel
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future); // 阻塞获取订阅结果

    try {
    
    
        while (true) {
    
    // 循环判断知道获取锁
            ttl = tryAcquire(leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
    
    
                break;
            }

            // waiting for message
            if (ttl >= 0) {
    
    
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
    
    
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
    
    
        unsubscribe(future, threadId);// 取消订阅
    }
}

总结lockInterruptibly:获取锁,不成功则订阅释放锁的消息,获得消息前阻塞。得到释放通知后再去循环获取锁。
下面重点看看如何获取锁:Long ttl = tryAcquire(leaseTime, unit, threadId)

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    
    
    return get(tryAcquireAsync(leaseTime, unit, threadId));// 通过异步获取锁,但get(future)实现同步
}
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    
    
                                          if (leaseTime != -1) {
    
     //1 如果设置了超时时间,直接调用 tryLockInnerAsync
                                              return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
                                          }
//2 如果leaseTime==-1,则默认超时时间为30s
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
//3 监听Future,获取Future返回值ttlRemaining(剩余超时时间),获取锁成功,但是ttlRemaining,则刷新过期时间
ttlRemainingFuture.addListener(new FutureListener<Long>() {
    
    
    @Override
    public void operationComplete(Future<Long> future) throws Exception {
    
    
        if (!future.isSuccess()) {
    
    
            return;
        }

        Long ttlRemaining = future.getNow();
        // lock acquired
        if (ttlRemaining == null) {
    
    
            scheduleExpirationRenewal(threadId);
        }
    }
});
return ttlRemainingFuture;
}

下面就是最重要的redis获取锁的方法tryLockInnerAsync:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    
    
    internalLockLeaseTime = unit.toMillis(leaseTime);
    return commandExecutor.evalWriteAsync(
        getName(),
        LongCodec.INSTANCE,
        command,
        "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]);",
        Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

这个方法主要就是调用redis执行eval lua,为什么使用eval,因为redis对lua脚本执行具有原子性。把这个方法翻译一下:

-- 1. 没被锁{
    
    key不存在}
eval "return redis.call('exists', KEYS[1])" 1 myLock
-- (1) 设置Lock为key,uuid:threadId为filed, filed值为1
eval "return redis.call('hset', KEYS[1], ARGV[2], 1)" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (2) 设置key过期时间{
    
    防止获取锁后线程挂掉导致死锁}
eval "return redis.call('pexpire', KEYS[1], ARGV[1])" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110

-- 2. 已经被同线程获得锁{
    
    key存在并且field存在}
eval "return redis.call('hexists', KEYS[1], ARGV[2])" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (1) 可重入,但filed字段+1
eval "return redis.call('hincrby', KEYS[1], ARGV[2],1)" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (2) 刷新过去时间
eval "return redis.call('pexpire', KEYS[1], ARGV[1])" 1 myLock 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110

-- 3. 已经被其他线程锁住{
    
    key存在,但是field不存在}:以毫秒为单位返回 key 的剩余超时时间
eval "return redis.call('pttl', KEYS[1])" 1 myLock

这就是核心获取锁的方式,下面直接释放锁方法unlockInnerAsync:

-- 1. key不存在
eval "return redis.call('exists', KEYS[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- (1) 发送释放锁的消息,返回1,释放成功
eval "return redis.call('publish', KEYS[2], ARGV[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
 
-- 2. key存在,但field不存在,说明自己不是锁持有者,无权释放,直接return nil
eval "return redis.call('hexists', KEYS[1], ARGV[3])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
eval "return nil"
 
-- 3. filed存在,说明是本线程在锁,但有可能其他地方重入锁,不能直接释放,应该-1
eval "return redis.call('hincrby', KEYS[1], ARGV[3],-1)" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
 
-- 4. 如果减1后大于0,说明还有其他重入锁,刷新过期时间,返回0。
eval "return redis.call('pexpire', KEYS[1], ARGV[2])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
 
-- 5. 如果不大于0,说明最后一把锁,需要释放
-- 删除key
eval "return redis.call('del', KEYS[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 发释放消息
eval "return redis.call('publish', KEYS[2], ARGV[1])" 2 myLock redisson_lock__channel_lock 0 3000 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:110
-- 返回1,释放成功

从释放锁代码中看到,删除key后会发送消息,所以上文提到获取锁失败后,阻塞订阅此消息。

锁的几种用法

在加锁的时候,只能指定加锁时长,不能指定等待时间,这是redissonLock和redLock的一个区别点,需要注意
如果没有指定leaseTime(加锁时长),默认会加30S,每10S进行一次锁续期即默认开启watchDog
如果指定了leaseTime,那不会自动续期,到了leaseTime之后,如果依旧没有执行完毕,会释放锁,此时,其他线程就可以继续对key加锁

1、tryLock

使用无参的tryLock()方法时,redisson会自动添加一个定时任务,定时刷新锁的失效时间,如果unlock时失败,则会出现该锁一直不释放的情况, 因为定时刷新的任务一直存在。
使用两个参数的tryLock(long waitTime, TimeUnit unit)方法时,比无参的多了个功能就是在waitTime内,重试获取锁,直到超时,返回失败
tryLock(long waitTime, long leaseTime, TimeUnit unit)传释放时间leaseTime时,在waitTime内,重试获取锁,直到超时,返回失败。但不会添加定时刷新锁的失效时间的任务。

2、lock方法

加锁失败会阻塞
一般有几个重要参数,leaseTime,awaitTime

无参方法会开启watchDog机制

     RLock sss = redissonClient.getLock("sss");
     sss.lock();

指定leaseTime

 sss.lock(15,TimeUnit.SECONDS);

如果指定了leaseTime,那不会自动续期,到了leaseTime之后,如果依旧没有执行完毕,会释放锁,此时,其他线程就可以继续对key加锁

阻塞超时时间

lock方式是没有指定超时时间的,那么如果监测到加锁失败会while不停从尝试获取锁。等待

      sss.tryLock(5,5,TimeUnit.SECONDS);

tryLock支持指定等待时间,到了等待时间返回false、不会一支阻塞

猜你喜欢

转载自blog.csdn.net/qq_37436172/article/details/130657031