Redis分布式锁 | 黑马点评
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
目录
一、分布式锁概述
在集群模式下synchronize根本锁不住。因为每个都是不同tomcat不同jvm的存在每个jvm的每个锁都可以有一个线程来获取就会出现并行安全问题。
要想解决这种问题必须得想办法让多个jvm只能用同一个锁。分布式锁
分布式锁满足分布式系统或集群模式下多进程可见并且互斥的锁。让多个jvm进程都可以看到锁监视器而且只有一个进程可以拿到锁。
特点多进程可见、互斥、高可用、高性能、安全性
二、基于Redis的分布式锁
1、思路分析
实现分布式锁的两个基本方法
我们获取锁的方法可能会出现问题当我们添加完锁之后还没来得及设置时间突然宕机这个时候就死锁了。所以我们必须保证添加锁和设置时间的时候要原子性一起完成。
我们可以把获取锁的两步修改为NX代表互斥EX是设置超时时间
#添加锁,NX是互斥,EX是设置超时时间
SET lock thread1 NX EX 10
2、初级版本
需求定义一个类实现下面接口利用Redis分布式锁实现一个用户只能下一单的业务
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间过期后自动释放
* @return true代表获取锁成功false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
实现ILock接口
name属性我们希望不同的业务有不同的锁name是业务的名称
锁的参数key是lock业务名称value是锁的线程的id
public class SimpleRedisLock implements ILock{
private StringRedisTemplate stringRedisTemplate;
//锁的名称
private String name;
public SimpleRedisLock(String name,StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
//锁的前缀
private static final String KEY_PREFIX ="lock:";
@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);
}
}
业务层调用实现一人只能下一单
我们这里构造参数传入的时候name不仅仅传入业务名称了还要加上用户id因为只是锁一个用户下一单同一个用户才加锁。所以传入“order:”+userId
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠劵
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if (beginTime.isAfter(LocalDateTime.now())) {
//尚未开始
return Result.fail("活动尚未开始");
}
//3.判断秒杀是否已经结束
LocalDateTime endTime = voucher.getEndTime();
if (LocalDateTime.now().isAfter(endTime)) {
//已结束
return Result.fail("活动已经结束");
}
//4判断库存是否充足
if (voucher.getStock() < 1) {
//库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// synchronized (userId.toString().intern()){
//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean tryLock = lock.tryLock(1200);
//判断获取锁成功
if (!tryLock){
//获取锁失败,返回错误或重试
return Result.fail("一个人允许下一单");
}
//这里可能会异常我们要try一下异常的话finally释放锁
try {
//获取spring事务代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
e.printStackTrace();
}finally {
//释放锁
lock.unlock();
}
// }
return Result.fail("抢购失败");
}
3、误删问题
当线程1正确获取redis锁执行业务业务某种原因阻塞超过时间就会超时释放一旦1提前释放2能获取成功当2正在拿锁做业务时1突然醒了然后执行完毕释放锁这个时候1就把2的锁给释放掉了3或者其他线程就能够进来。
这种情况产生主要是因为线程1把线程2的锁给释放了导致我们可以在释放锁之前加上判断是否是自己的锁我们可以把redis存入的value来当这个线程的标识删除的时候取出来判断一下
4、改进分布式锁
修改之前的分布式锁在获取锁的时候存入线程的标识可以是UUID标识
为什么要用UUID来标识呢我们之前用线程id为什么不行
因为线程id是递增的每个jvm都是这样递增所以不同的tomcat最终线程id是可能相同的
在释放的时候先判断线程是否一致一致释放不一致不释放。
获取锁和释放锁的方法改进
我们这里采用UUID+线程id当做redis的value
public class SimpleRedisLock implements ILock{
private StringRedisTemplate stringRedisTemplate;
//锁的名称
private String name;
public SimpleRedisLock(String name,StringRedisTemplate stringRedisTemplate) {
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 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);
}
}
}
UUID是常量同一个线程生成的UUID是一样的所以可以这样写我们的value还加上的线程id
因为单UUID还是可能会重复的只是概率特别小再加上线程id就不太可能重复了。
5、原子性问题
这种情况当线程1获取锁执行业务完成业务释放锁判断标识一致可以释放这个时候判断完成堵塞了可能是jvm垃圾回收会阻塞所有的代码就没有释放成功如果足够长就触发了超时释放锁。一旦超时释放其他线程2就能获取锁然后执行业务这个时候线程1恢复了但是他已经判断完标识了这个时候直接又把2的锁给释放了然后线程3又能进来执行
所以我们必须保证判断锁和释放锁的原子性。
6、使用Lua脚本解决原子性问题
Redis提供了Lua脚本功能在一个脚本中编写多条Redis命令确保多条命令执行时的原子性
lua脚本是用Lua是一种编程语言我们只要会用基础的操作就可以了
再次改进Redis的分布式锁
需求基于Lua脚本实现分布式锁的释放锁逻辑
释放锁的逻辑改变
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT ;
static {
UNLOCK_SCRIPT =new DefaultRedisScript<>();
//设置脚本的路径就在resource目录下直接名字就可以
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
//设置返回值类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
//调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
//要传入集合所以要转一下
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId());
}
lua脚本
-- 比较线程标示与锁中的标示是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
--释放锁 del key
return redis.call('del',KEYS[1])
end
return 0
7、setnx实现分布式锁存在问题
不可重入线程1先调用A获得锁又要调用B方法需要获取同一把锁如果锁是不可重入的方法A又没有释放锁这个时候就会出现死锁问题。
不可重试我们之前的是没有拿掉锁立刻失败返回false我们有时候需要这个锁被别人获取拿不到我们就等等如果最后成功了我们再执行业务。
超时释放太短的话可能没执行完业务就放太长可能出问题了等待时间较长
主从一致性redis主从之间同步是存在延迟的线程1在主节点获得了锁尚未同步给从节点的时候突然主节点宕机但是替换的从节点没有锁的这个时候其他线程都可以拿到锁了。但是这种情况概率比较低主从的延迟是极低的。
要解决上面这些问题就非常麻烦了所以我们要借助成熟的框架Redisson
三、Redisson
Redisson是Redis的基础上实现的Java驻内存数据网格。不仅提供了一系列分布式java常用对象还提供了许多分布式服务其中就包含了分布式锁的实现。
1、Redisson快速入门
1引入Redisson依赖
2配置Redisson客户端
3使用Redisson的分布式锁
2、Redisson可重入锁原理
我们自己实现的分布所锁不能可重入为什么redisson的可以呢底层怎么实现呢
如图他底层锁的是用的redis的hash结构因为还要存个进入锁的次数key是之前业务名称value分别是线程的唯一标识和进入锁的次数
当每次进入就判断线程是不是之前的线程并让统计数+1执行完业务就让统计次数-1如果统计数为0说明要释放锁了。
因为操作很多所有我们要保证原子性必须用lua脚本来写
获得锁的lua脚本
释放锁的lua脚本
3、Redisson可重试原理
他底层在拿不到锁的时候并没有直接结束而是订阅别人释放锁的信号。
在源码lua中每当有锁释放的时候都会发出异步消息告诉别人已经释放锁了。
所有有人真的释放锁应该会发消息过来我们收到消息再重试。
这个等待多久时间是我们设置的最大等待时间等到最大剩余时间结束了还没拿到锁那就取消订阅返回false。这种是等待释放了再尝试不是一直尝试减少了cpu的消耗
4、Redisson解决超时问题
利用看门狗机制就是在获取成功之后开启一个看门狗的定时任务每隔一段时间就会重置锁的超时时间。
5、Redission主从一致性问题
主从分离主节点来写操作从节点来读操作为了保证数据一致性所有要进行主从同步
但是主从同步会有一定的延迟可能就会产生问题
如果主节点修改完之后还没来得及同步瞬间挂了这就是产生了主从一致性问题
redission的解决策略就很简单直接开三台redis同时来写和读把3台redis的锁合在一起做连锁也可以3台机器都做主从分离也可以不建立。