黑马点评项目学习笔记--(4)分布式锁

1.如何实现分布式锁?

前面我们已经知道了,每个集群模式下出现线程并发问题,是因为每个集群节点对应一个JVM,没有JVM维护之间的锁监视器,只能将JVM内部的线程锁住。因此,我们现在应该弃用JVM内部的锁监视器,使用一个公用的,脱离JVM之外的锁监视器。
黑马点评项目学习笔记--(4)分布式锁_第1张图片

2.什么是分布式锁?

满足集群模式或者分布式系统下,多进程可见并且互斥的锁。

3.分布式锁需要满足的特点

1)互斥
2)多进程可见
3)高可用性 -> 大多数情况下获取锁应该是成功的
4)高性能 ->加锁本身就导致线程串行,所以获取锁的这个动作应该是迅速的5)安全性 ->避免死锁

4.如何实现分布式锁?

分布式锁的实现一般有三种,可以采用mysql、redis、zookeeper来实现
对于这三种方法的实现区别和各自的优缺点,后面会找时间专门写一篇文章做分析,现在先简单给出他们的区别:
黑马点评项目学习笔记--(4)分布式锁_第2张图片

1)实现分布式锁的两个基本方法

a.获取锁

使用set方法,为实现互斥,参数会给到NX,另外,为了防止业务执行过程中服务器宕机导致所无法释放,我们还会添加过期时间相关参数。同时,由于是在set命令中给到所需的参数,所以实现互斥与设置过期时间这两个操作是原子性的,要么同时成功,要么同时失败。

b.释放锁

业务执行结束后以删除key的方式释放锁,或者业务超时key到达过期时间也会释放锁。
采用非阻塞模式:获取不到锁不会阻塞(以免消耗CPU资源)也不会循环重复去获取锁,而是直接返回一个结果。

2)实现分布式锁的基本流程

a.实现分布式锁的基本流程–版本1:

黑马点评项目学习笔记--(4)分布式锁_第3张图片

包装类在自动拆箱时可能出现空指针异常?

黑马点评项目学习笔记--(4)分布式锁_第4张图片

版本1的实现流程会出现什么问题?

黑马点评项目学习笔记--(4)分布式锁_第5张图片 如上图,线程1成功获取锁后执行业务,但是在业务执行过程出现阻塞,出现业务超时,锁被释放。接着,线程2成功获取锁,执行业务,但是在执行业务过程中,线程1的业务执行完成了,于是它不分青红皂白,就把线程2刚刚拿到的锁给释放了。这样一来,线程3拿到锁后,执行业务,就会出现线程2与线程3并行地执行业务,可能会出现线程安全问题。

版本1的问题根源在哪?

线程1把线程2的锁释放了,在线程1释放锁之前,应该先看看这把锁的标识,判断是不是自己的锁,再决定是否释放。
黑马点评项目学习笔记--(4)分布式锁_第6张图片
如上图,通过释放锁之前对锁标识的判断,避免了线程2与线程3并行地执行业务。

b.实现分布式锁的基本流程–版本2:

黑马点评项目学习笔记--(4)分布式锁_第7张图片##### 线程标识的格式

//每个JVM下的线程id从1开始自增
//使用UUID来区分集群模式下不同的JVM的线程
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
版本2的实现流程会出现什么问题?

判断锁标识是否是自己的与释放锁两个操作可能不满足原子性!
黑马点评项目学习笔记--(4)分布式锁_第8张图片
线程1成功获得锁并顺利执行业务,判断锁标识是自己的后,由于释放锁需要执行fullgc,导致阻塞,线程1持有的锁可能就会超时释放,这样,线程2获得锁,执行业务,但是就在执行业务时,线程1恢复运行,不再阻塞,于是释放锁,但是现在释放的锁已经不是线程1的了,而是线程2的锁,这样,还是出现了释放别人的锁的情况,于是,后面线程3获取锁后执行业务,出现了线程2与线程3并行地执行业务。

如何解决版本2出现的问题?

使用Lua脚步调用redis命令将判断锁标识和释放锁两个操作放在一起,同时执行。

Lua基本语法


redis中提供了eval命令用于执行Lua脚本,Lua脚本中用redis.call()来调用redis命令。如上图,name和Rose分别作为参数放入KEYS和ARGV两个数组中(Lua中数组索引从1开始,KEYS[1]表示第一个参数)

代码书写:脚本文件的预先读取
//在类加载的时候就将脚本读取好方便直接使用
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}
现如今还存在的问题
黑马点评项目学习笔记--(4)分布式锁_第9张图片

同一个线程可能在一个方法中调用了另一个方法,而这两个方法中可能需要获取同一把锁,需要具备可重入条件。

如何解决这些问题->redission

5.Redisson

redisson是一个基于redis实现的分布式工具的集合。

Redisson配置

1.在pom.xml中添加redisson依赖。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

2.配置Redisson客户端。

import org.redisson.config.Config;
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        //注意导入的时候Redisson的Config
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.70.130:6379").setPassword("111");
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}

3.修改seckillVoucher方法中创建锁对象的流程,不再使用stringRedisTemplate,直接用redissonClient。

@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
private StringRedisTemplate stringRedisTemplate;

@Resource
private RedissonClient redissonClient;

@Resource
private RedisIdWorker redisIdWorker;

//涉及到对两张表的操作,秒杀卷信息表和优惠券订单表
@Override
public Result seckillVoucher(Long voucherId) {
    //1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    //2.判断秒杀是否开始
    if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
        //尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    //3.判断秒杀是否已经结束
    if(voucher.getEndTime().isBefore(LocalDateTime.now())){
        return Result.fail("秒杀已经结束!");
    }
    //4.判断库存是否充足
    if(voucher.getStock() < 1){
        //库存不足
        return Result.fail("库存不足!");
    }
    Long userId = UserHolder.getUser().getId();
    //5.创建锁对象
    RLock lock = redissonClient.getLock("lock:order:" + userId);
    //6.获取锁
    boolean isLock = lock.tryLock();
    if(!isLock){
        //获取锁失败,返回错误或者重试
        return Result.fail("不允许重复下单");
    }
    try {
        //获取代理对象(事务)
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }finally {
        //释放锁
        lock.unlock();
    }
}

Redisson可重入锁原理:

通过一个哈希结构,key为锁的名称,value包含两部分,一个是field表示线程标识,一个是value表示重入次数。

每次线程获取锁,则会先判断这个锁是否存在,若不存在,则获取锁并添加线程标识,同时设置锁的有效期。如果存在,先判断锁是否是自己的,如果不是,则获取锁失败。是自己的,则将重入次数+1并更新锁的过期时间(因为有新的业务开始)。

每次线程释放锁,同样会先判断这个锁是不是自己的,若不相符,说明不是自己的锁,不能释放。如果是则将重入次数-1并更新锁的过期时间。之后判断重入次数是否减为0,不为0则说明还有业务在进行,更新锁的过期时间。为0说明业务都执行结束了,则可以释放锁。

具体流程图如下:
黑马点评项目学习笔记--(4)分布式锁_第10张图片
另外,和前面一样的,获取锁和释放锁的操作redisson是通过lua脚本来实现的。

Redisson锁重试原理

redisson在释放锁的时候,会发布一条消息让别人知道哪个线程已经成功释放了锁:
黑马点评项目学习笔记--(4)分布式锁_第11张图片
当一个线程尝试获取锁的时候,发现锁被占用,这时不会立即重试,而是等待锁释放的消息(当然这里不会无限等待,如果等待时间超过了设置的最长等待时间,会直接返回false),而如果在最长等待时间内收到锁释放的消息,还会判断剩余等待的时间是否<=0,是则return false,否则就可以开始重新尝试获取锁了。而当没有获取成功时,也不会立即重试,而是要等待锁释放发出信号量,然后获得信号量后,还是判断是否还有剩余等待时间,还有则进入循环,再次尝试获取锁。

Redisson分布式锁的基本执行流程:

黑马点评项目学习笔记--(4)分布式锁_第12张图片
在获取锁成功后,会通过watchdog(看门狗),每个一段时间(设置的超时等待时间/3)就会更新超时释放时间,重置超时释放时间。

Redisson分布式锁主从一致问题

一主多从的情况下,会存在什么问题?

当一个线程向主节点获取了锁后,这个时候主节点宕机,选择一个从节点成为主节点后,新的主节点还没来得及同步数据,这个时候其它线程过来就可能能够获取到这个把锁,导致原来的锁失效了,就可能出现并发安全问题。

Redisson如何解决主从一致问题?

一主多从的情况下,会发生主从一致问题,所以redisson直接抛弃了这种模式,让每个节点独立起来,每个节点都可以进行读写操作,也就没有了主从不一致的问题。每次获取锁时,线程会向每个节点获取锁,只有每个节点都保存了这把锁的线程标识,这个时候才算获取锁成功。即使有一个节点宕机,还是可以正常运作。
黑马点评项目学习笔记--(4)分布式锁_第13张图片
而且,就算每个节点有自己的从节点,然后有一个节点出现宕机,也不会发生线程安全问题,因为不能从其它没有宕机的节点拿到锁。
黑马点评项目学习笔记--(4)分布式锁_第14张图片
而redisson的这种连锁机制,就是采用multiLock来实现的

6.总结

1)不可重入的分布式锁

原理:利用 setnx 的互斥性;利用 ex 避免死锁;释放锁时判断线程标识防止释放别人的锁
缺陷:不可重入、无法重试、锁超时失效

2)可重入的Redis分布式锁

原理:利用 hash 结构,记录线程标识和重入次数;利用watchDog延续锁时间;利用发布订阅消息、信号量控制锁重试等待
缺陷:redis宕机引起锁失效问题

3)Redisson的multiLock

原理:多个独立的Redis节点,必须在所以节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂

你可能感兴趣的:(黑马点评项目,学习,笔记,分布式)