分布式锁的实现,目前常用的方案有以下三类:
基于 Redis 实现分布式锁,网上可以查到很多相关资料,我最初也借鉴了这些资料,但是,在分布式锁的实现和使用过程中意识到这些资料普遍存在问题,容易误导初学者,鉴于此,撰写了本文,希望为对分布式锁感兴趣的读者提供一篇切实可用的参考文档。
为了确保锁服务可用,通常,分布式锁需同时满足以下四个约束条件。
基于 Redis 实现锁服务的思路比较简单。我们把锁数据存储在分布式环境中的一个节点,所有需要获取锁的调用方(客户端),都需访问该节点,如果锁数据(Key-Value 键值对)已经存在,则说明已经有其它客户端持有该锁,可等待其释放(Key-Value 被主动删除或者因过期而被动删除)再尝试获取锁;如果锁数据不存在,则写入锁数据(Key-Value),其中 Value 需要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,以便释放锁的时候进行校验;锁服务使用完毕之后,需要主动释放锁,即删除存储在 Redis 中的 Key-Value 键值对。其架构如下:
根据 Redis 官方的文档,获取锁的操作流程如下。
步骤1,向 Redis 节点发送命令,请求锁。代码如下:
SET lock_name my_random_value NX PX 30000
下面解释下各参数的意义。
lock_name
,即锁名称,这个名称应是公开的,在分布式环境中,对于某一确定的公共资源,所有争用方(客户端)都应该知道对应锁的名字。对于 Redis 而言,lock_name
就是 Key-Value 中的 Key,具有唯一性。my_random_value
是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内,且在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁的持有者。lock_name(key)
不存在的时候才能 SET 成功,从而保证只有一个客户端能获得锁,而其它客户端在锁被释放之前都无法获得锁。步骤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)
我们看下面这个典型死锁场景。
一个客户端获取锁成功,但是在释放锁之前崩溃了,此时该客户端实际上已经失去了对公共资源的操作权,但却没有办法请求解锁(删除 Key-Value 键值对),那么,它就会一直持有这个锁,而其它客户端永远无法获得锁。
我们的解决方案是:在加锁时为锁设置过期时间,当过期时间到达,Redis 会自动删除对应的 Key-Value,从而避免死锁。需要注意的是,这个过期时间需要结合具体业务综合评估设置,以保证锁的持有者能够在过期时间之内执行完相关操作并释放锁。
为了避免死锁,可利用 Redis 为锁数据(Key-Value)设置自动过期时间,虽然可以解决死锁的问题,但却存在隐患。
我们看下面这个典型场景。
这时我们可采取的解决方案见下。
my_random_value
,即客户端 A 恢复后,在操作共享资源前应比较目前自身所持有锁的 my_random_value
与 Redis 中存储的 my_random_value
是否一致,如果不相同,说明已经不再持有锁,则放弃对共享资源的操作以避免互斥性失效的问题。为了保证每次解锁操作都能正确进行,需要引入全局唯一变量 my_random_value
。具体而言,解锁需要两步,先查询(GET)锁对应的 Value,与自己加锁时设置的 my_random_value
进行对比,如果相同,则可确认这把锁是自己加的,然后再发起解锁(DEL)。需要注意的是,GET 和 DEL 是两个操作,非原子性,那么解锁本身也会存在破坏互斥性的可能。
下面是典型场景。
下面给出解决方案。
如何保障解锁操作的原子性呢?在实践中,我总结出两种方案。
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 的 eval 函数执行 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();
考虑 Redis 节点宕机,如果长时间无法恢复,则导致锁服务长时间不可用。为了保证锁服务的可用性,通常的方案是给这个 Redis 节点挂一个 Slave(多个也可以),当 Master 节点不可用的时候,系统自动切到 Slave 上。但是由于 Redis 的主从复制(Replication)是异步的,这可能导致在宕机切换过程中丧失锁的安全性。
我们看下典型场景。
解决方案有两个。
方案1,设想下,如果要避免上述情况,可以采用一个比较“土”的方法,即自认为持有锁的客户端在对敏感公共资源进行写操作前,先进行校验,确认自己是否确实持有锁,校验的方式前面已经介绍过——通过比较自己的 my_random_value
和 Redis 服务端中实际存储的 my_random_value
。
显然,这里仍存在一个问题。如果校验完毕后,Master 数据尚未同步到 Slave 的情况下 Master 宕机,该如何是好?诚然,我们可以为 Redis 服务端设置较短的主从复置周期,以尽量避免上述情况出现,但是,隐患还是客观存在的。
方案2,针对该问题场景,Redis 的作者 Antirez 提出了 RedLock,其原理基于分布式一致性算法的核心理念:多数派思想。下面对 RedLock 做简要介绍。
2.4 节介绍了基于单 Redis 节点的分布式锁在主从故障倒换(Failover)时会产生安全性问题。针对问题场景,Redis 的作者 Antirez 提出了 RedLock,它基于 N 个完全独立的 Redis 节点,其原理基于分布式一致性算法的核心理念:多数派思想,不过,RedLock 目前还不成熟,争议较大,本节仅作简要介绍。
运行 Redlock 算法的客户端依次执行以下步骤,来进行加锁的操作:
my_random_value
,也包含过期时间(比如 PX 30000,即锁的有效时间)。为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(Time Out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。这里的失败,应该包含任何类型的失败,比如该 Redis 节点不可用。>=N/2+1
)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(Lock Validity Time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。N/2+1
,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有 Redis 节点发起释放锁的操作(即前面介绍的 Redis Lua 脚本)。我们再来了解下解锁步骤。上面描述的只是获取锁的过程,而释放锁的过程比较简单,即客户端向所有 Redis 节点发起释放锁的操作,不管这些节点在获取锁的时候成功与否。
该方法在理论上的可靠性如何呢?
N 个 Redis 节点中的大多数能正常工作,就能保证 Redlock 正常工作,因此理论上它的可用性更高。2.4 节中所描述的问题在 Redlock 中就不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。
它有哪些潜在问题呢,我们来看下面这个例子。
从加锁的过程,读者应该可以看出:RedLock 对系统时间是强依赖的,那么,一旦节点系统时间出现异常(Redis 节点不在同一台服务器上),问题便又来了,如下场景,假设一共有 5 个 Redis 节点:A、B、C、D、E。
这里,我选用了 Redis 开源客户端 Jedis,读者在运行示例代码前,需在对应的 Maven 工程的 Pom 文件中加入如下依赖:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
Jedis 是一个优秀的基于 Java 语言的 Redis 客户端。但是,其不足也很明显,Jedis 在实现上是直接连接 Redis-Server,在多个线程间共享一个 Jedis 实例时是线程不安全的,如果想要在多线程场景下使用 Jedis,需要使用连接池,每个线程都使用自己的 Jedis 实例,当连接数量增多时,会消耗较多的物理资源。本文中使用 Jedis,采用的是连接池模式。如下代码:
JedisPoolConfig config = new JedisPoolConfig();
// 设置最大连接数
config.setMaxTotal(200);
// 设置最大空闲数
config.setMaxIdle(8);
// 设置最大等待时间
config.setMaxWaitMillis(1000 * 100);
// 在borrow一个jedis实例时,是否需要验证,若为true,则所有jedis实例均是可用的
config.setTestOnBorrow(true);
// 创建连接池
JedisPool jedisPool = new JedisPool(config, "127.0.0.1", 6379, 3000);
基于第 2 节《基于 Redis 的分布式锁的安全性分析》,我们很容易写出以下加锁代码:
public class DistributedLock
{
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 加锁
* @param jedisPool jedis 连接池
* @param lockName 锁名,对应被争用的公共资源
* @param myRandomValue 需保持全局唯一,以校验锁的持有者
* @param expireTime 过期时间。过期将自动删除(释放锁)
*/
public static void Lock(JedisPool jedisPool, String lockName, String myRandomValue,
int expireTime)
{
Jedis jedis = null;
try
{
jedis = jedisPool.getResource();
// "自旋",等待锁
while (true)
{
String result = jedis.set(lockName, myRandomValue, SET_IF_NOT_EXIST,
SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result))
{
return;
}
}
}
catch (Exception e)
{
throw e;
}
finally
{
if (null != jedis)
{
jedis.close();
}
}
}
}
加锁核心方法为:
jedis.set(String key, String value, String nxxx, String expx, int time)
接下来说明下各个参数的意义。
my_random_value
,在 2.2 节《设置锁自动过期时间以预防死锁存在的隐患》中,我分析了隐患场景并给出了解决方案。为了保障可靠性,在解锁时,仅仅依赖 Key 是不够的,为了避免错误得释放锁,释放前需要进行校验,即根据 Key 取出 Value,将其与自己加锁时设置的 my_random_value
进行对比,以便确认是否是自己持有的锁。my_random_value
可以使用特定的随机算法生成,如 UUID.randomUUID().toString()
。最后,我们做下小结。
通过上述说明,set(…) 方法可以满足加锁的安全性,执行 set(…) 方法有两种结果。
分别使用 jedis.setnx()
和 jedis.expire()
组合实现加锁,代码如下:
public static void lock(JedisPool jedisPool, String lockName, String myRandomValue, int expireTime)
{
Jedis jedis = jedisPool.getResource();
// 如果锁不存在,则加锁
Long result = jedis.setnx(lockName, myRandomValue);
if (result == 1)
{
// 为锁设置过期时间,由于加锁和设置过期时间是两步完成的,非原子操作
jedis.expire(lockName, expireTime);
}
}
setnx() 方法的作用就是 SET IF NOT EXIST,expire() 方法就是给锁加一个过期时间。初看,似乎没有什么问题,但经不起推敲:加锁实际上使用了两条 Redis 命令,非原子性,如果程序在执行完 setnx() 之后突然崩溃,导致锁没有设置过期时间,那么将会造成死锁。
网上很多资料中采用的就是这种最初级的实现方式,读者切勿仿效。
在 2.3 节《解锁操作的原子性》中,我曾分析了解锁操作可能出现的异常,并给出了两种解决方案,在此,我们再介绍下完整代码。
Redis 支持 Lua 脚本并保证其原子性,使用 Lua 脚本实现锁校验与释放,并使用 Redis 的 eval() 函数执行 Lua 脚本,代码如下:
public class DistributedLock
{
// 释放锁成功标志
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放锁
* @param jedisPool jedis连接池
* @param lockName 锁名,对应被争用的公共资源
* @param myRandomValue 需保持全局唯一,以校验锁的持有者
* @return 是否释放成功
*/
public static boolean unLock(JedisPool jedisPool, String lockName, String myRandomValue)
{
Jedis jedis = null;
// Lua脚本,用于校验并释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
try
{
jedis = jedisPool.getResource();
Object result = jedis.eval(script, Collections.singletonList(lockName),
Collections.singletonList(myRandomValue));
// 注意:如果脚本顺利执行将返回1,如果执行脚本中,其它的客户端对这个lockName对应的值进行了更改,那么将返回0
if (RELEASE_SUCCESS.equals(result))
{
return true;
}
}
catch (Exception e)
{
throw e;
}
finally
{
if (null != jedis)
{
jedis.close();
}
}
return false;
}
}
从上面的示例代码可以看出,解锁操作只用了两行代码。
第一行使用了 Lua 脚本,其语义为通过 GET 命令访问参数 KEYS[1]
对应的锁,获得锁对应的 Value,并将其与参数 ARGV[1]
对比,如果相同则调用 DEL 命令删除 KEYS[1]
对应的键值对(即释放锁操作)。
// Lua脚本,用于校验并释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
第二行通过 Redis 的 eval() 函数执行 Lua 脚本,其中入参 lockName 赋值给参数 KEYS[1]
,myRandomValue 赋值给 ARGV[1]
,eval() 函数将 Lua 脚本交给 Redis 服务端执行。
jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(myRandomValue));
根据 Redis 官网文档说明,通过 eval() 执行 Lua 代码时,Lua 代码将被当成一个命令去执行(可保证原子性),并且直到 eval 命令执行完成,Redis 才会执行其他命令。因此,通过 Lua 脚本结合 eval 函数,可以科学得实现解锁操作的原子性,避免误解锁。
使用 Redis 事务功能,通过 Watch 命令监控锁对应的 Key,释放锁则采用事务功能(Multi 命令),如果持有的锁已经因过期而释放(也可能释放后又被其它客户端持有),则 Key 对应的 Value 将改变,释放锁的事务将不会被执行,从而保证原子性,同时避免错误的释放锁,示例代码如下:
public class DistributedLock
{
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放锁
* @param jedisPool jedis连接池
* @param lockName 锁名,对应被争用的公共资源
* @param myRandomValue 需保持全局唯一,以校验锁的持有者
* @return 是否释放成功
*/
public static boolean unLockII(JedisPool jedisPool, String lockName, String myRandomValue)
{
Jedis jedis = null;
try
{
jedis = jedisPool.getResource();
// 监控锁对应的Key,如果其它的客户端对这个Key进行了更改,那么本次事务会被取消。
jedis.watch(lockName);
// 成功获取锁,则操作公共资源,自定义流程
// to do something...
// 校验是否持有锁
if (myRandomValue.equals(jedis.get(lockName)))
{
// 开启事务功能,
Transaction multi = jedis.multi();
// 释放锁
multi.del(lockName);
// 执行事务(如果其它的客户端对这个Key进行了更改,那么本次事务会被取消,不会执行)
// 如果正常执行,由于只有一个删除操作,返回的list将只有一个对象。
List<Object> result = multi.exec();
if (RELEASE_SUCCESS.equals(result.size()))
{
return true;
}
}
}
catch (Exception e)
{
throw e;
}
finally
{
if (null != jedis)
{
jedis.unwatch();
jedis.close();
}
}
return false;
}
}
这里稍微解释下。
参考百度百科,所谓事务,应该具有 4 个属性,即原子性、一致性、隔离性、持久性。这四个属性通常称为 ACID 特性。
Redis 支持事务功能,根据事务所具有特征,读者应该可以发现,我们解锁时最关心的所有问题,事务都可以解决。这也是我介绍事务功能解锁的原因。Redis 使用事务功能,通常采用的步骤如下。
步骤1,Watch 命令监控锁。
监控锁对应的 key(lockName),事务开启后,如果其它的客户端对这个 Key 进行了更改,那么本次事务会被取消而不会执行 jedis.watch(lockName)
。
步骤2,开启事务功能,代码如下:
jedis.multi()
步骤3,释放锁。
注意,事务开启后,释放锁的操作便是事务中的一个元素,隶属于该事务,代码如下:
multi.del(lockName);
步骤4,执行事务,代码如下:
multi.exec();
步骤5,释放资源,代码如下:
jedis.unwatch();
jedis.close();
直接使用 jedis.del()
方法删除锁,而没有进行校验。在 2.3 节所述的异常场景下,这种不校验锁的拥有者而直接解锁的方式,会导致锁被错误的释放,从而破坏互斥性,如下面代码所示。
public static void unLock(JedisPool jedisPool, String lockName)
{
Jedis jedis = jedisPool.getResource();
jedis.del(lockName);
}
如下解锁方式相较于上一种已经有了明显进步,在解锁之前进行了校验。但是问题并没有解决,整个解锁过程仍然是独立的两条命令,并非原子操作。代码如下:
public static void unLock1(JedisPool jedisPool, String lockName, String myRandomValue)
{
Jedis jedis = jedisPool.getResource();
// 判断加锁与解锁是不是同一个客户端
if (myRandomValue.equals(jedis.get(lockName)))
{
// 解锁,如果在此之前出现异常而使客户端阻塞,锁已经过期被自动释放,本客户端已经不再持有锁,则会误解锁
jedis.del(lockName);
}
}
本文引用了以下文档中的一些图片和文字,一一列出,以表敬意。
© 2019 - 2023 Liangliang Lee. Powered by gin and hexo-theme-book.