之前聊过Redis的分布式锁并且基于理论【Redis从入门到放弃系列 十】Redis的事务机制进行过代码实践【Redis从入门到放弃系列 十一】Redis分布式锁实战,但是其实只是对分布式锁的一个简单理解,对于其中可能的问题并没有过多讨论,甚至对于真实企业场景中的分布式锁,之前的那个也许只是个玩具,存在诸多问题。一个高可用的分布式锁应该满足如下几点要求:
- 互斥性:任意时刻只能有一个客户端拥有锁,不能被多个客户端获取
- 安全性:锁只能被持有该锁的客户端删除,不能被其它客户端删除
- 防死锁:获取锁的客户端因为某些原因而宕机,而未能释放锁,其它客户端也就无法获取该锁,需要有机制来避免该类问题的发生
- 高可用:当部分节点宕机,客户端仍能获取锁或者释放锁
之前的设计,通过setnx实际上只解决了互斥性的问题,其它的都不满足,那么基于问题如何再进行拓展设计呢?
单机状态下可能遇到的问题
首先从纯单机的角度去考虑下Redis现有设计的分布式锁不完善的一些地方。
解决SetNx的超时非原子操作问题【互斥性、防死锁】
之前的代码为了解决死锁的问题,使用了过期时间,代码如下:
/// <summary> ///给分组加分组锁 /// </summary> /// <returns></returns> public static bool GetGroupLock(string groupKey) { const int tenantId = TenantIdInRedis; try { using (var redis = new RedisNativeProviderV2(KeySpaceInRedis, tenantId)) { //redis中,如果返回true设置成功代表分组锁空闲,如果返回false设置失败表明队分组锁正在被持有 if (RedisSetNx(groupKey)) //如果为true,设置分组锁并设置该锁的过期时间 { RedisExpire(groupKey, 300);//设置过期时间为5分钟 return true; } } } catch (Exception ex) { //进行查询异常操作 Loggging.Error($"在redis 设置分组锁[{groupKey}]异常", ex); //抛出异常 } return false; }
但是可以看到setnx命令无法原子性的设置锁的自身过期时间,也就是说执行setnx命令时我们无法同时设置其过期时间,那么就会出现死锁,例如:客户端A刚执行完setnx,这时候客户端A挂掉了,没有完成给锁设置过期时间,此时就产生了死锁,所有的客户端再也无法获得该锁,这种情况一般采用Lua脚本来实现(因为Redis执行Lua脚本是原子性的),其实从 Redis 2.6.12 版本开始set命令完全可以替代setnx命令,我们看官网的set命令参数
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
- PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
- NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
- XX :只在键已经存在时,才对键进行设置操作。
例如:SET key value NX PX 30000 这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用)。那么我们用set命令带上EX或者PX、以及NX参数就满足了上面提到的互斥性(加锁)、死锁(自动过期)两个要求。
解决非本线程对锁的删除问题【安全性】
解锁实际就是删除缓存key,这段代码之前是这么写的,调用后就立即执行删除,没有判断现在的锁还是不是自己的锁了:
/// <summary> /// 给分组锁解锁 /// </summary> /// <returns></returns> public static bool GroupUnLock(ImportRequestDataModel model) { //申请成功标志 const int tenantId = TenantIdInRedis; var groupKey = ImportParallelismHelper.GetMessageGroupId(model.MetaObjName, model.TenantId); try { using (var redis = new RedisNativeProviderV2(KeySpaceInRedis, tenantId)) { return RedisDeleteKey(groupKey); //redis中,如果返回true设置成功代表原来不存在这样的分组,如果返回false设置失败表明原来存在这样的分组 } } catch (Exception ex) { //进行查询异常操作 Loggging.Error($"在redis 解除分组锁[{groupKey}]异常", ex); //抛出异常 } return false; }
如果客户端A拿到锁并设置了锁的过期时间为10S,但是由于某种原因客户端A执行时间超过了10S,此时锁自动过期,那么客户端B拿到了锁,然后客户端A此时正好执行完毕删除锁,但是此时删除的是客户端B加的锁,如何防止这种不安全的情况发生呢?有两种方案:
给锁自动续期,执行完任务再删除
我们可以让获得锁的线程开启一个守护线程,用来给自己的锁“续期”。例如设置的超时时间为10秒,则有如下的判断方法:
- 当过去了9S,客户端A还没执行完,守护线程会执行expire指令,把锁再“续期”10S,
- 守护线程从第9S开始执行,每9秒执行一次。
- 当客户端A执行完任务,会显式关掉守护线程。
如果客户端A忽然宕机,由于A线程和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续期,也就自动释放了。
设置锁的唯一标识【判断和删除原子操作】
可以在加锁的时候把set的value值设置成一个唯一标识,标识这个锁是谁加的锁,在删除锁的时候判断是不是自己加的那把锁,如果不是则不删除。例如加上自己的线程号作为唯一标识。当然这里会有一个新问题产生,判断是不是自己加的锁和释放锁是两个独立操作,不是原子性,所以我们需要使用Lua脚本执行判断和释放锁。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
解决Redis突然宕机线程剩余任务未执行完问题
其实在写代码的过程中有考虑过,如果线程在执行导入任务的过程中挂掉,下次起来的时候先去Redis中去获取下数据,如果能获取到继续执行:
try { data = ImportGroupQueue.GetLastTimeThreadUnDealData(queueIndex);//获取上次线程的导入数据 if (data != null) //如果data不为null { var groupKey = ImportParallelismHelper.GetMessageGroupId(data.MetaObjName, data.TenantId); while (true) { if (ImportGroupQueue.GetGroupLock(groupKey)) //如果可以获取到该分组的锁则处理残留数据 { break; } System.Threading.Thread.Sleep(800); //先设置,每次处理完之后,休眠800毫秒,保证不过于频繁的请求redis造成redis压力 } _loggging.Debug("线程" + ImportGroupQueue.ThreadRedisKeyPrefix + queueIndex + "持有分组锁:" + groupKey + "该分组锁的实体编码为:" + data.AppName + "租户id为:" + data.TenantId); _loggging.Info("处理上次退出时留在redis中的数据。线程:" + queueIndex + " data:" + Common.Serialize.SerializeHelper.Serialize(data)); ImportData(data, queueIndex); ImportGroupQueue.ClearCurrentDataModelInRedis(queueIndex); ImportGroupQueue.GroupUnLock(data); //参数数据处理完毕后,释放该锁 _loggging.Debug("线程" + ImportGroupQueue.ThreadRedisKeyPrefix + queueIndex + "释放了分组锁:" + groupKey + "该分组锁的实体编码为:" + data.AppName + "租户id为:" + data.TenantId); } } catch (Exception ex) { _loggging.Error("处理上次退出时留在redis中的数据发生异常:线程:" + queueIndex + " data:" + Common.Serialize.SerializeHelper.Serialize(data), ex); }
但是这样也有一个问题,万一Redis也挂掉怎么办呢?没关系这个时候可以使用Redis的灾备机制,使用持久化策略恢复数据。具体可以参照:【Redis从入门到放弃系列 九】Redis持久化策略
集群状态下可能遇到的问题
然后再从纯集群的状态下去考虑可能遇到的问题,当然单机遇到的问题集群一定都会遇到,只是场景更为复合。在大型的应用中,一般Redis服务都是集群形式,主从模式下:
- master挂掉后,slave选举上来成为master时容易出现问题,redis在进行主从复制时是异步完成的,比如在clientA获取锁后,主redis崩溃了,数据还没有复制到从redis中,
- 从redis选举出一个升级为主redis,造成新的主redis没有clientA 设置的锁,这时clientB尝试获取锁,并且能够成功获取锁,导致互斥失效。
这个时候就只能使用终极大招,Redis之父创建的Redlock算法了,有两个前置概念
- TTL:Time To Live;只 redis key 的过期时间或有效生存时间
- clock drift:时钟漂移;指两个电脑间时间流速基本相同的情况下,两个电脑(或两个进程间)时间的差值;如果电脑距离过远会造成时钟漂移值 过大
时钟漂移相对于TTL来说要小的多。
RedLock算法
在分布式版本的算法里我们假设我们有N个Redis Master节点,这些节点都是完全独立的。不使用任何复制或者其他隐含的分布式协调算法(如果采用的是Redis Cluster集群此方案可能不适用,因为Redis Cluster是按哈希槽 (hash slot)的方式来分配到不同节点上的,明显存在分布式协调算法)。
我们把N设成5,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
- 获取时间:5台机器同时获取当前系统时间(单位是毫秒)
- 轮流请求锁:轮流用相同的key和随机值(客户端的唯一标识)在5个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
- 计算获取锁时间:客户端计算上一步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
- 锁持有时间:如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间【例如获取花了3秒,锁释放为10秒,则不超过,并且实际使用时间为7秒,实际应该再减去时钟漂移,但可忽略不计】。
- 释放锁:如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。由于释放锁时会判断这个锁的value是不是自己设置的,如果是才删除,所以在释放锁时非常简单,只要向所有实例都发出释放锁的命令,不用考虑能否成功释放锁【也解决了判定是否是自己加的锁防止误删的问题】
以上就是整个RedLock的算法流程。
RedLock注意事项
有以下几个概念点需要厘清,关于RedLock的注意点:
- RedLock可以看成是同步算法:因为 即使进程间(多个电脑间)没有同步时钟,但是每个进程时间流速大致相同;并且时钟漂移相对于TTL小,可以忽略,所以可以看成同步算法
- RedLock失败重试机制:当client不能获取锁时,应该在随机时间后重试获取锁;并且最好在同一时刻并发的把set命令发送给所有redis实例;而且对于已经获取锁的client在完成任务后要及时释放锁,这是为了节省时间;
- 各节点Redis需要有持久化机制:如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性; 如果启动AOF永久化存储,重启redis后由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒1次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以锁完全有效性和性能方面要有所取舍;
- 锁的有效获取时间计算方式:先假设client获取所有实例,所有实例包含相同的key和过期时间(TTL) ,但每个实例set命令时间不同导致不能同时过期,第一个set命令之前是T1,最后一个set命令后为T2,则此client有效获取锁的最小时间为TTL-(T2-T1)-时钟漂移;
虽然说RedLock算法可以解决单点Redis分布式锁的高可用问题,但如果集群中有节点发生崩溃重启,还是会出现锁的安全性问题
RedLock的极致有效
假设一共有A, B, C, D, E,5个Redis节点,设想发生了如下的事件序列:
- 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)
- 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了
- 节点C重启后,客户端2锁住了C, D, E,获取锁成功
这样,客户端1和客户端2同时获得了锁(针对同一资源)。针对这样场景,解决方式也很简单, 断电后等待TTL后重启:即使断电情况也能有效保证锁完全有效性及性能高效:redis同步到磁盘方式保持默认每秒,在redis无论停掉后要等待TTL时间后再重启【这种情况下相当于客户端1放弃了自己的锁,客户端2设置后唯一,仍然互斥有效】(延迟重启) ,缺点是 TTL时间内服务相当于暂停状态
目前RedLock也有了较为广泛的应用,各种语言都有该算法的开源实现方式:
- Redlock-py (Python 实现):https://github.com/SPSCommerce/redlock-py
- Redsync.go (Go 实现):https://github.com/hjr265/redsync.go
- Redisson (Java 实现):https://github.com/redisson/redisson
- Redlock-cs (C#/.NET 实现):https://github.com/kidfashion/redlock-cs
以上就是整个关于Redis分布式锁的深入和一些改进策略
