引子
面试官常会问,你在项目中使用了哪些技术,Mysql、Redis、MQ 等等。看到你说了 Redis,一开始可能会简单的考察你一下 Redis 的特性和一些相关基础知识,之后可能会问你你还用过 Redis 做过什么技术实现吗?这时候你可以讲讲用 Redis 实现分布式锁。
分布式锁需要考虑哪些问题?
分布式的概念相信大家都有自己的理解。
锁,Java 中很多锁,synchronized 、ReentrantLock…
为什么需要分布式锁呢,当服务集群部署下,Java 的锁只是锁了本机器下服务,但对于具体的操作数据来说,比如说是一个账户,它在Mysql中又是唯一的一行,那么集群并发下,肯定会有线程安全问题(当然你可以说 sql 中采用 CAS 的思想,但是也会有丢失修改的并发事务问题),具体业务可以具体考量实现。
这里可以先思考一下,分布式锁需要考虑哪些问题,其实 Java 中的锁,我们能了解到有互斥性、独占性、不能死锁、可重入 等这些概念。对于集群部署来说,我们可以抽象把集群中所有的服务看做是一个服务,那分布式锁就很好理解,全服务里面唯一嘛。
- 互斥性:任意时刻,只有一个锁持有者
- 独占性:锁持有者只能释放自己持有的锁
- 不能死锁:锁需要有过期时间
- 可重入:只要你持有锁,无需再获取锁,所以可以忽略掉可重入的概念
分布式情况下,为了不能死锁,我们引入锁过期,但是这样会引出一个新问题。如果业务没有执行完毕,锁因为过期,被动释放了,这里互斥性就被打破了。所以一般来说我们还需要考虑锁续期。
然后锁的高可用性,实际上需要我们保证中间件的高可用性。
基于 Redis 的分布式锁
整体步骤:
- 根据要锁的资源,生成 key 作为锁标识,生成一个唯一的标识作为 value。比如锁定账户id=1的账户,key=“redis_lock_account_” + “1”,value = UUID…
- 只有不存在 key 的时候,才可以设置值,保证锁的互斥性
- 设置一个过期时间,防止锁持有者异常或忘记释放锁,防止死锁
- 创建一个守护线程,轮询判断过期时间,给锁续期
- 当锁持有者业务执行完毕,释放锁时,使用唯一的标识 去释放锁,保证锁的独占性
当然第 5 步 要保证原子性,使用 lua 脚本去执行
if redis.call("get",KEYS[1])== ARGV[1] thenreturn redis.call("del",KEYS[1])elsereturn0
end
至此,一个简单的 Redis 分布式锁就实现了,对于单节点部署 Redis,一般采用这种方案,但是这方案也是有诸多问题需要考量的。
- 锁过期时间设置多长?
- 守护线程多久轮训一次做锁续期?
- 如果 Redis 挂了,这个业务是不是无法继续执行下去了?
Redisson
Redisson 是 Redis 官方的分布式锁组件。GitHub 地址:https://github.com/redisson/redisson
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。Redisson 提供了使用 Redis 的最简单和最便捷的方法。Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
其实我们没必要重复造轮子,Redisson 很强大,可以直接拿来就用,当然,我们需要了解下它的实现。
原理解析参考入口:https://mp.weixin.qq.com/s/y_Uw3P2Ll7wvk_j5Fdlusw
Redisson 帮我们解决了第一个问题和第二个问题:
- 锁过期时间设置多长?
- 守护线程多久轮训一次做锁续期?
Redisson 提供了看门狗,每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间。在释放锁的同时结束这个线程。
但是第三个问题,没法解决。。
- 如果 Redis 挂了,这个业务是不是无法继续执行下去了?
RedLock
Redis 官网对 redLock 算法的介绍大致如下:The Redlock algorithm
在分布式版本的算法里我们假设我们有 N 个 Redis master 节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调机制。之前我们已经描述了在 Redis 单实例下怎么安全地获取和释放锁。我们确保将在每(N) 个实例上使用此方法获取和释放锁。在我们的例子里面我们设置 N=5,这是一个比较合理的设置,所以我们需要在 5 台机器或者虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。为了取到锁,客户端应该执行以下操作:
- 获取当前 Unix 时间,以毫秒为单位。
- 依次尝试从 5 个实例,使用相同的 key 和具有唯一性的 value(例如UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个尝试从某个 Reids 实例获取锁的最大等待时间(超过这个时间,则立马询问下一个实例),这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁消耗的时间。当且仅当从大多数(N/2+1,这里是3个节点)的 Redis 节点都取到锁,并且使用的总耗时小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key 的真正有效时间 = 有效时间(获取锁时设置的 key 的自动超时时间) - 获取锁的总耗时(询问各个 Redis 实例的总耗时之和)(步骤 3 计算的结果)。
- 如果因为某些原因,最终获取锁失败(即没有在至少 “N/2+1 ”个 Redis 实例取到锁或者“获取锁的总耗时”超过了“有效时间”),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功,这样可以防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。