1. 前言
关于分布式锁的实现,目前常用的方案有以下三类:
- 数据库乐观锁;
- 基于分布式缓存实现的锁服务,典型代表有 Redis 和基于 Redis 的 RedLock;
- 基于分布式一致性算法实现的锁服务,典型代表有 ZooKeeper、Chubby 和 ETCD。
关于 Redis 实现分布式锁,网上可以查到很多资料,笔者最初也借鉴了这些资料,但是,在分布式锁的实现和使用过程中意识到这些资料普遍存在问题,容易误导初学者,鉴于此,撰写本文,希望为对分布式锁感兴趣的读者提供一篇切实可用的参考文档。
本场 Chat 将介绍以下内容:
- 分布式锁原理介绍;
- 基于 Redis 实现的分布式锁的安全性分析
- 加锁的正确方式及典型错误案例分析;
- 解锁的正确方式及典型错误案例分析。
2. 分布式锁原理介绍
2.1 分布式锁基本约束条件
为了确保锁服务可用,通常,分布式锁需同时满足以下四个约束条件:
- 互斥性:在任意时刻,只有一个客户端能持有锁;
- 安全性:即不会形成死锁,当一个客户端在持有锁的期间崩溃而没有主动解锁的情况下,其持有的锁也能够被正确释放,并保证后续其它客户端能加锁;
- 可用性:就 Redis 而言,当提供锁服务的 Redis master 节点发生宕机等不可恢复性故障时,slave 节点能够升主并继续提供服务,支持客户端加锁和解锁;对基于分布式一致性算法实现的锁服务,如 ETCD 而言,当 leader 节点宕机时,follow 节点能够选举出新的 leader 继续提供锁服务;
- 对称性:对于任意一个锁,其加锁和解锁必须是同一个客户端,即,客户端 A 不能把客户端 B 加的锁给解了。
2.2 基于 Redis 实现分布式锁(以 Redis 单机模式为例)
基于 Redis 实现的锁服务的思路是比较简单直观的:我们把锁数据存储在分布式环境中的一个节点,所有需要获取锁的调用方(客户端),都需访问该节点,如果锁数据(key-value 键值对)已经存在,则说明已经有其它客户端持有该锁,可等待其释放(key-value 被主动删除或者因过期而被动删除)再尝试获取锁;如果锁数据不存在,则写入锁数据(key-value),其中 value 需要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,以便释放锁的时候进行校验;锁服务使用完毕之后,需要主动释放锁,即删除存储在 Redis 中的 key-value 键值对。其架构如下:
2.3 加解锁流程
基于 Redis 官方的文档,对于一个尝试获取锁的操作,流程如下:
步骤 1:向 Redis 节点发送命令,请求锁:
SET lock_name my_random_value NX PX 30000
其中:
-
lock_name
:即锁名称,这个名称应是公开的,在分布式环境中,对于某一确定的公共资源,所有争用方(客户端)都应该知道对应锁的名字。对于 Redis 而言,lock_name 就是 key-value 中的 key,具有唯一性。 -
my_random_value
是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁的持有者。 - NX 表示只有当 lock_name(key) 不存在的时候才能 SET 成功,从而保证只有一个客户端能获得锁,而其它客户端在锁被释放之前都无法获得锁。
- 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)设置自动过期时间,虽然可以解决死锁的问题,但却存在隐患.
典型场景:
- 客户端 A 获取锁成功
- 客户端 A 在某个操作上阻塞了很长时间(对于 Java 而言,如发生 full-GC)
- 过期时间到,锁自动释放
- 客户端 B 获取到了对应同一个资源的锁
- 客户端 A 从阻塞中恢复过来,认为自己依旧持有锁,继续操作同一个资源,导致互斥性失效
解决方案:
- 存在隐患的方案:第 5 步中,客户端 A 恢复回来后,可以比较下目前已经持有锁的时间,如果发现已经过期,则放弃对共享资源的操作即可避免互斥性失效的问题。但是,客户端 A 所在节点的时间和 Redis 节点的时间很可能不一致(如:客户端与 Redis 节点不在同一台服务器,而不同服务器时间通常不完全同步),因此,严格来讲,任何依赖两个节点时间比较结果的互斥性算法,都存在隐患。目前网上很多资料都采用了这种方案,鉴于其隐患,不推荐。
- 可取的方案:既然比较时间不可取,那么,还可以比较
my_random_value
:客户端A恢复后,在操作共享资源前应比较目前自身所持有锁的my_random_value
与 Redis 中存储的my_random_value
是否一致,如果不相同,说明已经不再持有锁,则放弃对共享资源的操作以避免互斥性失效的问题。
3.3 解锁操作的原子性
为了保证每次解锁操作都能正确性的进行,需要引入全局唯一的 my_random_value
。具体而言,解锁需要两步,先查询(get)锁对应的 value,与自己加锁时设置的 my_random_value
进行对比,如果相同,则可确认这把锁是自己加的,然后再发起解锁(del)。需要注意的是,get 和 del 是两个操作,非原子性,那么解锁本身也会存在破坏互斥性的可能。
典型场景:
- 客户端 A 获取锁成功。
- 客户端 A 访问共享资源。
- 客户端 A 为了释放锁,先执行 GET 操作获取锁对应的随机字符串的值。
- 客户端 A 判断随机字符串的值,与预期的值相等。
- 客户端 A 由于某个原因阻塞住了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端 B 获取到了对应同一个资源的锁。
- 客户端 A 从阻塞中恢复过来,执行 DEL 操纵,释放掉了客户端 B 持有的锁。
解决方案:
保障解锁操作的原子性,如何保障呢?在实践中,笔者总结出两种方案:
1. 使用 Redis 事务功能,使用 watch 命令监控锁对应的 key,释放锁则采用事务功能(multi 命令),如果持有的锁已经因过期而释放(或者过期释放后又被其它客户端持有),则 key 对应的 value 将改变,释放锁的事务将不会被执行,从而避免错误的释放锁,示例代码如下:
Jedis jedis = new Jedis("127.0.0.1", 6379);
// “自旋”,等待锁
String result = null;
while (true)
{
// 申请锁,只有当“lock_name”不存在时才能申请成功,返回“OK",锁的过期时间设置为5s
result = jedis.set("lock_name", "my_random_value", SET_IF_NOT_EXIST,
SET_WITH_EXPIRE_TIME, 5000);
if ("OK".equals(result))
{
break;
}
}
// 监控锁对应的key,如果其它的客户端对这个key进行了更改,那么本次事务会被取消。
jedis.watch("lock_name");
// 成功获取锁,则操作公共资源,自定义流程
// to do something...
// 释放锁之前,校验是否持有锁
if (jedis.get("lock_name").equals("my_random_value"))
{
// 开启事务功能,
Transaction multi = jedis.multi();
// 模拟客户端阻塞10s,锁超时,自动清除
try
{
Thread.sleep(5000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
// 客户端恢复,继续释放锁
multi.del("lock_name");
// 执行事务(如果其它的客户端对这个key进行了更改,那么本次事务会被取消,不会执行)
multi.exec();
}
// 释放资源
jedis.unwatch();
jedis.close();
2. Redis 支持 Lua 脚本并保证其原子性,使用 Lua 脚本实现锁校验与释放,并使用 Redis 的 evel 函数执行 Lua 脚本,代码如下:
Jedis jedis = new Jedis("127.0.0.1", 6379);
// “自旋”,等待锁
String result = null;
while (true)
{
// 申请锁,只有当“lock_name”不存在时才能申请成功,返回“OK",锁的过期时间设置为5s
result = jedis.set("lock_name", "my_random_value", SET_IF_NOT_EXIST,
SET_WITH_EXPIRE_TIME, 5000);
if ("OK".equals(result))
{
break;
}
}
// 成功获取锁,则操作公共资源,自定义流程
// to do something...
// Lua脚本,用于校验并释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
try
{
// 模拟客户端阻塞10s,锁超时,自动清除
Thread.sleep(10000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
// 执行Lua脚本,校验并释放锁
jedis.eval(script, Collections.singletonList("lock_name"),
Collections.singletonList("my_random_value"));
jedis.close();
3.4 Redis 节点故障后,主备切换的数据一致性
考虑 Redis 节点宕机,如果长时间无法恢复,则导致锁服务长时间不可用。为了保证锁服务的可用性,通常的方案是给这个 Redis 节点挂一个 Slave(多个也可以),当 Master 节点不可用的时候,系统自动切到 Slave 上。但是由于 Redis 的主从复制(replication)是异步的,这可能导致在宕机切换过程中丧失锁的安全性。
典型场景:
- 客户端 A 从 Master 获取了锁。
- Master 宕机了,存储锁的key还没有来得及同步到 Slave 上。
- Slave 升级为 Master。
- 客户端 B 从新的 Master 获取到了对应同一个资源的锁。
- 客户端 A 和客户端 B 同时持有了同一个资源的锁,锁的安全性被打破。
解决方案:
方案1:设想下,如果要避免上述情况,可以采用一个比较“土”的方法:自认为持有锁的客户端在对敏感公共资源进行写操作前,先进行校验,确认自己是否确实持有锁,校验的方式前面已经介绍过——通过比较自己的 my_random_value
和 Redis 服务端中实际存储的 my_random_value
。
显然,这里仍存在一个问题:如果校验完毕后,Master 数据尚未同步到 Slave 的情况下 Master 宕机,该如何是好?诚然,我们可以为 Redis 服务端设置较短的主从复置周期,以尽量避免上述情况出现,但是,隐患还是客观存在的。
方案2:多数派思想:针对问题场景,Redis 的作者 Antirez 提出了 RedLock,其原理基于分布式一致性算法的核心理念:多数派思想。