在并发场景下,分布式锁有着非同一般的作用
当然有很多博客讨论基于Redis实现分布式锁管理,但是方法多种多样,且有些设计简单但难以保证性能,有些则设计复杂。
本篇文章的目的在于提供更加合理的一种“基于redis分布式锁的设计”。我们提供了一种称之为Redlock的设计方案,相信会更加可靠。我们希望社区可以对此进行讨论和反馈,也希望将其应用到更加复杂的场景中。
当前一些实现方案
在讨论我们的放案前,这里有些已有的实现作为参考
- Redlock-rb (Ruby implementation).
- Redlock-py (Python implementation).
- Redlock-php (PHP implementation).
- Redsync.go (Go implementation).
- Redisson (Java implementation).
- Redis::DistLock (Perl implementation).
- Redlock-cpp (Cpp implementation).
- Redlock-cs (C#/.NET implementation).
1.安全性:相互排斥,任何情况只有一个客户端获得锁。
2.可用性A:死锁释放。即使持有锁的客户端crash,锁也会释放。
3.可用性B:容错性。只要多数节点可用,锁管理就可用。
为什么故障转移的方式实现是不足的
为了更好的理解我们要改进的内容,我们先分析当前“基于redis的分布式锁”的状况。
通过redis实现资源锁,一个简单的方式是通过一个节点创建一个key。这个key要有过期时间,保证无论如何锁可以得到释放。当客户端主动释放只需要删除这个key。
表面看上面的方式满足要求,但是有个潜在问题:这里只是单点故障。如果master宕机了会怎样呢?好,我们先加一个副几点。如果主节点不可用,则用副节点。但是发现不可用。以为redis的主从复制是异步的,所以无法保证互斥性。
这是如上问题的一种发生场景:
1.客户端A 在master获取锁。
2.在master复制数据到slave前,master节点宕机了。
3.slave节点成为master节点
4.客户端B尝试去锁客户端A已锁的资源。问题来了,可以获取锁。
单节点下的设计实现
在改善如上讨论的潜在问题前,我们先分析下如何如何改善这个简单的单节点case,应为单节点也是一个可行的放案。同时单节点也是分布式场景的基础。
获取锁色指令如下:
SET resource_name my_random_value NX PX 30000
key只有在之前不存在时(NX PX) 执行成功,这里过期时间是30000,key是"resource_name", 对于所有lock的申请value 都要求是唯一的。
一般为了更安全的方式释放锁,选择随机数作为value。通过脚本告诉redis:如果key存在且value是我们期望的值则删除。
如下的Lua脚本可以完成这个工作:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这是避免删除“其它客户端的锁”所必须的一部。比如一个客户端获取锁,但是由于操作潮湿,导致锁过期释放。同时其它客户端再次获取了这个锁,所以仅仅删除不做value比较是有删除其它客户端锁的风险的。通过如上的脚本,每个lock绑定一个随机值。删除时,同时对值做比较,可以保证只能删除自己当时所获取的锁。
随机值应该如何设计呢?我假定它是来源于 /dev/urandom 的20个字节。当然大家可以选择更有性价比的方式产生满足工程的唯一值,比如以/dev/urandom 为 salt借助RC4生成。一个更加简单的方式是组合精确到微妙的时间和客户端id。虽然它并不安全,但是足以满足对数系统的需求。
我们设置的锁的存活时间,称为"lock validity time(锁的有效时间)",它是自动释放的时间也是,也是客户端占资源的时间拥有互斥保护的时间窗口。
目前为止,我们有了一个不错的方式获取和释放锁。这个系统在单机模式是可以保证可用和安全。我们将把这扩展到没有这些优秀特性的分布式系统。
Redlock算法
我们假设一个分布式系统拥有那个master节点。这些节点是独立的,不需要姓胡复制数据一类的协同系统。上面我们已经讨论了单机下如何安全的管理锁。在我们的例子中N=5,这几个是不同的电脑或虚拟机,以保证独立性。
为了获取锁,客户端进行如下的操作:
1.以微妙的精确度获取当前时间
2.尝试以相同的key和value,依次获取5个节点的锁。在这一步中,尝试获取锁的超时时间远远小于锁释放的时间,你如锁过期自动释放时间是10s,则超时时间可以在5-50毫秒范围。这可以避免上时间阻塞在尝试链接下线的主机:如果一个主机不可达,马上尝试链下一个。
3.客户端计算获取锁总共消耗的时间。可以通过减去第一步的时间戳获得。仅当客户端可以获取大多数节点的锁(至少3个),且消耗的总时间小于锁的有效时间(lock validity time),则任为获取到了锁。
4.如果获取了锁,锁的有效时间视为初始的有效时间(这里10s)减去第三部获取锁的消耗时间(第三步计算所得的时间)。
5.如果未获取锁(由于未获取N/2+1 个节点的锁或者失效时间是负的),则释放所有实例的锁(即使相信节点未锁)
算法是异步的?
该算法前提假定是各个进程没有同步统一的时钟,但各个进程以几乎是一致的,这个时差是很小的,和自动过期释放时间(lock validity time)误差可以忽略。这是与真实场景一致的:不同主机都有自己的时钟,我们依赖于时钟偏移差在很小的范围内。
基于这一观点我们重申下互斥的规则:除去很小的时钟偏移(这是补偿不同节点的时钟差),客户端只有在锁的有效时间(第三步获取的)内完成自己的工作,才能保证互斥。
需要了解更多时钟漂移的相似系统问题可以参考这个文章:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency.
失败重试
当获取锁失败时,在一个随机的时间后才会再次尝试获取,避免不同客户端同时竞争资源(这可能会导致"脑裂"问题)。客户端越快的获取到多数节点的锁,发生“脑裂”的窗口越小。所以理想的情况是同时发送SET指令到所有节点。
需要强调的是,当获取多数节点的锁失败时,立即释放所有获取的锁是是异常重要的,而不是等待锁自动过期(然而有时网络出现问题,客户端无法和节点联系,由于等待锁的过期。会降低系统系统的可用性)。
锁的释放
释放所很简单,这仅仅需要释放所有节点的锁,无论客户端是否真拥有锁(是否释放成功是另一个问题)。
安全性
这个算法是安全的?我们可以设想下不同的场景的运作情况。
首先我们设想客户端可以获取多数节点的锁。所有节点拥有一个存活时间相同的key。但是这个key是不同时间设置的,所以最后失效的时间不同。但是不恰巧的第一个key设置的时间是t1(这个时间我们假设早于在联系到第一个节点的时间)。最后一个key最坏的情况时间是t(从最后一个节点获得响应的时间)。我们可以获得一个确信的key最小的生存时长失败MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT
,所有另外的key都晚于这个时间过期,我们可以确认key过期时间最小要设置这个
值。
当一个客户端获取了多数节点的锁,其余的客户端不会再获取到锁。因为n/2+1的set nx操作不可能成功,当n/2+1的节点key已经存在(这违反相互排斥属性)。
然而我们也想确保多客户端并发时不会同时成功。
如果客户端获取了多数节点的锁,并耗时相近或者长于最大有效时间的时间,则将它视为无效的锁,并且释放拥有的锁。所以我们只要考虑客户端如何以远小于有效时间的时间获取多数节点的锁。这个场景下的参数正是上面锁描述的MIN_VALIDITY,这时间内其余的客户单不可以再次获取锁。所以多客户端只有在锁过期、无效之后可以再次获取锁。
你能找到针对这类算法安全性问题的证明或者bug么?如果可以真是值得赞赏的!
可用性
系统的可用性基于三个点:
1.锁自动释放:最后总是可用的,可被再次获取的。
2.事实上,当无法获取多数锁时,客户端将配合的移除锁。或者获取锁的客户端中断了,使它看着不是必须等待key的过期才能再次去获取。
3.当客户端尝试再次获取锁时,将等待比获取多数节点锁耗时更长的时间,降低脑裂问题的概率。