Redis的分布式锁要注意哪些安全性问题?

2021-03-10 14:36:48 浏览数 (1)

分布式锁的实现,目前常用的方案有以下三类:

  1. 数据库乐观锁;
  2. 基于分布式缓存实现的锁服务,典型代表有 Redis 和基于 Redis 的 RedLock;
  3. 基于分布式一致性算法实现的锁服务,典型代表有 ZooKeeper、Chubby 和 ETCD。

但是在使用过程中还是要留意以下的集中安全性问题。

预防死锁

我们看下面这个典型死锁场景。

一个客户端获取锁成功,但是在释放锁之前崩溃了,此时该客户端实际上已经失去了对公共资源的操作权,但却没有办法请求解锁(删除 Key-Value 键值对),那么,它就会一直持有这个锁,而其它客户端永远无法获得锁。

我们的解决方案是:在加锁时为锁设置过期时间,当过期时间到达,Redis 会自动删除对应的 Key-Value,从而避免死锁。需要注意的是,这个过期时间需要结合具体业务综合评估设置,以保证锁的持有者能够在过期时间之内执行完相关操作并释放锁。

设置锁自动过期时间以预防死锁存在的隐患

为了避免死锁,可利用 Redis 为锁数据(Key-Value)设置自动过期时间,虽然可以解决死锁的问题,但却存在隐患。

我们看下面这个典型场景。

  1. 客户端 A 获取锁成功;
  2. 客户端 A 在某个操作上阻塞了很长时间(对于 Java 而言,如发生 Full-GC);
  3. 过期时间到,锁自动释放;
  4. 客户端 B 获取到了对应同一个资源的锁;
  5. 客户端 A 从阻塞中恢复过来,认为自己依旧持有锁,继续操作同一个资源,导致互斥性失效。

这时我们可采取的解决方案见下。

  1. 存在隐患的方案。第 5 步中,客户端 A 恢复后,可以比较下目前已经持有锁的时间,如果发现已经过期,则放弃对共享资源的操作即可避免互斥性失效的问题。但是,客户端 A 所在节点的时间和 Redis 节点的时间很可能不一致(比如客户端与 Redis 节点不在同一台服务器,而不同服务器时间通常不完全同步),因此,严格来讲,任何依赖两个节点时间比较结果的互斥性算法,都存在隐患。目前网上很多资料都采用了这种方案,鉴于其隐患,不推荐。
  2. 可取的方案。既然比较时间不可取,那么,还可以比较 my_random_value,即客户端 A 恢复后,在操作共享资源前应比较目前自身所持有锁的 my_random_value 与 Redis 中存储的 my_random_value 是否一致,如果不相同,说明已经不再持有锁,则放弃对共享资源的操作以避免互斥性失效的问题。

解锁操作的原子性

为了保证每次解锁操作都能正确进行,需要引入全局唯一变量 my_random_value。具体而言,解锁需要两步,先查询(GET)锁对应的 Value,与自己加锁时设置的 my_random_value 进行对比,如果相同,则可确认这把锁是自己加的,然后再发起解锁(DEL)。需要注意的是,GET 和 DEL 是两个操作,非原子性,那么解锁本身也会存在破坏互斥性的可能。

下面是典型场景。

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

下面给出解决方案。

如何保障解锁操作的原子性呢?在实践中,我总结出两种方案。

1. 使用 Redis 事务功能,使用 Watch 命令监控锁对应的 Key,释放锁则采用事务功能(Multi 命令),如果持有的锁已经因过期而释放(或者过期释放后又被其它客户端持有),则 Key 对应的 Value 将改变,释放锁的事务将不会被执行,从而避免错误的释放锁,示例代码如下:

2. Redis 支持 Lua 脚本并保证其原子性,使用 Lua 脚本实现锁校验与释放,并使用 Redis 的 eval 函数执行 Lua 脚本,代码如下:

Redis 节点故障后,主备切换的数据一致性

考虑 Redis 节点宕机,如果长时间无法恢复,则导致锁服务长时间不可用。为了保证锁服务的可用性,通常的方案是给这个 Redis 节点挂一个 Slave(多个也可以),当 Master 节点不可用的时候,系统自动切到 Slave 上。但是由于 Redis 的主从复制(Replication)是异步的,这可能导致在宕机切换过程中丧失锁的安全性。

我们看下典型场景。

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

解决方案有两个。

  • 方案1,设想下,如果要避免上述情况,可以采用一个比较“土”的方法,即自认为持有锁的客户端在对敏感公共资源进行写操作前,先进行校验,确认自己是否确实持有锁,校验的方式前面已经介绍过——通过比较自己的 my_random_value 和 Redis 服务端中实际存储的 my_random_value

显然,这里仍存在一个问题。如果校验完毕后,Master 数据尚未同步到 Slave 的情况下 Master 宕机,该如何是好?诚然,我们可以为 Redis 服务端设置较短的主从复置周期,以尽量避免上述情况出现,但是,隐患还是客观存在的。

  • 方案2,针对该问题场景,Redis 的作者 Antirez 提出了 RedLock,其原理基于分布式一致性算法的核心理念:多数派思想。下面对 RedLock 做简要介绍。

RedLock 简要介绍

上面介绍了基于单 Redis 节点的分布式锁在主从故障倒换(Failover)时会产生安全性问题。针对问题场景,Redis 的作者 Antirez 提出了 RedLock,它基于 N 个完全独立的 Redis 节点,其原理基于分布式一致性算法的核心理念:多数派思想,不过,RedLock 目前还不成熟,争议较大,本节仅作简要介绍。

运行 Redlock 算法的客户端依次执行以下步骤,来进行加锁的操作:

  1. 获取当前系统时间(毫秒数)。
  2. 按顺序依次向 N 个 Redis 节点执行获取锁的操作。这个获取操作跟前面基于单 Redis 节点获取锁的过程相同,包含随机字符串 my_random_value,也包含过期时间(比如 PX 30000,即锁的有效时间)。为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(Time Out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个 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 正常工作,因此理论上它的可用性更高。上述所描述的问题在 Redlock 中就不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。

  • 它有哪些潜在问题呢,我们来看下面这个例子。

从加锁的过程,读者应该可以看出:RedLock 对系统时间是强依赖的,那么,一旦节点系统时间出现异常(Redis 节点不在同一台服务器上),问题便又来了,如下场景,假设一共有 5 个 Redis 节点:A、B、C、D、E。

  1. 客户端 1 成功锁住了 A、B、C,获取锁成功(但 D 和 E 没有锁住)。
  2. 节点 C 时间异常,导致 C 上的锁数据提前到期,而被释放。
  3. 客户端 2 此时尝试获取同一把锁:锁住了C、D、E,获取锁成功。

- END -

0 人点赞