前面我们已经在单体服务的环境下使用悲观锁解决了“一人一单”问题,但是在分布式的集群环境下,悲观锁就不行了,如下图所示,悲观锁只在当前的JVM中有效,集群环境有相当多的JVM,每个JVM又监视不同的锁,这样悲观锁就没办法针对用户了。
分布式锁:
满足分布式系统或集群模式下多进程可见并且互斥的锁。
基于Redis的分布式锁
Redis如何保证互斥:set nx
如何防止死锁:expire 设置超时时间
如何保证获取锁和设置超时时间的原子性(要么都成功,要么都失败):
set lock value ex [time] nx
思考一
自定义实现Redis分布式锁的获取锁和释放锁方法。
@Data
@AllArgsConstructor
public class SimpleRedisLock implements ILock{
/**
* 业务名称
*/
private String name;
/**
* 锁的前缀
*/
private static final String KEY_PREFIX = "lock:";
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
long threadId = Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
原本基于JVM锁的实现
//单体服务的悲观锁实现
/* synchronized (userId.toString().intern()) { // <--- !!!在这里上锁!!!
//获取和事务有关的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}*/
分布式锁实现
Long userId = UserHolder.getUser().getId();
/* 分布式锁 */
// 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁
boolean isLock = lock.tryLock(10);
// 判断是否获取锁成功
if (!isLock) {
// 获取锁失败,直接返回错误信息
return Result.fail("不允许重复下单!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
观察下图思考一种情况:如果业务执行的时间较长,甚至比锁设置的过期时间还长,这会导致业务还未完成就释放锁。
试想:线程A获取锁并执行业务,A业务还未完成A锁就释放了,紧接着线程B获取锁并执行业务,而A业务完成了,在上述代码中,A此时直接释放了不是自己的锁,继而导致线程C获取锁并执行业务...,如此下去,分布式锁要求的互斥就在此时失效了,线程安全的问题又发生了。
解决方案:
在释放锁之前,判断锁是不是自己的,是的话才释放,不是就不管了。这样才可以避免可能的线程安全问题。
思考二
优化后:
import cn.hutool.core.lang.UUID;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
@Data
@AllArgsConstructor
public class SimpleRedisLock implements ILock{
/**
* 业务名称
*/
private String name;
/**
* 锁的前缀
*/
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
// 标识不一致就不管
}
}
在上面代码中,为每个线程都设置了专属的线程标识,并将线程标识存入Redis中,这样在释放锁的时候再次获取线程标识,并和Redis中存着的标识进行判断,如果一致才释放锁,不一致就不管。
但是这样就好了吗?观察下面的时序图,由于“判断锁”和“释放锁”是两个动作,无法保证原子性,就有可能出现:判断锁是自己的锁之后,JVM由于垃圾回收等机制导致线程内部发生阻塞,有可能一直阻塞到锁已经超时释放了才继续执行业务,而这样就又发生了释放了其它线程锁的情况,又出现了线程不安全的问题。
所以,必须保证判断锁和释放锁这两个动作的原子性。
思考三 Redis使用Lua代码保证原子性
如何保证?
- Redis提供的事务
可以保证操作的原子性,但不能保证操作的一致性,而且Redis的事务中的多个操作实际上是批处理的。
- Redis的Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基本语法参考
所以现在关键就是将“判断锁”和“释放锁”用Lua脚本表示出来并执行,就可以保证操作的原子性。
用Lua语言表示:
-- 锁的key
local key = "lock:order:5"
-- 当前线程的标识
local threadId = ".........."
-- 获取锁中的线程标识 get key
local id = redis.call('get', key)
-- 比较线程标识与锁中的标识是否一致
if (id == threadId) then
-- 释放锁 del key
return redis.call('del', key)
end
return 0
采用动态传参后:
RedisScript接口由DefaultRedisScript实现
lua脚本文件:
-- 比较线程标识与锁中标识是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
业务代码:
/**
* 业务名称
*/
private String name;
/**
* 锁的前缀
*/
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private StringRedisTemplate stringRedisTemplate;
/**
* 声明RedisScript,DefaultRedisScript是其实现类
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>(); // 实例化
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); // 读取脚本文件
UNLOCK_SCRIPT.setResultType(Long.class); // 指定返回值的类型
}
/**
* lua脚本,execute执行lua脚本,将判断和更新强行作为原子操作
*/
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute( // 标识一致才更改
UNLOCK_SCRIPT, // 参数一指定RedisScript
Collections.singletonList(KEY_PREFIX + name), // 参数二指定Key的List集合,这里使用工具类得到List集合
ID_PREFIX + Thread.currentThread().getId() // 参数三是可变参数,指定Value
);
//
}
基于Redis的分布式锁的问题
- 不可重用性
同一个线程无法多次获取同一把锁。例如:方法A调用方法B,线程在方法A中获取锁,然后执行业务后调用方法B,而方法B中又要获取同一把锁,如果锁时不可重用的,方法B就只能等待锁的释放,而锁又无法释放,因为方法A又在等待调用方法B,所以就出现了死锁。
- 不可重试
获取锁只尝试一次就返回false,没有重试机制。部分业务要求锁是阻塞的、可重试的。
- 超时释放
锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
- 主从一致性
如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现。
Redisson解决问题
Redisson入门
1、引入依赖
<!-- Redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2、配置Redisson客户端
/**
* 配置Redisson客户端
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.58.138:6379").setPassword("6y7u8i9o@cn");
// 创建客户端
return Redisson.create(config);
}
}
3、使用Redission的分布式锁
Redisson可重用性原理
在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。
在redission中,我们的也支持支持可重入锁
上面的流程用java实现已经不能保证操作的原子性了,只能用lua脚本来实现。
lua脚本实现:
local key = KEYS[1]; -- 锁的标识
local threadId = ARGV[1] -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if (redis.call('exists', key) == 0) then
-- 不存在,获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('pexpire', key, releaseTime);
return 1;
end;
-- 锁已经存在,判断threadId是否是自己
if (redis.call('hexists', key, threadId) == 1) then
-- 不存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('pexpire', key, releaseTime);
return 1;
end;
return 0; -- 到这,说明获取的不是自己的锁,获取锁失败
追溯源码:
RedissonLock.java中,针对tryLock和unLock都实现有方法,底层都是lua脚本。
Redisson的锁重试和“看门狗”机制
RLock lock = redissonClient.getLock(KEY)
boolean success = lock.tryLock(WaitTime, LeaseTime, TimeUnit)
这里的参数一waitTime就表示尝试获取锁时的等待时间,如果不填的话就默认一但获取失败就离开不等待。
我们回头看tryLock的Lua脚本,两个if都是判断是否获取锁成功的,并且获取锁成功都返回nil,而最后获取锁不成功就返回锁的剩余有效期。
看调用tryLockInnerAsync的方法。
如果 tryLockInnerAsync返回的ttl(锁的剩余时间)为null,就表示锁是空着的,返回true。
继续看tryLock的方法,如果ttl不为null,则表示锁获取失败,则尝试等待并再次获取锁。
这里的subsribe表示订阅锁。
而在RedissonLock的unLock方法的Lua脚本中,这里的
redis.call('publish', KEYS[2], ARGV[1])
就是在发布通知,表明锁已经释放
锁重试的流程图:
锁释放流程图: