通过加锁可以解决在单机情况下的一人一单问题,但在集群下就不行了
在集群下,或者在分布式系统下,每个线程有自己的jvm,多个jvm存在,每个jvm都有自己的锁监视器,会有多个线程获得锁,可能出现安全问题,所以我们要想办法,让多个jvm使用同一把锁
即:
关键是让多个进程看到同一个锁,并且只有一个线程能拿到锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
特点:
可以看到虽然redis服务宕机后不能自动释放锁,但是redis从高可用和高性能上来讲是很好的,所以接下来学习一下如何基于redis实现分布式锁
实现分布式锁肯定要实现的两个方法是:获取锁和释放锁
使用set nx ex 获取锁,并设置超时时间
//添加锁,nx是互斥(即这个key不存在才能创建),ex是设置超时时间
set lock thread1 nx ex 10
//释放锁,删除即可
del key
先写一个锁的接口
public interface ILock {
/**
* 获取锁
*
* @param timeoutSrc
* @return
*/
boolean tryLock(long timeoutSrc);
/**
* 释放锁
*/
void unlock();
}
写对应的实现类,实现获取锁和释放锁的方法,记着获取该线程的唯一标识,用Thread.currentThread().getId()
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSrc) {
//作为线程的唯一标识,获取线程的id
long threadId = Thread.currentThread().getId();
//当这个key不存在时再执行,实现了互斥
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSrc, TimeUnit.SECONDS);
//包装类怕有空指针异常
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
接下来在服务层改写原先的创建锁逻辑
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock(stringRedisTemplate, "order" + userId);
//获取锁方法
boolean lockFlag = redisLock.tryLock(1200);
//判断是否获取成功
if (!lockFlag) {
return Result.fail("不要重复下单");
}
//事务方法执行起来可能会出现异常,但最后都要释放锁,所以try-catch起来
try {
//获取当前的代理对象(事物)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
redisLock.unlock();
}
现在在apifox中用同一个用户的token向不同线程的程序发送请求
同一个用户在不同线程发送请求,只有其中一个请求能获得锁,库存也只减少了1,实现了分布式锁的功能
有一种极端情况,线程1获取到锁,但业务阻塞时间太长,已经超过了锁的超时释放时间,锁被释放了,这时线程2获取到锁,线程2执行业务时线程1业务完成,去释放锁,就把线程2的锁释放了,这时线程3也获取到锁…
出现了线程安全问题 ,最关键的就是在释放锁的时候要判断一下是不是自己线程的锁
详细思路:获取锁的时候存入线程标识,在释放锁的时候获取线程标识并判断是否一致,如果不一致,就什么也不做,就可以避免误删其他线程的锁
修改之前的分布式锁实现,满足:
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
@Override
public boolean tryLock(long timeoutSrc) {
//线程标识
String threadId =ID_PREFIX+ Thread.currentThread().getId();
//当这个key不存在时再执行,实现了互斥
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeoutSrc, TimeUnit.SECONDS);
//包装类怕有空指针异常
return Boolean.TRUE.equals(flag);
}
@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);
}
//标识不一致就不管了
}
}
修改了线程标识的获取实现逻辑,并在释放锁时判断线程标识和锁中的标识是否一致
因为线程1的锁的键值对被我们删除,后来在释放锁时线程标识和锁中的标识就对不上,就不能删除线程2对应的锁,而线程2对应的锁能被线程2释放
还有一种特殊情况,线程1执行完业务并判断锁标识与当前锁标识一致,准备释放锁时发生了阻塞,时间太长导致锁超时释放,线程2这时获取到了锁,在执行业务时,线程1不阻塞了,就把当前的锁释放了(加了uuid锁还被删的原因:前边判断的时候锁还是自己的)。。。
出现了线程安全问题,我们可以用redis事物和乐观锁实现,但这种做法就复杂很多了
推荐使用lua脚本
lua是一种编程语言,redis提供了lua脚本功能,在一个脚本中编写多条redis命令,确保多条命令执行时的原子性,lua基本语法参考网站: https://www.runoob.com/lua/lua-tutorial.html
重点介绍redis提供的调用函数,语法如下:
redis.call('命令名称','key','其他参数'......)
例如我们要执行set name zhuyi,则脚本是这样的:
redis.call('set','name','zhuyi')
如我们要先执行set name zhuyi,再执行get name,则脚本是这样的:
redis.call('set','name','zhuyi')
Local name = redis.call('get','name')
return name
写好脚本之后,要去调用脚本,调用脚本的常见命令如下:
例如,我们要执行redis.call('set','name','zhuyi')
这个脚本,语法如下:
如果脚本中的key,value不想写死,可以作为参数传递。key类型的参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name lvhan
其中name对应KEYS[1],lvhan对应ARGV[1]
-- 比较线程标识与锁中的标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
--释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
先判断当前线程标识与锁中的标识是否一致,一致就释放锁,不一致就不管
RedisTemplate调用lua脚本的api为:
其中,eval相当于execute函数,函数中的script相当于脚本,keys相当于KEYS数组,args相当于ARGV数组,numkeys相当于key的参数个数,也就是keys集合的长度
使用execute函数
脚本类型为RedisScript,这是个类就要去加载lua文件,如果我们每次释放锁都要去读取文件,产生io流,性能不好,所以我们应该提前定义脚本
(该页面通过ctrl+h查看)
RedisScript是一个接口,我们应该使用它的实现类DefaultRedisScript,T是返回类型
3.1. 初始化脚本
通过静态代码块,这个类一加载,脚本初始化就完成了
setLocation()是设置lua脚本位置的,ClassPathResource是在resource目录下寻找
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
//初始化脚本
static {
UNLOCK_SCRIPT = new DefaultRedisScript();
//读取文件位置,classpath就是resource
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
接下来在释放锁的逻辑中调用lua脚本
@Override
public void unlock() {
//调用lua脚本
//Collections.singletonList()单体集合
//释放锁如果不成功,说明锁已经被别人删了,也不用管,所以不用管返回值
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
测试:
线程1获取到锁
线程1的锁,现在删除这条数据
所以线程2获取到锁
线程2的锁
线程1执行完释放锁操作,但是并没有删除redis中的数据
线程2执行完释放锁操作,成功删除
现在判断和删除的操作是在脚本中写的,能保证原子性
基于setnx实现的分布式锁存在以下问题:
同一个线程无法多次获取同一把锁,如果a方法在方法里要去调用b方法,a方法先获取锁,再去调用b方法,但b方法也要获取这个锁,就获取不到了
我们之前获取的锁只尝试一次就返回false,没有重试机制,在很多情况下是不可接受的
可能会有其他的安全隐患,时间长了短了都不行
如果Redis提供了主从集群(主写从读),主从同步存在延迟,当主岩机时,如果从并同步主中的锁数据,可能会有多个线程拿到锁
不推荐亲自实现,我们使用Redisson
Redisson是一个在Redis基础上实现的java驻内存数据网格,提供了一系列的Java常用对象,提供了很多分布式服务,其中就有各种分布式锁的实现
官网:https://redisson.org
github:https://github.com/redisson/redisson
有两种方式,不推荐使用springboot-starter那种,因为会替代spring官方提供的对redis的配置和实现,我们自己写配置
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.13.6version>
dependency>
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
//配置类
Config config = new Config();
//添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServer()添加集群地址
//有密码还要加密码
config.useSingleServer().setAddress("redis://localhost:6379");
//创建客户端
return Redisson.create(config);
}
}
修改获取锁和释放锁的逻辑,通过redisson客户端的方法获取锁,如果获取不到就不重试了,所以redisLock.tryLock()里面我们不传入时间
//创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
//获取锁方法
boolean lockFlag = redisLock.tryLock();
//判断是否获取成功
if (!lockFlag) {
return Result.fail("不要重复下单");
}
//事务方法执行起来可能会出现异常,但最后都要释放锁,所以try-catch起来
try {
//获取当前的代理对象(事物)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
redisLock.unlock();
}
使用set nx 实现的分布式锁无法实现一个线程多次获取同一把锁,我们用redisson实现
当我们获取锁的时候,当我们判断这个锁已经有人的情况下,判断一下是否是同一个线程,如果是同一个线程,我们也让它获取锁,需要一个计数器记录一下重入的次数,在获取锁时这个数不断加1,释放锁时这个数不断减1
在redis里我们不光要记录线程标识,还要记录重入的次数,所以我们要用到hash结构,其中key是锁的名称,field是线程标识,value是重入的次数
在内部被调用的方法里边,释放锁的时候是不能直接删除锁,要使重入的次数减1,当所有的业务执行完,走到最外层方法的时候,释放锁才能删除锁,即等到重入的次数为0时
为了保证原子性,用lua脚本写逻辑
获取锁的lua脚本:
local key = KEYS[1]; --锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTime = ARGV[2]; --锁的自动释放时间
--判断锁是否存在
if(redis.call('exists',key) == 0 ) then
--不存在,直接获取锁
redis.call('hset',key,threadId,'1');
--设置有效期
redis.call('expire',key,releaseTime);
return 1;--返回结果
end;
--锁存在,判断是否在同一个线程
if(redis.call('hexists',key,threadId) == 1 ) then
--在同一个线程,重入次数加1
redis.call('hincrby',key,threadId,'1');
--设置锁有效期
redis.call('expire',key,releaseTime);
return 1;--返回结果
end;
return 0; --走到这一步说明不在当前获取锁的线程,获取锁失败
获取锁的lua脚本:
--释放锁
local key = KEYS[1]; --锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTime = ARGV[2]; --锁的自动释放时间
--判断锁是否是自己的
if(redis.call('hexists',key,threadId) == 0 ) then
return nil;
end;
--是自己的,锁计数减1
local count = redis.call('hincrby',key,threadId,-1);
--判断锁计数是否为0
if(count > 0) then
--大于0说明不能释放锁,重置有效期
redis.call('expire',key,releaseTime);
return nil;
else -- 等于0说明可以释放锁
--释放锁
redis.call('del',key);
return nil;
end;
原理:
利用信号量和pubsub功能实现等待、唤醒、获取锁失败的重试机制
第一次尝试获取锁失败后,不是立即失败,而是通过订阅消息,等待释放锁的消息,而成功获取锁的线程在释放锁的时候会发出这个消息,但是也不是无限的重试,有一个等待时间,如果超出这个等待时间,就不再尝试。因为这种等待唤醒的机制,cpu占用不高
开门狗机制:watchdog,超时续约,当线程成功获取锁之后,会开启一个定时任务,每隔一段时间就去重置锁的超时时间
获取联锁
RLock lock1 = redissonClient.getLock("order:1");
RLock lock2 = redissonClient.getLock("order:2");
RLock lock3 = redissonClient.getLock("order:3");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2, lock3);
原理:
多个独立的redis节点,必须在所有节点都获取重入锁,才算获取锁成功