为什么需要分布式锁
在聊为什么需要分布式锁的之前先来看看这么一个场景:
数据库创建设备记录时,设备编号不允许重复。开发工程师 A 每次创设备前先获取数据库记录是否存在设备编号,不存在则创建,存在则返回错误信息。有一天测试工程师 B 告诉 A ,他调用接口快速创建设备,出现了设备编号相同的记录。 为什么会出现这种情况,开发工程师 A 需要分析分析。
单体中多线程
线程 p1 从根据设备编号 DEVICENUMBER1 从数据库查找记录,发现不存在,继续执行创建设备的代码(这个逻辑比较长),在这期间,线程 p2 抢占到了 CPU 执行,也是使用设备编号 DEVICENUMBER1 从数据库查找记录,发现不存在,执行创建设备的代码进库。释放 CPU 之后,线程 p1 继续执行也进库。便出现了两条设备编号重复的数据。
分布式多进程
分布式服务 S1 从根据设备编号 DEVICENUMBER2 从数据库查找记录,发现不存在,继续执行创建设备的代码,由于这台服务资源相对较低,执行较慢,,在这期间,服务 S2 接收到创建请求,也是使用设备编号 DEVICENUMBER2 创建设备 从数据库查找记录,发现不存在,执行创建设备的代码。这样便出现了两条设备编号重复的数据。
解决方案
单体服务中,一个进程多线程,程序开发通常使用锁互斥以保证共享变量访问操作的正确性。但对于分布式来说是多个进程之间的资源共享,如何互斥呢?目前市面上的业务应用通常采用微服务架构,这也意味着一个应用会部署多个进程。此时必须借助一个外部系统,所有进程都去这个系统上申请加锁。而这个外部系统,必须要实现互斥能力,即两个请求同时进来,只会给一个进程加锁成功,另一个失败。
可以使用 Redis 提供的 Setnx 命令可以达到一定的效果。Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。
Setnx K V 加锁
redis 127.0.0.1:6379> SETNX KEY_NAME VALUE
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"
因此在执行业务之前,可以提前调用 Setnx 指令,成功(Setnx key vale 返回 1)则代表获取到锁。其他获取不到锁(Setnx key vale 返回 0)的进程或者线程者进行直接返回。
public static void lock() {
//1.获取客户端
Jedis redis = getJedis();
//2.获取锁
Long lockResult = redis.setnx(key, value);
if (1 == lockResult) {
// 3.执行业务
business();
// 4.释放锁
redis.del(key);
} else {
// 获取锁失败
System.out.println("Can not get lock");
}
}
不考虑异常情况下,这就是一个解决方案了。但是假如在客户端执行 redis.del 失败了(可能网络断开),那么这个锁就永远保存在 redis 服务 中了,其他进程或者线程将永远无法执行业务操作。针对这种情况,可以再提供一种补偿机制,在锁删除失败的情况下依然可以让锁消失。
SET K V EX TIME NX 设置缓存时间加锁
redis 127.0.0.1:6379> SETNX key value EX 100 NX
如果 key 不存在,则设置 key 的值为 VALUE,且缓存时间为 100 秒,100 秒过后消失。
127.0.0.1:6379> SET key value EX 100 NX
OK
127.0.0.1:6379> GET key
"value"
127.0.0.1:6379> SET key value1 EX 100 NX
(nil)
127.0.0.1:6379> ttl key
(integer) 76
那么以上的代码再修改一版如下:
public static void lock() {
Jedis redis = getJedis();
//修改为此指令
String lockResult = redis.set(key, value, "NX", "EX", EXPIRE_SECS);
if ("OK".equalsIgnoreCase(lockResult)) {
business();
redis.del(key);
} else {
System.out.println("Can not get lock");
}
}
如此一来,对无法释放锁这种情况也有了相应的解决方案了。但是,在某一瞬间服务器资源紧缺,客户端 A 执行 business 过慢,超过缓存时间,Redis 删除了 key, 客户端 B 拿到了锁,恰好此时 客户端 A 执行完 business, 删除 key,那么此时删除的 key 显然是客户端 B 设置的 key。因此其他客户也可以拿到锁执行业务,显然这违背了互斥性。有两种解决方法:
1、设置超时时间远大于业务执行时间;
2、删除锁的时候增加判断锁是不是自己的,如果是再删除。
第1种方案存在较大缺陷,设置过期的时间过大,删除键失败则会出现其他进程或者线程长时间无法获取锁执行业务,显然不可接受。
那么尝试使用第2种,把上面的代码修改一版:
private static void lock(String lockKey, String lockValue) {
Jedis redis = getJedis();
String lockResult = redis.set(lockKey, lockValue, "NX", "EX", EXPIRE_SECS);
if ("OK".equalsIgnoreCase(lockResult)) {
executeBusiness();
String presentValue = redis.get(lockKey);
//判断是否是自己的,是自己的再删除
if (lockValue.equalsIgnoreCase(presentValue)) {
redis.del(lockKey);
System.out.println("lock deleted");
}
} else {
System.out.println("Can not get lock");
}
}
从上面可以看到,获取当前客户端设置的值、判断是不是自己的锁、删除设置的值这不是原子性,如果在某一步发生阻塞,且在这期间客户端设置的值刚好过期,其他客户端便可以操作业务,也违背了互斥性。
那么如何保证这几步是原子性的呢,Redis提供了Lua脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令的原子性。
local keyName = redis.call('get',KEYS[1])
-- 比较线程标示与锁中的是否一直
if (ARGV[1] == keyName) then
-- 删除锁
redis.call('del',KEYS[1])
return 1
else
return 0
end
将此代码封装到代码中,再修改一版:
private static void lock(String lockKey, String lockValue) {
Jedis redis = getJedis();
String lockResult = redis.set(lockKey, lockValue, "NX", "EX", EXPIRE_SECS);
if ("OK".equalsIgnoreCase(lockResult)) {
executeBusiness();
delLock(lockKey,lockValue);
} else {
System.out.println("Can not get lock");
}
}
private static void delLock(String lockKey, String lockValue) {
String luaScript = "local keyName = redis.call('get',KEYS[1])\n" +
"\n" +
"if (keyName == ARGV[1]) then\n" +
" redis.call('del',KEYS[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
//加载脚本
String script = redis.scriptLoad(luaScript);
Object delResult = redis.evalsha(script, Arrays.asList(lockKey), Arrays.asList(lockValue));
}
至此基本完成一把可用的分布式锁,看来实现分布式锁并不是简单的事情。为了确保分布式锁可用,需要确保锁的实现同时满足以下五个条件:
- 互斥性:在任意时刻,只有一个客户端能持有锁;
- 高可用:一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证其他客户端能加锁;
- 安全性:加锁和解锁必须是同一个客户;
- 高性能:加锁和解锁需要开销尽可能低;
- 容错性:集群只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
当前分布式锁的缺陷
以上基于 setnx 可以满足大多数要求不高的系统。但是依然存在一些问题:
- 不可重入:同一个线程无法多次获取同一把锁;
- 不可重试:获取不到锁的线程直接返回了,没有重试机制;
- 无法保证一致性:在集群环境下,master宕机且来不急主从同步,出现锁失效。
显然实现尽善尽美的分布式锁不简单,好在 Redisson 实现了以上对于分布式锁的要求。但这不妨碍此篇对分布式锁实现的思考,下篇就来分析 Redisson 对分布式锁的实现,看看 Redisson 如何保证这几大条件和解决以上的缺陷。