前面我们已经知道了,每个集群模式下出现线程并发问题,是因为每个集群节点对应一个JVM,没有JVM维护之间的锁监视器,只能将JVM内部的线程锁住。因此,我们现在应该弃用JVM内部的锁监视器,使用一个公用的,脱离JVM之外的锁监视器。
满足集群模式或者分布式系统下,多进程可见并且互斥的锁。
1)互斥
2)多进程可见
3)高可用性 -> 大多数情况下获取锁应该是成功的
4)高性能 ->加锁本身就导致线程串行,所以获取锁的这个动作应该是迅速的5)安全性 ->避免死锁
分布式锁的实现一般有三种,可以采用mysql、redis、zookeeper来实现
对于这三种方法的实现区别和各自的优缺点,后面会找时间专门写一篇文章做分析,现在先简单给出他们的区别:
使用set方法,为实现互斥,参数会给到NX,另外,为了防止业务执行过程中服务器宕机导致所无法释放,我们还会添加过期时间相关参数。同时,由于是在set命令中给到所需的参数,所以实现互斥与设置过期时间这两个操作是原子性的,要么同时成功,要么同时失败。
业务执行结束后以删除key的方式释放锁,或者业务超时key到达过期时间也会释放锁。
采用非阻塞模式:获取不到锁不会阻塞(以免消耗CPU资源)也不会循环重复去获取锁,而是直接返回一个结果。
如上图,线程1成功获取锁后执行业务,但是在业务执行过程出现阻塞,出现业务超时,锁被释放。接着,线程2成功获取锁,执行业务,但是在执行业务过程中,线程1的业务执行完成了,于是它不分青红皂白,就把线程2刚刚拿到的锁给释放了。这样一来,线程3拿到锁后,执行业务,就会出现线程2与线程3并行地执行业务,可能会出现线程安全问题。
线程1把线程2的锁释放了,在线程1释放锁之前,应该先看看这把锁的标识,判断是不是自己的锁,再决定是否释放。
如上图,通过释放锁之前对锁标识的判断,避免了线程2与线程3并行地执行业务。
//每个JVM下的线程id从1开始自增
//使用UUID来区分集群模式下不同的JVM的线程
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
判断锁标识是否是自己的与释放锁两个操作可能不满足原子性!
线程1成功获得锁并顺利执行业务,判断锁标识是自己的后,由于释放锁需要执行fullgc,导致阻塞,线程1持有的锁可能就会超时释放,这样,线程2获得锁,执行业务,但是就在执行业务时,线程1恢复运行,不再阻塞,于是释放锁,但是现在释放的锁已经不是线程1的了,而是线程2的锁,这样,还是出现了释放别人的锁的情况,于是,后面线程3获取锁后执行业务,出现了线程2与线程3并行地执行业务。
使用Lua脚步调用redis命令将判断锁标识和释放锁两个操作放在一起,同时执行。
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);
}
同一个线程可能在一个方法中调用了另一个方法,而这两个方法中可能需要获取同一把锁,需要具备可重入条件。
如何解决这些问题->redission
redisson是一个基于redis实现的分布式工具的集合。
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();
}
}
通过一个哈希结构,key为锁的名称,value包含两部分,一个是field表示线程标识,一个是value表示重入次数。
每次线程获取锁,则会先判断这个锁是否存在,若不存在,则获取锁并添加线程标识,同时设置锁的有效期。如果存在,先判断锁是否是自己的,如果不是,则获取锁失败。是自己的,则将重入次数+1并更新锁的过期时间(因为有新的业务开始)。
每次线程释放锁,同样会先判断这个锁是不是自己的,若不相符,说明不是自己的锁,不能释放。如果是则将重入次数-1并更新锁的过期时间。之后判断重入次数是否减为0,不为0则说明还有业务在进行,更新锁的过期时间。为0说明业务都执行结束了,则可以释放锁。
具体流程图如下:
另外,和前面一样的,获取锁和释放锁的操作redisson是通过lua脚本来实现的。
redisson在释放锁的时候,会发布一条消息让别人知道哪个线程已经成功释放了锁:
当一个线程尝试获取锁的时候,发现锁被占用,这时不会立即重试,而是等待锁释放的消息(当然这里不会无限等待,如果等待时间超过了设置的最长等待时间,会直接返回false),而如果在最长等待时间内收到锁释放的消息,还会判断剩余等待的时间是否<=0,是则return false,否则就可以开始重新尝试获取锁了。而当没有获取成功时,也不会立即重试,而是要等待锁释放发出信号量,然后获得信号量后,还是判断是否还有剩余等待时间,还有则进入循环,再次尝试获取锁。
在获取锁成功后,会通过watchdog(看门狗),每个一段时间(设置的超时等待时间/3)就会更新超时释放时间,重置超时释放时间。
当一个线程向主节点获取了锁后,这个时候主节点宕机,选择一个从节点成为主节点后,新的主节点还没来得及同步数据,这个时候其它线程过来就可能能够获取到这个把锁,导致原来的锁失效了,就可能出现并发安全问题。
一主多从的情况下,会发生主从一致问题,所以redisson直接抛弃了这种模式,让每个节点独立起来,每个节点都可以进行读写操作,也就没有了主从不一致的问题。每次获取锁时,线程会向每个节点获取锁,只有每个节点都保存了这把锁的线程标识,这个时候才算获取锁成功。即使有一个节点宕机,还是可以正常运作。
而且,就算每个节点有自己的从节点,然后有一个节点出现宕机,也不会发生线程安全问题,因为不能从其它没有宕机的节点拿到锁。
而redisson的这种连锁机制,就是采用multiLock来实现的
原理:利用 setnx 的互斥性;利用 ex 避免死锁;释放锁时判断线程标识防止释放别人的锁
缺陷:不可重入、无法重试、锁超时失效
原理:利用 hash 结构,记录线程标识和重入次数;利用watchDog延续锁时间;利用发布订阅消息、信号量控制锁重试等待
缺陷:redis宕机引起锁失效问题
原理:多个独立的Redis节点,必须在所以节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂