Home

cuipf blog

08 Mar 2018

基于redis实现分布式锁

redis实现分布式锁

基于单Redis节点的分布式锁

操作

1. 客户端发送命令获取锁

SET resource_name random_value NX PX 30000

上面的命令如果执行成功,则客户端成功获取到了锁,接下来就可以访问共享资源了;而如果上面的命令执行失败,则说明获取锁失败。

命令说明:

  • random_value是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。
  • NX表示只有当resource_name对应的key值不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • PX 30000 表示这个锁有一个30秒的自动过期时间; 30s可以根据需求更改;

2. 客户端操作共享资源

3. 释放锁 可以由下面脚本完成:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

KEYS[1]就是resource_name; 先比较value是否相等, 如果相等就删除;

注意

  1. 这个锁必须要设置一个过期时间;

    如果未设置过期时间, 那么, 当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了; 也可以把这个时间称为锁的有效时间, 获得锁的客户端必须在这个时间之内完成对共享资源的访问。

  2. 上面的步骤1也可以使用下面的命令完成, 但一定要注意保证原子性;
multi
SETNX resource_name random_value
EXPIRE resource_name 30
exec
  1. 保证释放锁, 必须是自己持有的;

    所以上面现获取random_value做比较是很有必要的; 而且随机字符串也是必要的;

  2. 释放锁的时候, 用lua脚本可以保证”获取”,”比较”,”Del”这三步的原子性;

    如果没有保证释放锁的时候的原子性, 容易出现以下情况:

    • 客户端1获取锁成功。
    • 客户端1访问共享资源。
    • 客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
    • 客户端1判断随机字符串的值,与预期的值相等。
    • 客户端1由于某个原因阻塞住了很长时间。
    • 过期时间到了,锁自动释放了。
    • 客户端2获取到了对应同一个资源的锁。
    • 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

问题

前面的四个注意,只要实现分布式锁的时候加以注意,就都能够被正确处理。但除此之外,还有一个问题,是由failover引起的,却是基于单Redis节点的分布式锁无法解决的。正是这个问题催生了Redlock的出现。

这个问题是这样的。假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。考虑下面的执行序列:

  • 客户端1从Master获取了锁。
  • Master宕机了,存储锁的key还没有来得及同步到Slave上。
  • Slave升级为Master。
  • 客户端2从新的Master获取到了对应同一个资源的锁。
  • 于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

此外还有问题:锁的有效时间(lock validity time),设置成多少合适呢? 如果设置太短的话,锁就有可能在客户端完成对于共享资源的访问之前过期,从而失去保护;如果设置太长的话,一旦某个持有锁的客户端释放锁失败,那么就会导致所有其它客户端都无法获取锁,从而长时间内无法正常工作。看来真是个两难的问题。

分布式锁Redlock

步骤

由于前面介绍的基于单Redis节点的分布式锁在failover的时候会产生解决不了的安全性问题,因此antirez提出了新的分布式锁的算法Redlock,它基于N个完全独立的Redis节点(通常情况下N可以设置成5)。

获取锁

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向N个Redis节点执行获取锁的操作。

    这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有;

  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。

释放锁

释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。

问题

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。我们前面讨论的单Redis节点的分布式锁在failover的时候锁失效的问题,在Redlock中不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。具体的影响程度跟Redis对数据的持久化程度有关。

假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:

  • 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
  • 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
  • 节点C重启后,客户端2锁住了C, D, E,获取锁成功。 这样,客户端1和客户端2同时获得了锁(针对同一资源)。

优化方式

在默认情况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。所以,上面分析的由于节点重启引发的锁失效问题,总是有可能出现的。为了应对这一问题,antirez又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。

关于Redlock还有一点细节值得拿出来分析一下:在最后释放锁的时候,antirez在算法描述中特别强调,客户端应该向所有Redis节点发起释放锁的操作。也就是说,即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。这是为什么呢?设想这样一种情况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET操作,但是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。实际上,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。

遗留问题

前面在讨论单Redis节点的分布式锁的时候,最后我们提出了一个疑问,如果客户端长期阻塞导致锁过期,那么它接下来访问共享资源就不安全了(没有了锁的保护)。这个问题在Redlock中是否有所改善呢?显然,这样的问题在Redlock中是依然存在的。

另外,在算法第4步成功获取了锁之后,如果由于获取锁的过程消耗了较长时间,重新计算出来的剩余的锁有效时间很短了,那么我们还来得及去完成共享资源访问吗?如果我们认为太短,是不是应该立即进行锁的释放操作?那到底多短才算呢?又是一个选择难题。

引用

基于Redis的分布式锁到底安全吗

cuipf

scribble