Redisson实现分布式锁原理及源码分析

为什么要分布式锁

单体应用中,可以通过synchronized等相关锁实现线程间共享数据的独占,但是在分布式环境下,线程锁是不能跨应用的,所以需要通过一个分布式存储组件来实现分布式锁。常用的分布式锁实现组件有Redis和ZooKeeper,由于Redis是AP(可用性)架构的,ZooKeeper是CP(一致性)架构的,根据实际的应用场景,两种实现方案都可以。

大多数场景下,用Redis实现分布式锁就可以了,主要是Redis性能比ZooKeeper更好,实现的分布式锁效率更高。如果需要在强一致性条件下实现分布式锁,那么可以考虑使用ZooKeeper。

此处介绍的是Redis实现分布式锁,一些Redis客户端组件已经为我们封装实现了Redis分布式锁了,比如Redisson。生产环境下,建议使用这些组件实现的分布式锁,以防止自己实现时考虑不周导致线上问题。这些三方组件是通过大量用户验证了的,安全性和性能更高一些。

下面来介绍下分布式锁的实现原理,以及Redisson实现分布式锁的方案和源码。

Redisson实现分布式锁

参考:github.com/redisson/redisson/wiki

原理图

代码示例

大概逻辑:

java 复制代码
// 下单场景
public String order(){
    // 商品编号
    String lockKey = "product:001";
    // 获取锁对象,即RedissonLock
    RLock lock = redisson.getLock(lockKey);
    try {
        // 加分布式锁
        lock.lock();
        // 执行业务逻辑
        System.out.println("抢单逻辑。。。");
    } finally {
        // 释放锁
        lock.unlock();
    }
    return "success";
}

后面的源码分析是根据getLock()lock.lock()unlock(),三个方法来展开的,暂未分析tryLock()的实现。

锁实现关注点

前面的代码是大概的加解锁逻辑,如果要实现分布式锁,可以提前想一想,Redisson的代码需要实现哪些功能呢?

  • 如何原子地获取锁?

可以通过redis的setnx命令实现原子操作,key不存在返回true,key已存在返回false。

  • 客户端服务器宕机,锁如何释放?

即使在finally里有解锁逻辑,但是如果服务器宕机或者应用挂掉时没有走finally逻辑,会导致之前设置的锁无法释放,导致死锁。可以通过expire给指定key设置过期时间,如果客户端服务意外宕机,锁也会在过期后自动释放。

  • 如何保证设置值和设置过期时间的原子性?

redisson中是通过lua脚本封装redis命令setnx和expire实现原子性的,lua脚本的批量命令可以在redis服务端原子地执行,这是redis所支持的特性,关于lua脚本后面会介绍。

  • 如何设置过期时间从而保证业务逻辑刚好执行完释放锁?

如果只是简单地设置一个固定过期时间,可能会出现两种场景问题:第一,如果业务逻辑没有执行完锁过期了,此时别的请求就会加锁,导致之前请求的锁失效,引发并发问题;第二,如果业务逻辑执行完锁没过期,此时就会导致别的请求也无法获取到锁,降低了锁性能。第一个问题,redisson中是通过锁续期来解决的(watch dog)。第二个问题,redisson中是通过redis的发布订阅功能实现的,锁释放后发布一个消息到redis的channel,等待获取锁的请求会订阅到该消息,及时地再次尝试获取锁。

  • 没有竞争到锁的请求是如何处理的?

类似于synchronized的线程锁,竞争失败的请求需要再次获取锁,在redisson中是通过间歇性尝试加锁来实现的。每隔一定的时间再次尝试获取锁,而且该请求会订阅锁的释放消息,一旦锁释放等待中的请求立马尝试加锁,而不用在下一个轮循周期到来时再尝试加锁。这么看来,redisson尝试加锁逻辑是非公平锁的实现。

  • redisson会存在一个请求释放另一个请求的锁吗?

不会。我们可以来分析一下,如果请求A获取锁后执行业务逻辑还没释放锁,请求B也获取到了该锁,请求A比请求B先执行完,此时释放的是请求B的锁。但是此种情况在redisson中是不存在的,因为redisson有锁续命逻辑,请求A获取到锁后别的请求是拿不到锁的。

可以看到,要实现一个分布式锁功能,要考虑的地方还是很多的,不仅要逻辑缜密无逻辑漏洞,还需要考虑到锁的性能。

源码分析

getLock()-获取锁对象

获取锁对象,其实就是获取一个RedissonLock对象(锁的大部分逻辑都是在该对象中实现的),主要是一些初始化工作。

java 复制代码
RLock lock = redisson.getLock(lockKey);

RedissonLock继承了RedissonExpirable抽象类,实现了RLock接口,如下图:

RedissonLock构造方法:

java 复制代码
// commandExecutorcommandExecutor为通过redis连接配置生成的命令执行器
// name为锁名称
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
    // 对应的RedissonExpirable构造方法
    super(commandExecutor, name);
    this.commandExecutor = commandExecutor;
    // id为通过UUID.randomUUID()生成的uuid
    this.id = commandExecutor.getConnectionManager().getId();
    // internalLockLeaseTime为redis连接配置文件配置的看门狗超时时间,默认30秒
    this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
}

lock()-加锁

开始竞争锁。

java 复制代码
lock.lock();
java 复制代码
public void lock() {
    try {
        // 获取锁
        lockInterruptibly();
    } catch (InterruptedException e) {
        // 处理中断异常
        Thread.currentThread().interrupt();
    }
}
java 复制代码
public void lockInterruptibly() throws InterruptedException {
    lockInterruptibly(-1, null);
}

获取锁:

获取锁成功返回ttl剩余过期时间为null,不再继续执行。获取锁失败返回ttl剩余过期时间不为null,while循环再次获取锁,这里用到了Semaphore实现阻塞ttl时间后轮序和唤醒通知机制。

java 复制代码
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    // 通过lua脚本原子地设置锁名称到redis,并设置过期时间(默认30秒)
    // 是一个hash结构,key为锁名称,field为uuid+当前线程id
    // ttl为当前锁剩余过期时间毫秒数
    Long ttl = this.tryAcquire(leaseTime, unit, threadId);
    
    // 请求获取锁成功,ttl返回null,不再执行后面逻辑
    
    if (ttl != null) {
        // ttl不为空,表示当前锁已被占用,没有获取到锁
        // redis的发布订阅,没有抢到锁的请求订阅一个channel,哪里发布的这个channel呢?主请求解锁时发布的
        RFuture<RedissonLockEntry> future = this.subscribe(threadId);
        this.commandExecutor.syncSubscription(future);

        try {
            while(true) {
                // 再次尝试获取锁,刷新ttl
                ttl = this.tryAcquire(leaseTime, unit, threadId);
                if (ttl == null) {
                    // 获取到了锁
                    return;
                }

                if (ttl >= 0L) {
                    // 仍然没有获取到锁,自旋获取锁,非公平
                    // 信号量,阻塞ttl时间后再while,让出cpu,因为信号量的凭证数量为0
                    // 如果获取到锁的请求在ttl之内就自行完毕,难道这里还要等ttl个时间?有唤醒功能
                    this.getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    this.getEntry(threadId).getLatch().acquire();
                }
            }
        } finally {
            // 取消订阅
            this.unsubscribe(future, threadId);
        }
    }
}
java 复制代码
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    // 获取异步结果
    return get(tryAcquireAsync(leaseTime, unit, threadId));
}

尝试加锁:

加锁成功走续命逻辑,加锁失败会还行上级方法中的while循环获取锁逻辑。

java 复制代码
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
    if (leaseTime != -1) {
		// lock()不会进来
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
	// leaseTime获取的是redis配置中的时间,默认30秒
    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;
            }
        	// tryLockInnerAsync()方法异步执行成功后
            // 得到当前锁过期剩余时间
            Long ttlRemaining = future.getNow();
            // lock acquired
            if (ttlRemaining == null) {
                // 加锁成功,走这里
                // 超时刷新,锁续命逻辑,延时任务xxx/3,主线程锁存在,重置为30秒
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}

加锁的lua脚本分析:

java 复制代码
<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 " +
              // 锁名为key在redis中不存在,加锁并设置过期时间,注意此处的数据结构是hash
              // key为锁名,filed为uuid+threadId,value为1,过期时间为传入的时间
              "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 " +
              // 锁对应的filed存在,可重入锁,对应的filed加1,重置过期时间
              // 暂不分析这种情况
              "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
              "redis.call('pexpire', KEYS[1], ARGV[1]); " +
              "return nil; " +
              "end; " +
          	  // 锁名为key在redis中已存在,但是不是自己的锁,返回当前锁过期剩余时间,单位毫秒
              "return redis.call('pttl', KEYS[1]);",
              Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

续命逻辑:

后台异步任务。加锁成功后,每隔30/3=10秒重置当前锁的过期时间。

java 复制代码
private void scheduleExpirationRenewal(final long threadId) {
    // tryLock的实现,暂不分析
    if (expirationRenewalMap.containsKey(getEntryName())) {
        return;
    }

    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            // 锁续命
            RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        // 当前锁存在,将锁过期时间重置为30秒
                     	"redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return 1; " +
                    "end; " +
                    "return 0;",
                      Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
            
            future.addListener(new FutureListener<Boolean>() {
                @Override
                public void operationComplete(Future<Boolean> future) throws Exception {
                    expirationRenewalMap.remove(getEntryName());
                    if (!future.isSuccess()) {
                        log.error("Can't update lock " + getName() + " expiration", future.cause());
                        return;
                    }
                    
                    if (future.getNow()) {
                        // reschedule itself
                        // 递归实现续命逻辑
                        scheduleExpirationRenewal(threadId);
                    }
                }
            });
        }
        // 默认每30/3=10秒间歇性执行
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
        task.cancel();
    }
}

unlock()-解锁

主动解锁。

java 复制代码
lock.unlock();
java 复制代码
public void unlock() {
    // 解锁
    Boolean opStatus = (Boolean)this.get(this.unlockInnerAsync(Thread.currentThread().getId()));
    if (opStatus == null) {
        // 释放当前锁失败
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + Thread.currentThread().getId());
    } else {
        if (opStatus) {
            // 成功解锁,取消锁续命定时任务
            this.cancelExpirationRenewal();
        }

    }
}
java 复制代码
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
              	// 锁不存在,发布消息,等待的请求订阅了该channel
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
              	// 锁已被释放
                "return nil;" +
            "end; " +
          	// 之前这里设置的是1
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
              	// 重入锁解锁
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            "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));

}

LUA脚本

redis2.6之后支持的可以在redis服务端执行的命令。

redis管道不支持多命令原子操作,redis也有事务,但是官方更建议使用lua实现事务。

redis中使用lua脚本格式:

java 复制代码
eval script numkeys key [key ...] arg [arg ...]

命令解释:

script:具体的lua命令。

numkeys:参数用于指定键名参数的个数。

key [key ...]:从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] ,KEYS[2] ,以此类推)。对应redis命令中的key。

arg [arg ...]:附加参数,可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、ARGV[2] ,诸如此类)。

lua示例:

lua 复制代码
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

其中:return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}表示这是一段有返回值的lua脚本。数字2指明了键名参数的数量,key1key2是键名参数,分别使用KEYS[1]KEYS[2]访问,firstsecond是附加参数,通过ARGV[1]ARGV[2]访问。

在lua脚本中执行redis命令:

lua 复制代码
redis.call();
redis.pcall();

示例:

lua 复制代码
> eval "return redis.call('set','foo','bar')" 0
OK
> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 foo bar
OK
> get foo
bar
相关推荐
卓航6 小时前
Redis slowlog使用和实现
redis
波吉爱睡觉6 小时前
Redis反弹Shell
redis·web安全·网络安全
骑着蜗牛闯宇宙7 小时前
Thinkphp8 Redis队列与消息队列Queue
redis·php
是阿建吖!9 小时前
【Redis】初识Redis(定义、特征、使用场景)
数据库·redis·缓存
neoooo9 小时前
《锁得住,才能活得久》——一篇讲透 Redisson 分布式锁的技术实录
java·spring boot·redis
小七mod13 小时前
【Spring】Spring Boot启动过程源码解析
java·spring boot·spring·面试·ssm·源码
Villiam_AY20 小时前
Redis 缓存机制详解:原理、问题与最佳实践
开发语言·redis·后端
GEM的左耳返1 天前
Java面试全攻略:Spring生态与微服务架构实战
spring boot·redis·spring cloud·微服务·kafka·java面试
程序员勋勋11 天前
Redis的String数据类型底层实现
数据库·redis·缓存
颜颜yan_1 天前
Python面向对象编程详解:从零开始掌握类的声明与使用
开发语言·redis·python