Redisson分布式锁实现原理和使用

常见的锁

内存锁lock,synchronize
分布式锁redis,zookeeper实现
Redisson基于redis实现了Lock接口的分布式集群锁,是可重入锁,功能强大,源码复杂,比redis单机模式分布式锁可靠,稳定性更高,支持集群模式,支持锁根据业务时长自动延迟释放

redis普通分布式锁存在一定的缺陷——它加锁只作用在一个Redis节点上,如果通过sentinel和cluster保证高可用,如果master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

高可用问题

  • 客户端1在Redis的master节点上拿到了锁
  • Master宕机了,存储锁的key还没有来得及同步到Slave上
  • master故障,发生故障转移,slave节点升级为master节点
  • 客户端2从新的Master获取到了对应同一个资源的锁
      于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破了。针对这个问题。Redis作者antirez提出了RedLock算法来解决这个问题

业务线超时问题

  • 节点1:如果设置锁的过期时间为30MS,但是业务线可能因为网络或者数据量峰值出现导致执行时间超过了30MS,那么这时redis已经把锁给释放了,但是业务线却仍然在执行
  • 节点2这时去获取锁,发现锁可以获取成功,这就造成了 同时有两个节点在执行同一个业务逻辑,则无法保证业务的幂等性(数据加上版本号处理,但仍然会对累加结果造成重复性错误),会造成数据重复处理,或者日志主键ID重复,同一订单两次计算金额,客户两次扣款等问题

Redisson在这个问题上加上了自动续时,如果锁已经被持有,那么另一个线程试图再次获取锁时,会把已存在的锁重置过期时间,就相当于延长了当前锁的时间从而避免过期造成幂等性问题

源码RedLock算法的实现思路

全流程图
在这里插入图片描述

加锁机制

lock()方法直接调用 lockInterruptibly 方法,tryAcquire就是获取锁的具体实现

@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    // 获取当前线程的id
    long threadId = Thread.currentThread().getId();
    // 尝试获取锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // lock acquired ,ttl为空,说明成功获取锁,返回
    if (ttl == null) {
        return;
    }
 
    // 如果获取锁失败,则订阅到对应这个锁的channel
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future);
 
    try {
        // 没有获取倒锁就不断的自循环获取锁
        while (true) {
        	// 再次尝试获取锁
            ttl = tryAcquire(leaseTime, unit, threadId);
            // lock acquired , ttl为空,说明成功获取锁,返回
            if (ttl == null) {
                break;
            }
 
            // waiting for message , ttl大于0 则等待ttl时间后继续尝试获取
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        // 取消对channel的订阅
        unsubscribe(future, threadId);
    }
	// get(lockAsync(leaseTime, unit));

这里会订阅Channel,当资源可用时可以及时知道,并抢占,防止无效的轮询而浪费资源。
当资源可用用的时候,循环去尝试获取锁,由于多个线程同时去竞争资源,所以这里用了信号量,对于同一个资源只允许一个线程获得锁,其它的线程阻塞,从而避免CPU一致执行while
在这里插入图片描述

tryAcquireAsync 方法,有自定义过期时间则直接获取,没有则使用系统配置的默认过期时间获取

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    // 如果存在过期时间,按照正常费那事获取锁
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 读取配置的默认加锁时间30秒执行获取锁的  private long lockWatchdogTimeout = 30 * 1000; 
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    // 如果一直持有这个锁,开启监听通过定时任务不断刷新锁的过期时间
    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;

看 tryLockInnerAsync 方法,采用LUA脚本代码加锁,LUA脚本在redis中具有原子性操作。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
	// 设置过期时间
    internalLockLeaseTime = unit.toMillis(leaseTime);
 
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
    		  // 如果锁不存在,则通过hset设置它的值,并设置过期时间
              "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; " +
              // 如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
              "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; " +
              // //如果锁已存在,但并非本线程,则返回过期时间ttl
              "return redis.call('pttl', KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

这里KEYS[1]就是getName(),ARGV[2]是getLockName(threadId)

假设前面获取锁时传的name是“abc”,假设调用的线程ID是Thread-1,假设成员变量UUID类型的id是6f0829ed-bfd3-4e6f-bba3-6f3d66cd176c

那么KEYS[1]=abc,ARGV[2]=6f0829ed-bfd3-4e6f-bba3-6f3d66cd176c:Thread-1
这段脚本的意思是
  1、判断有没有一个叫“abc”的key
  2、如果没有,则在其下设置一个字段为“6f0829ed-bfd3-4e6f-bba3-6f3d66cd176c:Thread-1”,值为“1”的键值对 ,并设置它的过期时间
  3、如果存在,则进一步判断“6f0829ed-bfd3-4e6f-bba3-6f3d66cd176c:Thread-1”是否存在,若存在,则其值加1为2,并重新设置过期时间
  4、返回“abc”的生存时间(毫秒)
这里用的数据结构是hash,hash的结构是: key 字段1 值1,字段2 值2 ,字段3 值3 …

用在锁这个场景下,key就表示锁的名称,也可以理解为临界资源,字段就表示当前获得锁的线程,
所有竞争这把锁的线程都要判断在这个key下有没有自己线程的字段,如果没有则不能获得锁,如果有,则相当于重入,字段值加1(次数),重入锁也就基本实现了

在这里插入图片描述

锁互斥机制

那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。

接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。

所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。

此时客户端2会进入一个while循环,不停的尝试加锁。

释放锁机制

核心解锁代码,也用LUA脚本

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
    		// 如果锁已经不存在, 发布锁释放的消息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            // 如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            // 通过hincrby递减1的方式,释放一次锁
            // 若剩余次数大于0 ,则刷新过期时间
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            // 否则证明锁已经释放,删除key并发布锁释放的消息
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
 
}

假设name=abc,假设线程ID是Thread-1

同理,我们可以知道
KEYS[1]是getName(),即KEYS[1]=abc
KEYS[2]是getChannelName(),即KEYS[2]=redisson_lock__channel:{abc}
ARGV[1]是LockPubSub.unlockMessage,即ARGV[1]=0
ARGV[2]是生存时间
ARGV[3]是getLockName(threadId),即ARGV[3]=6f0829ed-bfd3-4e6f-bba3-6f3d66cd176c:Thread-1
因此,上面脚本的意思是:
  1、判断是否存在一个叫“abc”的key
  2、如果不存在,向Channel中广播一条消息,广播的内容是0,并返回1
  3、如果存在,进一步判断字段6f0829ed-bfd3-4e6f-bba3-6f3d66cd176c:Thread-1是否存在
  4、若字段不存在,返回空,若字段存在,则字段值减1
  5、若减完以后,字段值仍大于0,则返回0
  6、减完后,若字段值小于或等于0,则广播一条消息,广播内容是0,并返回1;

可以猜测,广播0表示资源可用,即通知那些等待获取锁的线程现在可以获得锁了

一句话说:每次释放都对myLock数据结构中的那个加锁次数减1
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。然后呢,另外的客户端2就可以尝试完成加锁了

在这里插入图片描述

watch dog自动延期机制

客户端1加锁的锁key默认生存时间才30秒,如果业务执行时间超过了30秒,客户端1还是需要一直持有这把锁,怎么办呢?

简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间

Redisson分布式锁的缺点

其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。

但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。

此时就会导致多个客户端对一个分布式锁完成了加锁。

这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。

所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁,如果要追求强一致性,那么只能考虑zookeeper分布式锁,当然它们各有自己的优缺点。

spingboot中配置和使用

redisson配置不会影响jedis的使用,data-redis配置方式不变,因此可以同时两个一起使用

<!--配置方式和原来一样不会影响使用--->
<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>


<dependency>
   	<groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.13.6</version>
</dependency>

redisson-dev.yml集群模式,这里使用redis的cluster

clusterServersConfig:
  idleConnectionTimeout: 10000
  connectTimeout: 10000
  timeout: 3000
  retryAttempts: 3
  retryInterval: 1500
  subscriptionsPerConnection: 5
  clientName: null
  loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
  slaveConnectionMinimumIdleSize: 32
  slaveConnectionPoolSize: 64
  masterConnectionMinimumIdleSize: 32
  masterConnectionPoolSize: 64
  readMode: "SLAVE"
  password: u111111111
  nodeAddresses:
    - "redis://192.168.32.176:6371"
    - "redis://192.168.32.176:6372"
    - "redis://1192.168.32.176:6373"
  scanInterval: 1000
#threads: 0
#nettyThreads: 0
codec: !<org.redisson.codec.JsonJacksonCodec> {}
transportMode : "NIO"

RedissonConfig这里根据环境使用相应的配置

@Configuration
public class RedissonConfig {

    @Autowired
    private Environment env;
    @Autowired
    private RedissonProperties redssionProperties;

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() throws IOException {
        String[] profiles = env.getActiveProfiles();
        String profile = "";
        if(profiles.length > 0) {
            profile = "-" + profiles[0];
        }

        Config config = Config.fromYAML(new ClassPathResource("redisson" + profile + ".yml").getInputStream());
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }


}

Java中使用很简单直接调用就可以了

 @Autowired
    private RedissonClient redissonClient;
    /**
     * 每天凌晨0点执行一次
     */
 @Scheduled(cron = "0 0 0 */1 * ?")
  public void executeCountMonitorDataTask() {
      log.info("*******执行记录状态标记任务*******");
      String lock_key = RedisKeyEnum.REDIS_LOCK_PUBLISH_TASK.getRedisPrefix();
      RLock lock = redissonClient.getLock(lock_key);
      try {
          lock.lock();
          log.info("get redis lock success:{}",lock_key);
          execJob();
      } catch (Exception e) {

      } finally {
          lock.unlock();
          log.info("unlock redis lock success:{}",lock_key);
      }


  }

主要枷锁解锁
redissonClient.getLock(lock_key)
lock.unlock();

参考资料

图片来源:https://www.cnblogs.com/cjsblog/p/9831423.html

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值