Skip to content

技术精华-完全解读Redisson的分布式锁原理

技术精华-完全解读Redisson的分布式锁原理

for update

SQL

sql
select column from table where column = ... for update

在 select 的 sql 上加上 for update 会对此记录加上行级锁,在超时,提交,回滚会进行释放。

缺点: 当请求等待锁释放时,不能灵活的控制加锁时间、等待锁的时间。如果在一个事务中,开始的时候就使用 for update 的话,则需要这个事务执行完提交或回滚才能够解锁,不能很好的控制锁的粒度,并发性会降低。在 Repeatable Read 的隔离级别下有可能会产生死锁。 参考链接

项目中的 Redis 锁

java
public ResultMap<IDCardOCRVo> IDCardOCR(IDCardOCRDto dto){

    //部分省略。。。

    //通过redis防重提交

    Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(userId, "1");

    if (ifAbsent) {

        stringRedisTemplate.expire(userId, 15, TimeUnit.SECONDS);

    }else {

        throw new BusinessException(ResultCode.NOT_FREQUENTLY_OPERATE);

    }

}

如果执行到 if (ifAbsent) 服务挂掉,那么这个 userId 就会一直存在 redis 中,别的请求一直获取不到,相当于死锁。

Redisson

地址:https://github.com/redisson/redisson

特点

Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格框架, 充分利用 Redis 键值数据库提供的一系列优势, 基于 Java 实用工具包中常用接口, 为使用者提供了 一系列具有分布式特性的常用工具类。

  1. 互斥性:指定一个 key 作为锁标记,存入 Redis 中,指定一个 唯一的用户标识 作为 value。当 key 不存在时才能设置值,确保同一时间只有一个客户端进程获得锁。
  2. 防死锁:设置一个过期时间,防止因系统异常导致没能删除这个 key。
  3. 安全性:当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足 只有加锁的人才能释放锁
  4. WatchDog 机制:能够很好的解决锁续期的问题,预防死锁。
  5. 灵活性:能够灵活的设置加锁时间,等待锁时间,释放锁失败后锁的存在时间。

流程图

(此处建议插入 Redisson 分布式锁的流程图,例如:尝试获取锁 -> Lua 脚本执行 -> 成功/失败 -> Watchdog 续期 -> 释放锁)

原理

构建过程 org.redisson.Redisson#getLock

(源码分析部分,通常涉及 RedissonClient.getLock("key") 返回 RedissonLock 对象)

加锁过程 org.redisson.RedissonLock#lock()

我们直接调用的 lock 方法,这时 leaseTime 为 -1,不执行 if 分支。

这时 leaseTime 为默认的 30s,这段 lua 的执行是重点:

  1. 首先呢,他先用 exists 命令判断了待获取锁的 key anyLock 存不存在。
  2. 如果不存在,就使用 hset 命令将锁 key testlock 作为 key 的 map 结构中存入一对键值对,UUID:ThreadID 1
  3. 同时还使用了 pexpire 命令给 anyLock 设置了过期时间 30000 毫秒,然后返回为空。
  4. 如果 anyLock 已经存在了,会走另一个分支,此时会判断 anyLock Map 中是否存在 UUID:ThreadID
  5. 如果存在的话,就调用 hincrby 命令自增这个 key 的值,并且将 anyLock 的过期时间设置为 30000 毫秒,并且返回空。
  6. 如果上面俩种情况都不是,那么就返回这个 anyLock 的剩余存活时间。

脚本也可以保证执行命令的原子性。然后呢就直接返回了一个 RFuture ttlRemainingFuture, 并且给他加了一个监听器,如果当前的这个异步加锁的步骤完成的时候调用,如果执行成功,就直接同步获取一个 Long 类型的 ttlRemaining。通过加锁的 lua 脚本可知,如果加锁或者重入锁成功的话会发现 TTLRemaining 是为 null 的,那么就会执行下面的这一行代码,我们可以看到注释 锁已获得

以上我们分析了 redisson 加锁的过程,总结来说,流程不复杂,代码也很直观,主要是异步通过 lua 脚本执行了加锁的逻辑。

看门狗机制 (WatchDog)

其中,我们注意到了一些细节,比如 RedissonLock 中的变量 internalLockLeaseTime, 默认值是 30000 毫秒,还有调用 tryLockInnerAsync() 传入的一个从连接管理器获取的 getLockWatchdogTimeout(), 他的默认值也是 30000 毫秒,这些都和 redisson 官方文档所说的 watchdog 机制有关。看门狗,还是很形象的描述这一机制,那么看门狗到底做了什么,为什么怎么做呢?下面我们就来分析和探讨一下。

加锁成功后的问题

假设在一个分布式环境下,多个服务实例请求获取锁,其中服务实例 1 成功获取到了锁,在执行业务逻辑的过程中,服务实例突然挂掉了或者 hang 住了,那么这个锁会不会释放,什么时候释放?

回答这个问题,自然想起来之前我们分析的 lua 脚本,其中第一次加锁的时候使用 pexpire 给锁 key 设置了过期时间,默认 30000 毫秒,由此来看如果服务实例宕机了,锁最终也会释放,其他服务实例也是可以继续获取到锁执行业务。

但是要是 30000 毫秒之后呢,要是服务实例 1 没有宕机但是业务执行还没有结束,锁释放掉了就会导致线程问题,这个 redisson 是怎么解决的呢?这个就一定要实现自动延长锁有效期的机制。

之前,我们分析到异步执行完 lua 脚本执行完成之后,设置了一个监听器,来处理异步执行结束之后的一些工作。

  1. 首先,会先判断在 expirationRenewalMap 中是否存在了 entryName,这是个 map 结构,主要还是判断在这个服务实例中的加锁客户端的锁 key 是否存在,如果已经存在了,就直接返回;第一次加锁,肯定是不存在的。
  2. 接下来就是搞了一个 TimeTask,延迟 internalLockLeaseTime/3 之后执行,这里就用到了文章一开始就提到奇妙的变量,算下来就是大约 10 秒钟执行一次,调用了一个异步执行的方法 renewExpirationAsync 方法, 也是调用异步执行了一段 lua 脚本。

续约脚本逻辑: 首先判断这个锁 key 的 map 结构中是否存在对应的 UUID:ThreadID,如果存在,就直接调用 pexpire 命令设置锁 key 的过期时间, 默认 30000 毫秒。

在上面任务调度的方法中,也是异步执行并且设置了一个监听器,在操作执行成功之后,会回调这个方法,如果调用失败会打一个错误日志并返回,更新锁过期时间失败;然后获取异步执行的结果,如果为 true,就会调用本身,如此说来又会延迟 10 秒钟去执行这段逻辑。

总结: 这段逻辑在你成功获取到锁之后,会每隔十秒钟去执行一次,并且,在锁 key还没有失效的情况下,会把锁的过期时间继续延长到 30000 毫秒,也就是说只要这台服务实例没有挂掉,并且没有主动释放锁,看门狗都会每隔十秒给你续约一下,保证锁一直在你手中。完美的操作。

其他实例没有获得锁的过程

这时如果有别的服务实例来尝试加锁又会发生什么情况呢?或者当前客户端的别的线程来获取锁呢?很显然,肯定会阻塞住,我们来通过代码看看是怎么做到的。还是把眼光放到之前分析的那段加锁 lua 代码上。

  • 可重入性:当加锁的锁 key 存在的时候并且锁 key 对应的 map 结构中当前客户端的唯一 key 也存在时,会去调用 hincrby 命令,将唯一 key 的值自增一,并且会 pexpire 设置 key 的过期时间为 30000 毫秒,然后返回 nil, 可以想象这里也是加锁成功的,也会继续去执行定时调度任务,完成锁 key 过期时间的续约,这里呢,就实现了锁的可重入性。
  • 互斥性:那么当以上这种情况也没有发生呢,这里就会直接返回当前锁的剩余有效期, 相应的也不会去执行续约逻辑。此时一直返回到上面的方法: 如果加锁成功就直接返回,否则就会进入一个死循环,去尝试加锁,并且也会在等待一段时间之后一直循环尝试加锁,阻塞住,直到第一个服务实例释放锁。

对于不同的服务实例尝试会获取一把锁,也和上面的逻辑类似,都是这样实现了锁的互斥。

释放锁

释放锁的 Lua 脚本逻辑:

  1. 判断当前客户端对应的唯一 key 的值是否存在,如果不存在就会返回 nil
  2. 否则,值自增 -1。
  3. 判断唯一 key 的值是否大于零,如果大于零,则返回 0(锁未完全释放,因重入)。
  4. 否则删除当前锁 key,并返回 1(锁已完全释放)。

返回到上一层方法,也是针对返回值进行了操作,如果返回值是 1,则会去取消之前的定时续约任务,如果失败了,则会做一些类似设置状态的操作。

关于 Redlock 与主从一致性

现在来说,redis 分布式锁,redisson 去加锁,也就是去 redis 集群中选择一台 master 实例去实现锁机制,并且能因为一台 master 可能会挂载多台 slave 实例,这样也就实现了高可用性。

但是呢,不得不去思考,如果 master 和 salve 同步的过程中,master 宕机了,偏偏在这之前某个服务实例刚刚写入了一把锁,这时候就尴尬了,salve 还没有同步到这把锁,就被切换成了 master,那么这时候可以说就有问题了,另一个服务实例在新的 master 上获取到一把新锁,这时候就会出现俩台服务实例都持有锁,执行业务逻辑的场景,这个是有问题的。也是在生产环境中我们需要去考虑的一个问题。