使用Redis实现分布式锁
参考自:https://redis.io/topics/distlock
分布式锁在多进程共享资源的情况下是很常见的控制并发的工具。
本文描述一种称为RedLock的算法,它在实现上,比普通的单实例Redis锁更安全。
实现
- Redlock-rb(Ruby实现)
- Redlock-py(Python实现)
- Aioredlock(Asyncio Python实现)
- Redlock-php(PHP实现)
- PHPRedisMutex(进一步的PHP实现)
- cheprasov / php-redis-lock(用于锁的PHP库)
- Redsync(Go实现)
- Redisson(Java实现)
- Redis :: DistLock(Perl实现)
- Redlock-cpp(C ++实现)
- Redlock-cs(C# .NET实现)
- RedLock.net(C# .NET实现)包括异步和锁定扩展支持
- ScarletLock(带有可配置数据存储的C#.NET实现)
- Redlock4Net(C# .NET实现)
- node-redlock(NodeJS)实现)包括对锁扩展的支持
安全和活性保证
从三个方面来对设计进行建模,这三个方面是分布式锁所需的最低要求
- 安全特性:互斥。在任何给定时刻,只有一个客户端可以持有锁
- 活性A:无死锁。即使获得锁的客户端崩溃了或发生网络分区,其他客户端也可以获得锁
- 活性B:容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁
为什么基于主从复制模式的Redis部署会有问题
使用Redis锁的最简单方法是在Redis实例中创建key。key使用Redis过期特性,在创建key时带上生存时间,这样最终锁会被释放(为了满足上述的活性A)。当客户端需要释放锁时,删除key。
但是,这个方式存在一个问题:架构中的单点故障。如果Redis服务器宕机了怎么办?简单的做法,我们加一个slave节点。如果master节点不可用了,slave节点顶替上。但,这个方案其实并不可行。因为Redis的主从复制是异步的,无法保证上述的安全特性。例如如下场景:
- 客户端A从主服务器中获取锁
- 在将key写入到slave节点之前,master宕机了
- slave节点晋升为master节点
- 客户端B获得被A锁定的相同资源的锁
这样,锁的互斥条件就无法保证了。当然,在业务的一致性要求不是特别高的场景下,这种方式是一种简单的解决方案。
Redis单实例的实现
在描述RedLock之前,我们先看下Redis单实例下的实现。这也是RedLock算法的基础。
获取锁,执行下面语句
SET resource_name my_random_value NX PX 30000
该命令仅在key不存在时才设置(NX选项),并且到期时间为30000毫秒(PX选项)。value设置为“my_random_value”随机值。这个值在所有客户端请求中必须唯一。
释放锁时以Lua脚本的方式,仅且仅当key存在并且存储的value是当前客户端设置的随机值,才删除该key。通过以下Lua脚本完成
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
存储的value是当前客户端生成的唯一值,这点很重要。假设客户端获得锁之后,处理了很长时间,而这段时间内锁已经过期被自动释放了,而另外的客户端已经获得相同key的新的锁,如果不先判断value再做删除,而是直接删除,就会导致删除了别的客户端获得的锁,从而导致锁互斥的问题。
随机值,可以参考/dev/urandom。或者更轻便的方法是拼接时间+客户端ID,虽然不是很安全,但大多数情况下是可以满足了的。UUID其实也可以用
RedLock算法
如上面所说,单实例的Redis始终存在单点故障问题。RedLock算法是基于集群模式部署的Redis实现分布式锁。假设有N个Redis服务器,这里我们直接假设N为5,这5个Redis实现需要运行在不同的计算机上,以确保相互之间的独立。
为了获得锁,客户端执行下方操作:
- 以毫秒为单位获取当前时间
- 在所有的N个实例中顺序的使用相同的key和value(随机值)获取锁。在每个实例中获取锁时,设置一个获取锁的超时时间,这个超时时间应该小于过期时间(假设过期时间为TTL)。例如,如果过期时间为10秒,超时时间可以设置为5到50毫秒之间(防止客户端长时间与处于故障状态的Redis通讯,一旦某个实例无法获取锁,立即对下一个实例获取锁)
- 所有实例获取锁结束之后(不一定获取成功),把当前时间减去步骤1中获得的时间,这个时间是获取锁所花费的时间。如果客户端能从大多数实例获得锁(至少N/2+1个实例,如本例中至少为3个),且获取锁所花费的时间小于TTL,则认为获得了锁。
- 如果获得了锁,则有效时间=TTL-获取锁所花费的时间
- 如果客户端未能获取锁(获得锁的实例小于N/2+1,或者有效时间为负数),则它需要解锁所有的实例(哪怕是那些它没能获得到锁的实例)
算法是非同步的么
RedLock算法基于一个假设,即哪怕各个实例间没有同步时钟,但每个实例的本地时间仍以几乎相同的速率往前流动,并且与TTL相比,时钟漂移(CLOCK_DRIFT)的误差很小。这与实际计算机世界的现象类似,每台计算机都有自己的本地时钟,通常可以认为不同的计算机产生的时钟漂移很小。
如果要更好描述获得锁的有效时间,则应该在上方步骤4获得的有效时间上,再减去一段时间(几毫秒,用于时钟漂移补偿)
失败后的重试
当客户端无法获取锁时,它应随机延迟一定时间后再重试,以使得获取同一锁的多个客户端分散开(如果不延迟,那很可能会导致脑裂,每次都没有客户端真正获得锁)。客户端在各个Redis实例中获取锁的速度越快,出现裂脑情况(以及需要重试)的时间窗口就越小,因此最好的做法是客户端使用并发同时将SET命令发送到N个实例。
另外,对于未能获得大多数锁的客户端,应尽快释放(部分)获得的锁,这样就不必等待key过期后,这些实例上的锁才能再次被获取
释放锁
释放锁很简单,只需在所有实例中释放锁,无论客户端是否是否获得该实例上的锁
安全争论
这个争论的要点在于是否会出现多个客户端同时拿到锁
- 我们先假设客户端能够在大多数实例中获取锁,并且锁的有效时间不为负数。所有获得锁的实例都将包含一个具有相同生存时间的key。但是,key在不同的实例中是在不同的时间设置的,因此key也会在不同的时间失效。假设T1为第一台实例设置key之前的时间,而T2为从最后一台服务器获得答复的时间,则至少存在
MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
,在这个时间内,其他客户端是无法获取锁的。因为其他客户端在这个时间内没法拿到大于N/2+1个实例的锁,所以这个时间内不会出现多个客户端同时拿到锁 - 先假设客户端能够在大多数实例中获取锁,并且锁的有效时间为负数。这种情况下,当前客户端也会认为自己是获取锁失败,所以不会出现多个客户端同时拿到锁
- 假设客户端没能够在大多数实例中获取锁,那这种情况和2类似,也不会出现多个客户端同时拿到锁
活性争论
- key会过期,会自动释放锁,所以不会存在死锁
- 客户通常会在未获得锁或获得锁且任务完成时删除锁,这样则不必等待key过期就可以重新获得锁
- 客户端获取锁失败后的重试等待时间,设置为稍大于正常情况下获取大多数实例的锁的时间,这样脑裂的情况发生的概率更小
- 当客户端获得锁后,发生网络分区,客户端完成任务后无法删除获得的锁,那就得等TTL时间,锁自动释放了
性能,崩溃恢复和fsync
性能:如果要减少延迟和增加每秒能执行的获取/释放锁操作,那就使用并发吧,同时向各个实例发送获取锁命令。
崩溃恢复:如果要设计崩溃恢复系统模型,需要考虑持久化。
例如,如果没有配置持久化,当客户端A在5个实例有3个实例获取锁了,然后3个实例中的其中一个发生重启,那这时候客户端B又能从这个实例和其余两个实例获得锁,这时候就发生冲突了。
如果使用AOF持久化,当实例重启后,锁还是继续存在实例中的,并且过期时间还是其原本的过期时间,那这时候不会发生上面的问题。只是,如果情况是断电重启,而我们配置的持久化策略是everysec,则重启后,还是会丢失key的。我们可以配置持久化策略为always,这样理论上任何类型的重启都能保证锁的安全性,但这样性能会很差。
实际上,如果延迟重启实例,即等待一个当前所有锁都自动被释放的时间再启动实例,则不需要持久化,也能保证重启后,不会发生锁的重复获取冲突。只是,如果延迟重启,则可能导致服务不可用(比如大多数实例都崩溃了,剩下的实例不大于N/2+1,则没有一个客户端能成功获得到锁了)
使得算法更可靠:续约
客户端获得到锁后,在执行任务时,如果任务分为多个小的步骤,那每个步骤判断当前锁快过期了,则再通过Lua脚本,发送命令给所有实例,进行锁续约。续约的条件为Key存在,且value还是当前客户端前一次获取锁时设置的随机值。判断是否续约成功的条件和获取锁成功的条件类似。在这种情况下,要记得限制最大续约次数,否则其他客户端可能很久都无法获取到锁了