基于 Redis 的分布式锁实现及踩坑案例

1. 前言

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

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

关于 Redis 实现分布式锁,网上可以查到很多资料,笔者最初也借鉴了这些资料,但是,在分布式锁的实现和使用过程中意识到这些资料普遍存在问题,容易误导初学者,鉴于此,撰写本文,希望为对分布式锁感兴趣的读者提供一篇切实可用的参考文档。

本场 Chat 将介绍以下内容:

  1. 分布式锁原理介绍;
  2. 基于 Redis 实现的分布式锁的安全性分析
  3. 加锁的正确方式及典型错误案例分析;
  4. 解锁的正确方式及典型错误案例分析。

2. 分布式锁原理介绍

2.1 分布式锁基本约束条件

为了确保锁服务可用,通常,分布式锁需同时满足以下四个约束条件:

  1. 互斥性:在任意时刻,只有一个客户端能持有锁;
  2. 安全性:即不会形成死锁,当一个客户端在持有锁的期间崩溃而没有主动解锁的情况下,其持有的锁也能够被正确释放,并保证后续其它客户端能加锁;
  3. 可用性:就 Redis 而言,当提供锁服务的 Redis master 节点发生宕机等不可恢复性故障时,slave 节点能够升主并继续提供服务,支持客户端加锁和解锁;对基于分布式一致性算法实现的锁服务,如 ETCD 而言,当 leader 节点宕机时,follow 节点能够选举出新的 leader 继续提供锁服务;
  4. 对称性:对于任意一个锁,其加锁和解锁必须是同一个客户端,即,客户端 A 不能把客户端 B 加的锁给解了。

2.2 基于 Redis 实现分布式锁(以 Redis 单机模式为例)

基于 Redis 实现的锁服务的思路是比较简单直观的:我们把锁数据存储在分布式环境中的一个节点,所有需要获取锁的调用方(客户端),都需访问该节点,如果锁数据(key-value 键值对)已经存在,则说明已经有其它客户端持有该锁,可等待其释放(key-value 被主动删除或者因过期而被动删除)再尝试获取锁;如果锁数据不存在,则写入锁数据(key-value),其中 value 需要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,以便释放锁的时候进行校验;锁服务使用完毕之后,需要主动释放锁,即删除存储在 Redis 中的 key-value 键值对。其架构如下:

enter image description here

2.3 加解锁流程

基于 Redis 官方的文档,对于一个尝试获取锁的操作,流程如下:

步骤 1:向 Redis 节点发送命令,请求锁:
SET lock_name my_random_value NX PX 30000

其中:

  1. lock_name:即锁名称,这个名称应是公开的,在分布式环境中,对于某一确定的公共资源,所有争用方(客户端)都应该知道对应锁的名字。对于 Redis 而言,lock_name 就是 key-value 中的 key,具有唯一性。
  2. my_random_value 是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁的持有者。
  3. NX 表示只有当 lock_name(key) 不存在的时候才能 SET 成功,从而保证只有一个客户端能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  4. PX 30000 表示这个锁节点有一个 30 秒的自动过期时间(目的是为了防止持有锁的客户端故障后,无法主动释放锁而导致死锁,因此要求锁的持有者必须在过期时间之内执行完相关操作并释放锁)。
步骤 2:如果步骤 1 的命令返回成功,则代表获取锁成功,否则获取锁失败。

对于一个拥有锁的客户端,释放锁流程如下:

1.向 Redis 结点发送命令,获取锁对应的 value:

GET lock_name

2.如果查询回来的 value 和客户端自身的 my_random_value 一致,则可确认自己是锁的持有者,可以发起解锁操作,即主动删除对应的 key,发送命令:

DEL lock_name

通过 Redis-cli 执行上述命令,显示如下:

100.X.X.X:6379> set lock_name my_random_value NX PX 30000
OK
100.X.X.X:6379> get lock_name
"my_random_value"
100.X.X.X:6379> del lock_name
(integer) 1
100.X.X.X:6379> get lock_name
(nil)

3. 基于 Redis 的分布式锁的安全性分析

3.1 预防死锁

典型死锁场景:

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

解决方案:

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

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

为了避免死锁,可利用 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 是否一致,如果不相同,说明已经不再持有锁,则放弃对共享资源的操作以避免互斥性失效的问题。
<
收藏 收藏
分享
购买文章 ¥6