基于Redis实现的分布式锁是一个常用的组件,我们通过Redis官网的文章来回顾下它是如何实现的。
讨论技术实现细节之前,我们应该考虑下分布式锁应该具备哪些特性,提供哪些功能。
Safety and Liveness是分布式系统的算法和设计中两个非常重要的属性,我们用这两个属性对分布式锁做个定义。
1.Safety property。
分布式锁作为一个“锁”,所谓安全特性我认为其实也是基本功能,即同一时刻,只能有一个客户端可以持有锁(这里是指对同一资源)。
2.Liveness property A。
不会发生死锁。即使一个持有锁的实例挂了或者是发生了分区不一致,也不会死锁。
3.Liveness property B。
错误容忍。只要大多数(超过半数)的节点获取到了锁,就算是持有了锁。
单机情况下,如果Master节点失败了,我们通过FailOver的实现将Master转移到一个Slave节点,这样的方式为什么还不够呢?
可以对市面上常见的Redis分布式锁做个分析,最简单的Redis锁的实现方式是在一个Redis实例中创建一个Key,这个Key有一个过期时间(通过expire命令实现),所以最终这个锁会被释放(LivenessPropertyA)。如果客户端需要释放资源,则删除这个Key。
表面上看这种模式可以很好的工作,但是它隐藏了一个问题:这个架构的单点问题。如果Redis的Master节点挂了怎么办?OK我们可以增加一个Slave,然后当主节点挂了启用Slave节点。但是这个方案是不可行的,如果这样我们无法实现SafetyProperty,因为Redis的主从复制是异步的。
这个模式会出现如下问题:
如果说你可以接受有些特定场景多个客户端能持有一把锁,那可以使用这种基于FailOver的单节点方案,否则还是需要本文中后面的解决方案。
单机场景下的实现逻辑比较简单,用Redis命令呈现如下:
加锁:SETNX给一个Key设置一个UniqueId,SETNX的语义是“SET if Not Exist”,SETEX是给一个Key设置一个时间,Redis提供了一个命令来完成这个操作,SET resource_name my_random_value NX PX 30000
(EX和PX只是时间单位上的差异,EX单位是秒,PX单位是毫秒)。注意这里的UniqueId要能够保证全局多个请求之间唯一。如果SET成功则视为加锁成功,否则加锁失败。
解锁:用GET命令查看Key对应的Value,如果等于UniqueId,则DEL该Key。需要说明的是,UniqueId的设置,就是为了防止不同的客户端错误地释放了别人的锁,因此客户端A有自己的UniqueId,删除前要比对这个UniqueId是否正确,比对成功才可以删除。要保证UniqueId全局唯一,可以考虑用时间戳+客户端ID的方式来作为UniqueId(分布式ID生成是另一个话题,这里不展开)。这里需要注意,GET查看和DEL这两步操作必须要是一个原子操作,否则极限场景依然会出现异常情况,即客户端A通过GET拿到Key的Value并比对成功,在这个过程中Key到了过期时间,客户端B获得了Key的锁,这是A去执行DEL相当于把B持有的锁给释放了。通过Lua脚本可以解决两步操作原子性的问题。如下
加锁
if redis.call('setnx',KEYS[1]) == ARGV[1]
then
redis.call('expire',ARGV[2])
return 1
else
return 0
end
解锁
if redis.call('get',KEYS[1]) == ARGV[1])
then
return redis.call('del',KEYS[1])
else
return 0
end
分布式版本的算法我们假设有N个Redis的Master节点,每一个都是独立的,我们不引入副本或者其他的协作工具。举一个例子N=5,因此我们将5个Redis示例运行在5台电脑或者虚拟机上。
为了获取锁,客户端要执行如下操作:
这个算法是异步的吗
?上述算法基于一个假设,即每个Redis节点没有同步时钟机制,每个机器上的时间流逝的速度是一样的,这个假设和实际场景中的PC是类似的,每个PC都有一个本地时钟,不同电脑上的时钟可能存在时钟漂移的问题。基于这个假设我们需要更好的规范SafetyProperty,即分布式锁要保证只有持有锁的客户端会在锁的生效时间内释放锁,这个生效时间是上面的步骤3中的时间减去一些细微的时钟漂移的时间差。
失败重试
。如果获取锁失败,重试时客户端要对每个Redis节点间隔随机的时间,防止多个节点同时尝试获取锁(容易造成脑裂现象),同样获取到超过半数的锁的速度越快,脑裂的概率越低,所以情况下客户端应该多线程地向N个节点申请锁。同样对于获取锁失败的Client,尽快释放部分获取的锁也是很重要的,没必要等到锁超时才释放(除非出现了网络问题)。
释放锁
。非常简单,不管有没有加锁成功,尝试用单机版的方式释放锁即可。
SafetyProperty
。这个算法是安全的吗?假设第一个实例获取锁在T1时刻,最后一个锁获取的时间是T2时刻,对每个节点设置的锁过期时间是TTL,那么这个锁的最差情况的持续时间是LockTime = TTL-(T2-T1)-CLOCK_DRIFT。如果说N/2+1个实例已经加锁成功了,那么另一个客户端的SETNX命令不可能在超过半数的机器上成功了,这是显而易见的。当LockTime<0,那么这个客户端加的锁会因为超过过期时间而被失效,这种场景下会存在多个客户端都加锁成功的情况。
LivenessProperty
。1.因为锁都有过期时间,因此最终任何锁会被释放,不会出现死锁。2.没加到锁会释放锁,所以多数场景不需要等到Key过期就会被释放。3.如果一个锁要重试,那么起码要等待一个大于最小加锁时间(LockTime)的时间间隔,防止脑裂。
性能考虑
。大多数用户使用Redis作为锁的工具是想用到它的高吞吐量和低延迟,为了满足这点需求,客户端和多个Server通信最好使用并行的方式(即用NIO的方式和Server交互)。当然还要考虑一个场景,假如ClientA获取了3个机器上的锁,这时候其中一个重启了,然后ClientB对相同资源可以锁住剩下两台和重启的这台机器,这会打破Safety保证。如果我们能够开启AOF持久化,那么这个问题可以在某种程度上避免,即机器重启是一次正常重启,那么还没有存盘的信息也会被fsync到磁盘之后再重启,但是如果是断电的场景,就会丢失一部分内存的数据,我们的Key可能也会丢失。如果我们为了解决这个问题把fsync设置为always,那么性能又会大打折扣(直接把Redis锁降级成CP类型的分布式系统,比如说ZK)。如果说我们保证了在机器重启之后,它不参与当前活跃的锁的加锁操作,这段时间都是其他的机器在参与加锁,那么就可以保证Safety约束了。为了实现这一点,可以在机器重启后,让机器暂停服务一段时间,这个时间略大于前面我们用到的锁有效时间TTL。使用延迟重启的方法可以基本及解决Safety问题,但是这个会引入不可用的问题。比如说大多数的机器都重启了,那么在TTL的时间内没有客户端可以加锁成功。
锁续约
。如果客户端加锁是由小的步骤组成,那么可以考虑用相对较小的加锁时间,然后扩展算法的实现支持给锁续约。如果说客户端正在锁有效期内,可以通过向所有服务器发送Lua脚本增加Key的过期时间并继续维持同样的Value。客户端仅需要考虑对超过半数的实例续约成功而且执行任务在锁的有效期内。尽管续约并不改变基本的算法实现,但是最大续约次数要进行限制,否则LivenessProperty就会被破坏(w无限续约等于没有过期时间,可能死锁)。
本文的理论比较多,基本上是把Redis官网的文章复述了一遍,通过翻译也加深了对Redis分布式锁的理解。分布式场景实现是在单机的基础之上,还要兼顾高吞吐量低延迟,同时要尽最大可能保证可靠性。文末还有两个大神的分析文章,其中第一篇是Martin Kleppmann(数据密集型应用的作者)的分析,第二篇是另一个大神的反对观点,有空可以围观下神仙打架的battle。
这篇是原理分析,下篇会贴几种实现的代码,包括经典的lua脚本实现,redisTemplate实现,以及Redission的源码,后面还会有可重入的分布式锁的实践,敬请期待…
https://redis.io/topics/distlock
https://lrita.github.io/2018/10/23/safety-and-liveness-in-distributed/
http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
http://antirez.com/news/101