文章目录
Redis分布式锁
redis分布式锁主要是基于setnx以及expired来进行设计的。
先来解释一下redis如何存储过期信息:
# redis一共有四种设置过期时间的命令:
expire [缓存有效期,秒]:例如expire 60代表有效期是60s
expireat [有效截止时间unixTime,秒数]:例如 expire 1490954500代表2017-03-31 18:01:40过期
expire [缓存有效期,毫秒]:例如expire 60000代表有效期是60s
pexpireat [有效截止时间毫秒数,毫秒数]:例如 expire 1490954500000代表2017-03-31 18:01:40过期
但是无论执行哪个命令,对于Redis底层来说,最终都是转换成pexpireat来实现的,也就是说实际保存的都是该key过期时间的毫秒数,所以最终保存的是unix时间戳,也就是到什么时候过期。
因为Redis最终存储的是unix时间戳,也就是有效截至时间,那么当expire存入AOF时,重新加载AOF就不会出现有效时间刷新的问题,因为时间戳没变。
比如:
set testkey testvalue
expire testkey 60
重新加载AOF是有效时间还是原来的60ms,不会更新
Redis的过期时间存在过期字典中,当验证是否过期时,判断是否在过期字典中,若在,则判断时间戳是不是小于当前unix时间戳,若小于,则过期。
Redis删除策略
- 定时删除:设置过期时间的时候,为它设置一个计时器,等到时间到了,立即删除。
优点:删除即时 ,过期立刻删除,不占用空间
缺点:过期key较多时,每个key都有一个时间戳,消耗CPU。并且每个Key都要创建一个计时器,消耗时间。 - 被动删除:当获取key时,判断是否过期,如果过期,那么返回空,并清理。
优点:不用额外创建删除任务,很少占用cpu
缺点:大量的key无法即时删除,占据内存 - 定期清理:定期清理一部分过期的key(要检查哪些过期了)例:每秒10次,每次20个key,这样每秒可以检查200个key。不易过大,消耗CPU。(这是一种折中的办法)
优点:通过限制删除操作的时长和频率,来减少cpu的占用率;cpu占用优于定时删除内存占用方面优于懒汉式
缺点:cpu占用方面劣于懒汉式,内存占用方面劣于定时删除 - 设置最大内存的淘汰策略
unix时间戳
unix时间戳是从1970年1月1日(UTC/GMT的午夜)开始所经过的秒数,不考虑闰秒。
单点
setnx(key,value)和expired(key,second)
当只有setnx时,没有设置有效时间,会发生死锁。例子:当线程A setnx获得锁后,挂掉了,无法释放锁了,那么其他线程永远也无法获得锁,所以会发生死锁。
解决方案
增加了expired设置锁的有效时间,默认30秒(博主目前还没搞懂为什么是30,如果有大佬知道,欢迎评论指教),这样如果发生上述情况,那么通过过期时间可以避免死锁。
set(key,value,second,time)
当加了expired后,依旧可能发生死锁,就是在setnx执行后,expired执行之前,线程挂掉,那么就会发生死锁。
解决方案
使用了set,将setnx和expired包装到set中,作为一个原子方法进行加锁和有效期的设置。这样就不会发生上述情况了。
能封装的原因:Redis命令中可以直接使用set命令进行有效时间的设置。
value的设计
当线程A 获得锁后,还没有执行完,有效期到了,这是线程B获得锁,然后线程A执行完,删除锁,此时发生了误删。
解决方案
- 为防止误删,可以将value设计成一个随机值或者线程ID,那么当删除时进行比对,如果value和当前线程一致,则可以删除,如果不一致,则不能删除。
- 防止没执行完就过期,可以使用一个守护线程,定时检查是否执行完毕。比如有效时间设为30s,那么没20秒检查一次是否执行完,没执行完,则续期。
lua脚本
当进行value判断时,如果判断时是一致的,然后判断完过期了,并且线程B获得锁,那么依旧会导致误删的出现。
解决方案
使用lua脚本,将value判断和删除封装成一个脚本,进而将判断和删除合并成一个原子操作,解决上述问题。
还存在的问题
上面的方案只适用于redis单实例,如果redis挂掉了,那么如果没有持久化的话,锁就没了。如果持久化了,如果采用AOF每秒持久化一次,那么在一秒前挂掉依旧会丢失锁,如果使用每条命令的AOF持久化,那么Redis性能消耗过大。使用RDB持久化也是如此。
可能有人会问,那为什么不使用主从模式呢?
使用主从模式,依旧会存在问题。首先, 主从复制是异步的,当在master中加锁后,还没来的及同步到slave中时,就挂掉了,那么如果slave升为master后,依旧会导致锁的重复获取。
例:线程A获得锁后,master挂掉,还没来得及同步到slave中,当slave升为master后,新master中并没有A获得锁的信息,所以线程B依旧可以获得锁,那么就会发生冲突。
解决方案
可以延迟升级,即slave升为master时,延迟一个ttl(锁的有效时间),这样升级为master时,就不是有重复获得锁的问题。但是在延迟过程中,想当于服务都停止了。
如果master可重启,那么也是要延迟重启。
RedLock
为解决上面重复获得锁的问题,提出了RedLock的解决方案。
思路:采用N个完全独立(尽可能不同物理机,故障隔离)的Redis节点,然后同时setnx,如果多数获得锁成功,那么加锁成功。这样就允许少数节点挂掉。加锁过程和单点类似。
N的取值:(通常是5个,因为5个节点的配置是比较合理的最小配置方式。且N一般为大于2的奇数,原因是相对均匀(比如:6和7,都需要至少4个节点获得锁,但是7更均匀,且7个里选四个比6个里选四个成功率更大))
时间漂移:简单来说就是两台机器由于地理位置的不同,导致时间流走的速度不一致,每台机器的时钟是根据具体地理位置的时间进行确定的。
时钟漂移就是机器与机器之间时间流速的差异。
RedLock过程:
- 客户端(加锁的机器,并不是浏览器那个)先获得本地时间戳
- 轮流用相同的key和value对redis实例加锁,在获得锁的过程中要设置一个快速失败时间(如果想要获得一个10秒的锁, 那么每一个锁操作的失败时间设为5-50ms)
这样可以防止因为单个redis阻塞导致其他redis获得锁失败。 - 再次获得当前时间,减去1中的时间即为获得锁时间
- 如果获得锁时间小于有效时间,且获得锁的redis个数在一半以上,那么获得锁成功。
- 锁真正有效时间为最初设置的有效时间-获得锁时间-时钟漂移。
- 如果获得锁失败,要释放所有节点(并不是加锁成功的节点,而是所有节点,因为有的获得锁失败可能是加锁成功,ask时失败了)。
存在的问题即解决
- Redis锁同步问题(即系统时间不同步)
例:
假设我们有 A、B、C、D、E 五个Redis节点
客户端1 从 A、B、C、D、E五个节点中,获取了 A、B、C三个节点获取到锁,我们认为他持有了锁。
节点B的时间走的比A、C快, 这时B会先于A、C释放锁
客户端2 可以从 B、D、E三个节点获取到锁。
在整个分布式系统就造成 两个 客户端 同时持有锁了。
但是这个不同步的事件对于有效时间来说是非常小的。所以有效时间要减去时钟漂移,保证不会出现一个节点先走完。
这个算法成立的一个条件是:即使集群中没有同步时钟,各个进程的时间流逝速度也要大体一致,并且误差与锁存活时间相比是比较小的。实际应用中的计算机也能满足这个条件:各个计算机中间有几毫秒的时钟漂移(clock drift)。
即使时钟不同,差别也不是很大。
2. 失败重试
当一个客户端获取锁失败时,这个客户端应该在一个随机延时后进行重试,之所以采用随机延时是为了避免不同客户端同时重试导致谁都无法拿到锁的情况出现。同样的道理客户端越快尝试在大多数Redis节点获取锁,出现多个客户端同时竞争锁和重试的时间窗口越小,可能性就越低,所以最完美的情况下,客户端应该用多路传输的方式同时向所有Redis节点发送SET命令。 这里非常有必要强调一下客户端如果没有在多数节点获取到锁,一定要尽快释放所有节点,这样就没必要等到key超时后才能重新获取这个锁(但是如果网络分区的情况发生而且客户端无法连接到Redis节点时,会损失等待key超时这段时间的系统可用性)
3. 重启问题
如果线程A获得123(一共12345个)个节点的锁,当3挂掉了,然后立即重启,那么线程B可以获得345的锁,那么就出现重复获得锁的问题。
解决:超时重启,重启时在TTL时间后在重启。