使用Redis实现分布式锁(一)

使用Redis实现分布式锁

参考自:https://redis.io/topics/distlock

分布式锁在多进程共享资源的情况下是很常见的控制并发的工具。

本文描述一种称为RedLock的算法,它在实现上,比普通的单实例Redis锁更安全。

实现

安全和活性保证

从三个方面来对设计进行建模,这三个方面是分布式锁所需的最低要求

  1. 安全特性:互斥。在任何给定时刻,只有一个客户端可以持有锁
  2. 活性A:无死锁。即使获得锁的客户端崩溃了或发生网络分区,其他客户端也可以获得锁
  3. 活性B:容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁

为什么基于主从复制模式的Redis部署会有问题

使用Redis锁的最简单方法是在Redis实例中创建key。key使用Redis过期特性,在创建key时带上生存时间,这样最终锁会被释放(为了满足上述的活性A)。当客户端需要释放锁时,删除key。

但是,这个方式存在一个问题:架构中的单点故障。如果Redis服务器宕机了怎么办?简单的做法,我们加一个slave节点。如果master节点不可用了,slave节点顶替上。但,这个方案其实并不可行。因为Redis的主从复制是异步的,无法保证上述的安全特性。例如如下场景:

  1. 客户端A从主服务器中获取锁
  2. 在将key写入到slave节点之前,master宕机了
  3. slave节点晋升为master节点
  4. 客户端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实现需要运行在不同的计算机上,以确保相互之间的独立。

为了获得锁,客户端执行下方操作:

  1. 以毫秒为单位获取当前时间
  2. 在所有的N个实例中顺序的使用相同的key和value(随机值)获取锁。在每个实例中获取锁时,设置一个获取锁的超时时间,这个超时时间应该小于过期时间(假设过期时间为TTL)。例如,如果过期时间为10秒,超时时间可以设置为5到50毫秒之间(防止客户端长时间与处于故障状态的Redis通讯,一旦某个实例无法获取锁,立即对下一个实例获取锁)
  3. 所有实例获取锁结束之后(不一定获取成功),把当前时间减去步骤1中获得的时间,这个时间是获取锁所花费的时间。如果客户端能从大多数实例获得锁(至少N/2+1个实例,如本例中至少为3个),且获取锁所花费的时间小于TTL,则认为获得了锁。
  4. 如果获得了锁,则有效时间=TTL-获取锁所花费的时间
  5. 如果客户端未能获取锁(获得锁的实例小于N/2+1,或者有效时间为负数),则它需要解锁所有的实例(哪怕是那些它没能获得到锁的实例)

算法是非同步的么

RedLock算法基于一个假设,即哪怕各个实例间没有同步时钟,但每个实例的本地时间仍以几乎相同的速率往前流动,并且与TTL相比,时钟漂移(CLOCK_DRIFT)的误差很小。这与实际计算机世界的现象类似,每台计算机都有自己的本地时钟,通常可以认为不同的计算机产生的时钟漂移很小。

如果要更好描述获得锁的有效时间,则应该在上方步骤4获得的有效时间上,再减去一段时间(几毫秒,用于时钟漂移补偿)

失败后的重试

当客户端无法获取锁时,它应随机延迟一定时间后再重试,以使得获取同一锁的多个客户端分散开(如果不延迟,那很可能会导致脑裂,每次都没有客户端真正获得锁)。客户端在各个Redis实例中获取锁的速度越快,出现裂脑情况(以及需要重试)的时间窗口就越小,因此最好的做法是客户端使用并发同时将SET命令发送到N个实例。

另外,对于未能获得大多数锁的客户端,应尽快释放(部分)获得的锁,这样就不必等待key过期后,这些实例上的锁才能再次被获取

释放锁

释放锁很简单,只需在所有实例中释放锁,无论客户端是否是否获得该实例上的锁

安全争论

这个争论的要点在于是否会出现多个客户端同时拿到锁

  1. 我们先假设客户端能够在大多数实例中获取锁,并且锁的有效时间不为负数。所有获得锁的实例都将包含一个具有相同生存时间的key。但是,key在不同的实例中是在不同的时间设置的,因此key也会在不同的时间失效。假设T1为第一台实例设置key之前的时间,而T2为从最后一台服务器获得答复的时间,则至少存在MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT,在这个时间内,其他客户端是无法获取锁的。因为其他客户端在这个时间内没法拿到大于N/2+1个实例的锁,所以这个时间内不会出现多个客户端同时拿到锁
  2. 先假设客户端能够在大多数实例中获取锁,并且锁的有效时间为负数。这种情况下,当前客户端也会认为自己是获取锁失败,所以不会出现多个客户端同时拿到锁
  3. 假设客户端没能够在大多数实例中获取锁,那这种情况和2类似,也不会出现多个客户端同时拿到锁

活性争论

  1. key会过期,会自动释放锁,所以不会存在死锁
  2. 客户通常会在未获得锁或获得锁且任务完成时删除锁,这样则不必等待key过期就可以重新获得锁
  3. 客户端获取锁失败后的重试等待时间,设置为稍大于正常情况下获取大多数实例的锁的时间,这样脑裂的情况发生的概率更小
  4. 当客户端获得锁后,发生网络分区,客户端完成任务后无法删除获得的锁,那就得等TTL时间,锁自动释放了

性能,崩溃恢复和fsync

性能:如果要减少延迟和增加每秒能执行的获取/释放锁操作,那就使用并发吧,同时向各个实例发送获取锁命令。

崩溃恢复:如果要设计崩溃恢复系统模型,需要考虑持久化。

例如,如果没有配置持久化,当客户端A在5个实例有3个实例获取锁了,然后3个实例中的其中一个发生重启,那这时候客户端B又能从这个实例和其余两个实例获得锁,这时候就发生冲突了。

如果使用AOF持久化,当实例重启后,锁还是继续存在实例中的,并且过期时间还是其原本的过期时间,那这时候不会发生上面的问题。只是,如果情况是断电重启,而我们配置的持久化策略是everysec,则重启后,还是会丢失key的。我们可以配置持久化策略为always,这样理论上任何类型的重启都能保证锁的安全性,但这样性能会很差。

实际上,如果延迟重启实例,即等待一个当前所有锁都自动被释放的时间再启动实例,则不需要持久化,也能保证重启后,不会发生锁的重复获取冲突。只是,如果延迟重启,则可能导致服务不可用(比如大多数实例都崩溃了,剩下的实例不大于N/2+1,则没有一个客户端能成功获得到锁了)

使得算法更可靠:续约

客户端获得到锁后,在执行任务时,如果任务分为多个小的步骤,那每个步骤判断当前锁快过期了,则再通过Lua脚本,发送命令给所有实例,进行锁续约。续约的条件为Key存在,且value还是当前客户端前一次获取锁时设置的随机值。判断是否续约成功的条件和获取锁成功的条件类似。在这种情况下,要记得限制最大续约次数,否则其他客户端可能很久都无法获取到锁了

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值