Redis还可以干分布式锁的活儿
现在的业务开发一般都是分布式微服务,多个微服务之间要保证同一时刻,同一用户只能有一个请求被处理,为了防止关键业务出现数据冲突和并发错误。
在只有一台服务器,单机的情况下,为了防止出现并发问题,你也许想到可以使用Synchronized同步关键字或者使用Lock手动加锁来防止高并发的线程安全问题,但是如果在分布式集群的情况下,多台服务器协同工作,再使用Synchronized同步关键字和Lock锁还能起作用吗?当然不能了,单机锁只能管控住自己这一台服务器,多台服务器之间,物理上是没有联系的,只是在逻辑上有联系。此时我们在分布式部署的场景下,必须得用分布式锁。
分布式锁既不是给同一进程中的多个线程使用,也不是给同一台机器上的多个进程使用,而是用来控制多台不同机器上不同线程之间的同步。
这个分布式说是由不同机器上的Redis客户端进行获取和释放的。没错,Redis虽然最常用来做缓存,它的一些特性,天生就是干缓存的料儿,但是它也可以有“副业”啊,那就是实现分布式锁。
举个例子,比如修改一个用户账户余额,我们采用分布式微服务架构,不同的服务部署在不同的机器上,如下图
很显然,这两个线程同时操作,出现了并发问题,因为“读取账户余额”和修改后“保存账户余额”不是原子操作!我们需要使用Redis来做分布式锁,相当于是一座沟通不同机器之间的桥梁,实现不同机器上的多个线程之间的同步。
那么Redis到底怎么来实现分布式锁呢?既然分布式锁也是一把锁,那肯定和我们之前学习的Lock锁,或者Synchronized同步锁相类似,都是以某个东西作为中间媒介,来控制线程之间的同步。这个中间媒介可以是类似于标志位、信号量等,只要是就有唯一性即可。想想我们之我们之前学习的Synchronized锁和Lock锁都是那什么来当锁的,class对象、实例对象、静态变量等等。那么Redis要想实现分布式锁,该用什么东西作为媒介呢?作为K-V键值对数据库,我猜你也就想到了利用key来充当锁了。你还真猜对了!
Redis提供的setnx
(set if not exists)命令就具有基本的加锁功能,这个命令在代表锁的key不存在的情况下,才会执行成功。因此我们可以使用这个命令来获取锁,如果获取到锁,其它客户端再执行这个命令时,由于代表锁的key已经存在了,那么这个命令就会执行失败。执行完相应的操作后执行del
命令删除这个key,相当于释放锁。
但是有个问题你有没有想到,万一执行到之间,出现了异常,导致del
命令没有被执行,那岂不是锁就会得不到释放,进而导致死锁吗?你可能会想到把del命令放到代码层面finally中释放锁,这样无论正常获取锁、释放锁,还是出现异常,都能够执行到del
命令来释放锁。我们可以使用官方推荐的Lua脚本保证释放锁操作的原子性,或者自己手写,使用watch
命令和事务,利用**CAS自旋的思想,简单实现一个乐观锁。**在数据被其它客户端抢先修改了的情况下,通知加锁的客户端,让它撤销对数据的修改,而不会真正的把数据锁住。
你以为万事大吉了吗?可是如果发生宕机了呢?部署了微服务代码层面根本就没有执行到finally代码块,没办法保证释放锁,所以还需要给这个锁设置过期时间。加锁和设置过期时间必须是原子操作。
设置了过期时间的分布式锁,在释放锁的时候,还需要判断当前锁是否是自己当时获取的锁。因为持有锁的进程很可能由于业务逻辑复杂操作时间过长而导致锁到期被自动释放,在当前进程还没有完全执行完业务操作时,由于锁被到期自动释放,很可能其它进程又获取到锁,这样就会发生同一把锁,两个线程在占用。所以放复杂业务逻辑执行完时,在释放锁的时候,还需要判断当前准备释放的锁是否还是原来的那把锁。你可能会说把过期时间设置长一点儿不就可以了吗?设置多长才合适,业务执行时间的长短是不能保证,过期时间设置过长或者过短都不合适。因此设置过期时间长短又成了一个问题。
针对设置过期时间有两种方案:
- 守护线程“续命”:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间。
- 超时回滚:当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行回滚,并返回失败。
但是使用守护线程进行续命的方法在某些极端的情况下会发生问题:线程1首先获取锁成功,将键值对写入 redis 的 master 节点,在 redis 将该键值对同步到 slave 节点之前,master 发生了故障,redis 触发故障转移,其中一个 slave 升级为新的 master,此时新的 master 并不包含线程1写入的键值对,因此线程2尝试获取锁也可以成功拿到锁,此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据。根本原因主要是由于 redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致性
由此看来我们使用setnx
命令和del
命令,虽然能够实现简单的加锁释放锁,但是需要考虑的其它问题太多了,显然这一对命令是不可靠的。Redis也考虑到了这些问题,因此Redi官方推荐直接使用RedLock的Redisson实现。
所谓的Redlock算法,是基于set加锁、lua脚本解锁进行的改良。假设我们有 N 个 Redis 主节点,例如 N = 5,这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统,为了取到锁,客户端应该执行以下操作:
- 获取当前时间
- 依次尝试从5个实例,使用相同的 key 和随机值(例如UUID)获取锁。当向Redis 请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在 5-50 毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis 节点对话时长时间处于阻塞状态。如果一个实例不可用,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端通过当前时间减去步骤1记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功
- 如果取到了锁,锁真正有效时间等于初始有效时间减去获取锁所使用的时间(步骤3计算的结果)
- 如果由于某些原因未能获得锁(无法在至少N/2+1个Redis实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)
可以看出,该方案为了解决数据不一致的问题,直接舍弃了异步复制,只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点,官方建议是 5。
该方案主要存以下问题:
- 严重依赖系统时钟。如果线程1从3个实例获取到了锁,但是这3个实例中的**某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,**当他释放后,此时又有3个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。
- 如果线程1从3个实例获取到了锁,但是万一其中有1台重启了,则此时又有3个实例是空闲的,则线程2也可以获取到锁,此时又出现两个线程同时持有锁了
除了使用Redis实现分布式锁,实际上Zookeeper这个分布式应用程序协调服务组件也能实现分布式锁。
Zookeeper 的分布式锁实现方案如下:
- 创建一个锁目录
/locks
,该节点为持久节点 - 想要获取锁的线程都在锁目录下创建一个临时顺序节点
- 获取锁目录下所有子节点,对子节点按节点自增序号从小到大排序
- 判断本节点是不是第一个子节点,如果是,则成功获取锁,开始执行业务逻辑操作;如果不是,则监听自己的上一个节点的删除事件
- 持有锁的线程释放锁,只需删除当前节点即可
- 当自己监听的节点被删除时,监听事件触发,则回到第3步重新进行判断,直到获取到锁
持有锁的线程释放锁,只需删除当前节点即可
6. 当自己监听的节点被删除时,监听事件触发,则回到第3步重新进行判断,直到获取到锁
Zookeeper 保证了数据的强一致性,CAP理论侧重于实现CP,因此不会存在之前 Redis 方案中的问题,但是性能不如Redis那么好