简单介绍:此处的分布式锁,可以简单理解为服务部署在 >= 2台机器上,我们想要同一个时间,只有一个机器可以加锁成功,保证业务的成功。
要保证多台机器获取到同一个锁,则锁名是一定的。暂定叫他:myLock
锁名确定了之后,机器A-服务A
,机器B-服务A
,机器C-服务C
,只有一个可以获取到锁。大功告成。
考虑,上面三个服务在执行过程中,有可能正常结束,有可能抛出异常,所以我们需要保证 能在程序正常和异常结束情况下,都要释放锁,保证其他服务可以使用
使用语言特性,保证代码执行结束会自动释放锁
public void execute() {
// 加锁
redis.lock()
try {
doSomething();
// ...省略其他代码
} finally {
// 释放锁
redis.unLock();
}
}
假设机器A-服务A
获取到了锁,但是在执行到下图位置时,服务或者机器挂掉了,此时,锁就无法被释放了。其他服务无法获取该锁。
此时,可以为锁设置超时时间,在一定时间过后,锁自动删除,保证在 机器宕机情况下,锁也会在到期后自动释放,其他服务就有机会获取锁,继续执行服务了
假设我们引入了超时时间机制,超时时间设置多少,就显得比较重要。来看下图:
聪明的你一定看出了一点问题吧。
A执行时间超过了我们预期的10分钟,导致了一系列连锁反应。
至于A为何释放锁,还要归功于我们上面的代码:
要解决上述问题,问题在于超时时间。
超时时间不太好定,这次定为12分钟
,下次执行了15分钟,20分钟…之后。你也觉得每次手动修改超时时间很奇怪对吧。
那我设置超时时间1天可以吗?
正常情况下服务执行完毕后,会自动释放锁,异常情况下,有可能释放不了锁,那我能不能监测到服务故障了,直接释放锁呢?
锁被其他人释放有两种场景:
同一机器上的两个线程
不同机器上的两个服务
解决方式:
添加额外信息,说这个锁属于谁,就不会比别人释放了。如下图
额外信息:属于哪个机器,哪个线程。例如:机器A-线程1号
我们采用等待服务执行完毕后再释放的策略【ps,并非唯一解决方式,此方式只是解决方法中的一种可能】
在本机器启动额外线程来不断给锁延续超时时间,这样服务挂了,就不会继续延续超时时间了,如下图
可重入锁,指的是,对一把锁多次加锁,计数加1,同样,释放对应次数的锁,才能最终把这个锁释放掉。
众所周知,Java的很多锁都是提供可重入功能的。其实可重入锁,就是指,可以重复获得一把锁。
这个设计其实让我很不理解,为什么要重复获取同一把锁呢?这不是很奇怪吗?
可能是有代码层面的考量,复用其他代码的考量。
但是根据 StackOverflow论坛
和 Go语言开发团队
的一个大佬,观点是可重入锁是会导致BUG的一大来源。
Go语言开发团队大佬原文
Stack overflow论坛说明
不过我还是在这里提一下,什么情况下使用,如何实现。
根据 知乎该链接 可以知道,在如下的情况下,可能会使用到可重入锁,调用子类的 doSomething
方法,会用到本对象来当锁来加锁,子类的doSomething
方法又调用了 父类的doSomething
方法,还是把本对象当锁来加锁,这样,就会重复加锁,如果不使用可重入锁,就会导致子类一直不能获取到父类的锁,卡主,导致死锁。
public class Person{
public synchronized void doSomething(){
........
}
}
public class Student extends Person{
public synchronized void doSomething(){
super.doSomething();
}
}
所以,我们可以在实现分布式锁时,再加一个 使用当前锁的次数来记录加锁了多少次,来保证可以重复对一把锁加锁。
要实现一个完整的,功能众多的分布式锁,满足如下条件
下面为了简化,我们先用伪代码描述流程,随后给出对应的实际代码
public class RedisHelper {
public void lock(String lockName) {
// 直接加锁
redis.lock(lockName)
}
public void unlock(String lockName) {
redis.unlock(lockName)
}
}
public class RedisHelper {
public void lock(String lockName, long expiredTime) {
// 直接加锁
redis.lock(lockName)
// 设置过期时间为x秒
.expireTime(expiredTime, "Seconds")
}
public void unlock(String lockName) {
redis.unlock(lockName)
}
}
public class RedisHelper {
// 为当前机器生成一个随机不重复的id
String ID = UUID.randomId();
public void lock(String lockName, long expiredTime) {
// 直接加锁
redis.lock(lockName)
// 设置过期时间为x秒
.expireTime(expiredTime, "Seconds")
// 属于当前线程
.belongs(ID + currentThread.getId())
}
public void unlock(String lockName) {
redis.unlock(lockName)
}
}
由于篇幅已经够长了,所以,可重入锁和看门狗的实现,会放到下次博客。
redis命令默认是串行单线程执行的。
当我们判断锁是否是某个服务的锁时,会涉及多条命令,如下:
if 锁存在,且锁是A的锁:
删除该锁
如果读者了解 多线程 的观测失效,或者 竞态条件,可以知道,在多线程环境下,是有可能存在判断失效的情况的,如下:
也就是上述代码的第一句话判断完了之后,在执行第二句话之前,图中红色部分被执行了。
那么,就删除错锁了。
如果想要一次性执行多条命令。可以将其放到一个 lua
脚本中,redis
会将其中的全部命令当做一次命令来执行。
借助 Spring
的 redisTemplate
实现。
其中"""
三引号之中的内容属于 Java17
语法,可以使用 Java8
的拼接字符串等来替换。
@Component
public class RedisHelper {
private final RedisTemplate<String, String> redisTemplate;
public static final String UNLOCK_OK = "OK";
public static final String UUID = java.util.UUID.randomUUID().toString();
public RedisHelper(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 加锁,且设置锁过期时间,将锁的归属信息当做value写入
*/
public boolean tryLock(String key, Long timeValue, TimeUnit timeUnit) {
return Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(key, lockValue(), timeValue, timeUnit));
}
/**
* 解锁,解锁成功返回OK,失败返回该锁剩余的生存时间
*/
public Res unLock(String key) {
// 使用lua脚本
RedisScript<String> script = RedisScript.of("""
if (redis.call('get', KEYS[1]) == ARGV[1]) then
redis.call('del', KEYS[1]);
return 'OK';
end;
return tostring(redis.call('pttl', KEYS[1]));
""", String.class);
String result = redisTemplate.execute(script, List.of(key), lockValue());
return new Res(result);
}
/**
* 用于包装结果对象,告诉调用者是否解锁成功
*/
@Data
public static class Res {
private final String value;
public Res(String value) {
this.value = value;
}
public boolean isOk() {
return UNLOCK_OK.equals(value);
}
public boolean isFailed() {
return !isOk();
}
}
// 锁的归属信息
String lockValue() {
return UUID + ":" + Thread.currentThread().getId();
}
}