目录
一、概念及不同分布式锁实现的对比
1、概念
2、特征
3、不同分布式锁实现的对比
二、Redis实现分布式锁的思路
1、获取锁思路
2、释放锁思路
三、代码实现分布式锁
1、准备
2、获取锁
2、释放锁
四、分布式锁的误删锁问题
1、问题
2、原因
五、误删锁的解决方案
1、解决思路
2、代码实现
1.获取锁
2.释放锁
六、分布式锁的原子性问题
1、问题
2、原因
3、解决思路
七、Java操作Redis执行lua脚本相关的API
八、原子性问题的解决方案
1、写入.lua文件
2、准备DefaultRedisScript对象
3、释放锁代码修改
九、基于setnx实现分布式锁的使用
十、基于setnx实现分布式锁的问题
1、不可重入
2、不可重试
3、超时释放
4、主从一致性
5、总结
满足分布式系统或集群模式下多进程可且互斥的锁就叫分布式锁,传统的Synchronized锁,它是在JVM中有一个锁监视器,这个锁监视器仅对当前进程适用,如果将该服务多台服务器进行部署使用该锁则不能保证线程安全问题,此时我们可以使用分布式锁来代替它。
它具有以下特征:多进程可见、互斥、高可用、高性能、安全性。为了保证安全性,后续实现时我们需要考虑异常、死锁等情况
MySQL | Redis | Zookeeper | |
互斥 | 利用它本身的互斥锁机制实现 | 利用setnx互斥命令实现 | 利用节点的唯一性和有序性实现 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接自动释放锁 | 利用ttl到期自动释放,存在问题:如果ttl过长则无效等待时间过长,,如果ttl过短则存在线程安全问题 | 临时节点,断开连接自动释放 |
通过setnx这一命令实现获取锁,如果setnx成功则获取到锁,后续线程只尝试获取一次,如果setnx失败则返回false,为了保证安全性我们setnx后还要给这个锁加过期时间expire ttl,但是如果我们刚执行完setnx还没有执行expire ttl操作时redis宕机则死锁,为了防止我们将setnx 与 expire操作改为一个原子操作:set lock value EX ttl NX 即可
释放锁有两种,一种是通过ttl过期后自动释放,一种是del lock删除即可手动释放,此处我们使用手动删除的方式来实现
首先我们先要创建分布式锁的工具类,在类中定义所需要的字段
import org.springframework.data.redis.core.StringRedisTemplate;
public class RedisDistributedLock {
private String name; // 需要加锁的业务key
private StringRedisTemplate stringRedisTemplate; // 操作redis
private static final String KEY_PREFIX = "lock"; // 加锁key的前缀
// 提供构造方法
public RedisDistributedLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
}
接着我们来实现获取锁的操作
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* 基于redis实现分布式锁
*/
public class RedisDistributedLock {
private String name; // 需要加锁的业务key
private StringRedisTemplate stringRedisTemplate; // 操作redis
private static final String KEY_PREFIX = "lock"; // 加锁key的前缀
// 提供构造方法
public RedisDistributedLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 加锁方法
* @param time 过期时间
* @return 加锁是否成功
*/
public boolean tryLock(long time) {
// 获取线程ID
long threadId = Thread.currentThread().getId();
// 获取锁
Boolean isLock = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name,threadId + "",time, TimeUnit.SECONDS);
// 返回结果 此处需要注意如果直接返回boolean类型会产生拆箱,可能会发送异常,所以使用以下操作
return Boolean.TRUE.equals(isLock);
}
}
/**
* 释放锁
* 将key删除即可
*/
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
针对上述代码,如果有一个线程1获取到了这个锁,正常情况下其他线程获取该锁时则会失败,但是如果由于某些原因,线程1获取到了该锁后执行业务时发送了阻塞,直到锁的过期时间到了自动删除后线程1还在阻塞,此时线程2尝试获取锁时获取成功了,于是他去执行他的业务,这个时候线程1执行完了业务开始尝试去释放锁(删除key)于是它删除成功了,但是它删除的不是自己的锁而是线程2的锁,这个时候线程3又获取到了锁,线程3去执行了业务逻辑,此时多个线程并发执行产生线程安全问题
产生上述线程安全问题的原因注意是线程1把不属于自己的别人的锁给释放了
由于上述问题产生原因是线程把不属于自己的锁给释放了,此时我们可以在线程释放锁的时候进行判断,判断这个锁是不是属于自己的,也就是获取一下这个key的value与自己的value进行对比,但是此时又存在了一个问题就是我们之前存入key的value时是获取了线程的id,在同一个进程下每个线程的id都是递增的不会重复,但是如果不同进程,也就是服务多服务器部署的情况下,就可能存在线程id相同但是线程不同,所以我们需要将value修改一下给它加一个唯一的前缀来区别不同的进程,我们可以通过uuid来实现
private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
/**
* 加锁方法
* @param time 过期时间
* @return 加锁是否成功
*/
public boolean tryLock(long time) {
// 获取线程ID
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean isLock = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name,threadId,time, TimeUnit.SECONDS);
// 返回结果 此处需要注意如果直接返回boolean类型会产生拆箱,可能会发送异常,所以使用以下操作
return Boolean.TRUE.equals(isLock);
}
我们需要修改释放锁的代码
/**
* 释放锁
* 将key删除即可
*/
public void unlock() {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁的线程标识
String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断是否一致
if (threadId.equals(lockId)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
针对上述代码,如果此时线程1获取到了锁,线程1业务阻塞了,发送了上面误删的前置问题,但是它删除锁时进行判断发现锁与自己的线程id不一样,则不会去删除锁,一切正常。但是如果线程1获取到了锁执行完业务后删除锁时,先去reids查询了当前锁的标识,然后在准备执行删除操作时由于如JVM垃圾回收等的问题发送了阻塞,而恰好此时在还没执行删除操作前,这个锁到了过期时间于是自动删除,此时线程2获取到了锁开始执行业务,这个时候线程1的删除锁操作开始执行,将线程2的锁删除了,这个时候线程3又趁虚而入获取到了锁执行业务,又发送了多个线程并发执行的线程安全问题
由于上述释放锁操作中的查询与删除操作不是原子性的
我们可以通过操作使得这两个操作变为一个原子性的操作,Redis提供了Lua脚本功能,在一个lua脚本中我们可以编写多条redis指令,这些指令在执行时是原子性的,在redis中可以通过EVAL命令来调用lua脚本
EVAL script numkeys [key ……] [arg……]
命令 脚本 key个数 多个key 其他参数
在script中也就lua脚本中可以通过KEYS[下标]来获取后面的key,也可以通过ARGV[下标]来获取后面的其他参数,要注意的是lua中下标是从1开始的
不了解lua的话可以去这个网站学习它的基础操作,在redis中我们也是常用基础操作多
Lua 教程 | 菜鸟教程 (runoob.com)https://www.runoob.com/lua/lua-tutorial.html
下面我们将释放锁的逻辑写为lua脚本
-- 查询操作获取锁的线程标识
local id = redis.call('get',KEYS[1])
-- 与当前线程标识进行对比
if (id == ARGV[1]) then
-- 当前线程持有锁,进行删除
return redis.call('del',KEYS[1])
end
return 0
在Java中我们如何操作redis中的EVAL命令让它去执行上述lua脚本呢,在StringRedisTemplate提供了相关的API
public
T execute(RedisScript script,List keys,Object... args) RedisScript
script:lua脚本,其中T返回值类型 List
keys: EVAL命令后的keys Object... args:EVAL命令后的arg
我们需要将上述lua脚本写入一个.lua文件(创建一个)
需要记住该路径
在我们的类中需要准备DefaultRedisScript对象来提前加载这个lua脚本
private static final DefaultRedisScript UNLOCK_SCRIPT; // 加载lua脚本
static {
// 类加载时就初始化
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua存放.lua文件的路径")); // 指定录取去加载
UNLOCK_SCRIPT.setResultType(Long.class); // 返回值类型
}
/**
* 释放锁
* 将key删除即可
*/
public void unlock() {
// 构建keys
List keys = new ArrayList<>();
keys.add(KEY_PREFIX + name);
// 执行lua脚本
stringRedisTemplate.execute(UNLOCK_SCRIPT,keys,ID_PREFIX + Thread.currentThread().getId());
}
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void useLock() {
// 构造业务名称作为锁的key
String key = "order";
// 首先创建出对象
RedisDistributedLock lock = new RedisDistributedLock(key,stringRedisTemplate);
// 获取锁
boolean isLock = lock.tryLock(20);
if (!isLock) {
// 没有获取到,可进行结束或重试操作,此处结束
return;
}
try {
// 获取到了锁执行相关的业务代码
} finally {
// 释放锁
lock.unlock();
}
}
线程1在方法a中获取到了锁,然后在方法a中调用方法b,方法b中也需要获取同一把锁,此时由于方法a中获取了锁,该处锁不可重入则导致死锁,发送线程安全问题
获取锁只获取一次,如果没有获取到锁没有重试机制
虽然超时释放解决了死锁的问题,但是如果业务时间过长大于锁的过期时间则会发送操作安全问题
如果Redis提供了主从集群,主从的同步会有延迟,如果线程1从主节点获取了锁,主节点还没有来的及将锁同步给从节点就忽然宕机,此时从节点没有锁的信息,其他线程就可以获取到锁,从而产生线程安全问题
后续我们使用分布式锁会使用比较成熟的存在的组件