1、Redlock
最近学习了以下Redis的作者antirez在很早之前提出的Redlock分布式锁的概念,阅读了以下两篇文章:
- 基于Redis的分布式锁到底安全吗(上)?
- 基于Redis的分布式锁到底安全吗(下)?
在本篇博客中,会对Redlock的实现做一些简单的总结,来加深自己对该分布式锁实现的印象,以及详细认识到它潜在的问题,在今后能针对不同的业务场景选择出合适的分布式锁实现。
1.1、单节点锁
首先,如何使用一个单机redis节点实现一个锁呢?
我们知道,想要对一个资源区获取唯一的访问权限,需要有两个原子操作的支持,才能保证多条语句的并发安全:
在Redis中,我们可以通过SETEX和DEL方法实现。
加锁:使用SETNX key value操作,可以在redis中保存一条唯一的记录,让它作为当前客户端持有锁的标识,只有成功set了一条信息,才能让客户端持有对共享资源区的访问权限。
释放锁:使用DEL语句,删除掉这个标识,就当作已经释放了对共享资源区的锁定。
这样子简单的两条步骤,就可以轻松的实现锁的功能。但是它还存在着很多安全漏洞,我们需要一一完善。考虑有以下的场景:
场景一:客户端获取锁后宕机
- A:SETNX file permission,成功
- A:发生了宕机
- B:SETNX file permission,失败,重复尝试。
因为节点A获取了锁后,宕机了,导致这个锁永远不会被释放,就不会有其他的节点能获取到它,进入无限的等待。
为了解决这个问题,我们可以给锁添加一个过期时间,这样子假如某个节点获取锁后宕机了,长时间没有操作后,锁会自动释放掉。但是因为引入了过期时间,所以这个过期时间的大小设置也是需要非常讲究的,太短会导致可能任务没有执行完毕锁就被释放了,太长会导致节点宕机了其他节点等待时间过长,严重影响服务器的整体性能。
场景二:客户端执行时间过长,导致删除了其他客户端获取的锁
- A:SETNX file permission,成功
- A:执行业务,可能因为一些其他因素,例如等待网络响应、GC等,阻塞持续了很长的时间
- 锁因为过期被释放了
- B:SETNX file permission,成功
- A:执行业务完成,DEL file,成功
在这个场景中,A因为执行了过长的时间,导致误删了其他节点的锁。
为了解决这个问题,我们可以将value设置为一个随机的值,在删除锁的时候,需要先校验当前锁的值是否和自己加锁的值一样,若不一样则不允许删除。注意,这里删除锁涉及到了三个操作:获取锁、检查值、删除锁;这三个操作要求是原子的,否则会引发其他的并发问题。我们可以使用lua脚本来实现。
为什么使用Lua脚本能实现原子性?
取决于Redis的单线程性质(就算在Redis6之后引入的多线程,也是在I/O层面的多线程),所有的命令都是有一个主单线程串行处理,这样子如果能把多条命令当作一条命令来处理,在未处理完时不允许打断脚本,就可以实现原子性了。
经过了两轮改善,目前想要实现一个单节点redis的并发锁,我们就可以按照下面的流程来执行:
- 生成一个随机的值random_value
- 尝试加锁SET KEY random_value NX EX time
- 加锁成功,访问资源
- 删除锁,原子操作:
- GET KEY == random_value
- DEL KEY
如此一来,我们就实现了一个相对安全的单节点redis锁了。除了有可能在访问资源的过程中耗时过长导致另一个线程加入了资源区的访问,不会存在更多的故障。(并发安全性上)
1.2、分布式锁
进一步的,我们思考如何去实现一个分布式锁呢?
我们先看单节点redis锁存在着什么问题:假如这个redis崩溃了,那么所有的线程都没有权限访问资源区,导致整个系统无法使用,这就是系统的容灾性能太差了。
为此,我们引入多个redis节点,保证当一个redis节点崩溃的时候,其他的还可以提供服务来维持系统的稳定运行。
假设目前的redis集群采用了主从复制模式,我们按照刚刚的流程,会出现这样子的情况:
- A:尝试加锁
- 主节点:加锁成功,告知A,消息发出后立刻宕机
- 从节点:主节点死亡,晋升为主节点,但是A加锁成功的消息还未同步
- A:知道自己获取锁成功了
- B:尝试加锁
- 从节点:对B加锁成功
- A、B:都认为自己获取了锁,开始对资源区进行访问
出现这个问题的根源,来自于redis的数据同步操作是异步的,就会导致数据在一定时间内会出现不一致的问题,导致了并发安全问题。
我们想要让所有的redis节点,能都知道我们已经获取锁了,所以我们需要对每个redis节点都尝试进行获取锁的操作,这就是Redlock的思想。
Redlock 是由redis的作者antirez提出的一种分布式锁算法,它通过对多个redis实例进行多数写入、设置自动过期时间和时钟漂移补偿的方式,能够在分布式环境下为并发的共享资源提供一种安全可靠的互斥访问。
假设有N个redis节点,锁的key是resource_name,它的步骤如下:
- 加锁:
- 1、生成一个足够随机的字符串作为value
- 2、使用time命令获取当前的毫秒时间,设为start_ms
- 3、对每个Redis实例执行获取锁的操作,SET resource_name RANDOM NX PX TTL_MS,记录成功加锁的节点数为S
- 4、再次获取当前的毫秒时间,和开始时间相减得到进行操作的时间差,同时再减去一点时间用来补偿时钟漂移 ,最后再用过时时间减去这部分时间,得到剩下的有效时间VALIDITY
VALIDITY = TTL_MS – DELTA_MS – CLOCK_DRIFT
- 5、若S>=⌈N/2⌉(满足多数节点记录了锁),并且VALIDITY>0,则加锁成功;否则执行释放锁操作
- 释放锁:
- 对所有的Redis节点执行释放锁的原子操作,和之前的单节点锁相同。
通过这种方式,在我们之前提到的主从节点数据不一致的问题在Redlock中就不存在了。但是如果有节点发生崩溃重启,它还是会对锁的安全性带来影响的。可以看这个case:
- 有五个Redis节点,分别是A\B\C\D\E:
- 客户端1对A\B\C加锁成功,客户端1获取了锁。
- 节点C崩溃了,并且在C上的锁没有持久化下来,重启之后丢失了。
- 客户端2对C\D\E加锁成功,客户端2获取了锁。
这样下来,客户端1和客户端2获取了同一把锁。
问题出现在了“持久化”上面,在默认情况下,Redis的AOF持久化会每秒进行一次fsync刷盘,因此最坏的情况下我们会丢失掉1s的数据,为了尽可能不丢失数据,我们可以在加锁的时候都进行一次刷盘,但是这样子就会降低服务器的性能。但是即使我们执行了fsync,也不一定真的就落到磁盘上了,这取决于操作系统的控制,非redis的问题。所以,不管如何,它还是可能会存在丢失数据的风险的。针对这个情况,antirez提出了延迟重启的策略,当一个节点崩溃的时候,我们不应该立刻重启它,应该等到当所有锁都过期了之后,再去重启。这样的话,这个节点在重启之前所参与的锁都会失效,就可以实现安全重启。
还有一点需要注意的是,在释放锁的时候,不管之前在某个节点是否加锁成功,也应该对它执行一次释放锁的原子操作。因为可能会存在这样子的场景:客户端1向某个节点尝试加锁,在节点上加锁成功了,但是节点向客户端返回成功响应的数据缺丢包了,客户端以为是失败了,但是是成功了。为了保证资源能得到有效的释放,应该对所有的节点都进行一次释放的操作。
1.3、潜在的问题
现在我们知道,Redlock的实现是需要依赖于时钟的,它就会带来一些潜在的问题。在获取锁的操作中,我们获取了两次时钟,分别是开始时间和结束时间,假如在这个过程,时钟发生了变化,例如时钟向前跳或者向后跳,都会对安全带来破坏。
发生时钟跳跃的原因有两个:
- 管理员手动调节时钟
- 从NTP服务收到了一个大的时钟更新事件,导致时钟发生大幅度漂移
对于这两个原因,都是可以做到尽可能避免的。管理员手动调节时钟这一个原因,那么就不允许调节就好了,在实际项目中,一般也不会这么做,就像一般人不会手动修改持久化日志一样;第二个原因,我们可以使用一个不会进行大幅度跳跃的方式来修改时间(通过恰当的配置),在获取锁的实现中,我们本身就引入了对时钟漂移的考量,加入了一个补偿参数,可以应对几毫秒的时钟差距也能保障分布式锁的安全性。
另外,还有一些不可抗拒因素,可能会带来安全性的影响:
在获取锁的步骤中,当我们获取了结束时间之后,发生了一次长时间的GC或者网络波动,导致了锁已经过期了,但是客户端却无法察觉。这是无法预测的事情,它可能会在极端情况造成安全性的破坏。这是需要注意的地方。
2、小结
本篇文章到这里,关于redlock的总结就结束了。想要更好的使用redlock,我们应该知道它的优势以及存在的风险。Redlock的优势就在于轻量便捷,能够在大多数情况都可以防止双重持有,但是它也存在着潜在风险,依赖于时钟并且可能会受到长时间阻塞等问题的影响。
在实践中,如果系统对极少数的竞争是可以容忍的,并且希望部署成本较低,那么使用Redlock是没有问题的;若业务要求绝对禁止互斥、安全性零容忍,可以考虑将这部分资源改成串行执行的方式,或者尝试使用基于Zookeeper/etcd实现分布式锁。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |