上一篇博客我们讲到秒杀问题的一人一单在单机模式下使用synchronized添加悲观锁能解决并发问题。
但是在集群模式下,我们使用悲观锁就无法解决并发问题,因为集群中每个java虚拟机不是共用一个锁,而是每个java虚拟机都拥有属于自己的锁,因而就无法保证在并发模式下的一人一单问题。
怎么解决呢?
那就要引入我们今天的要学习的内容—分布式锁。
我们这里先简要介绍一下分布式锁原理:集群模式悲观锁失效是因为每个JVM有其独自的锁,而集群模式就必须共享同一个锁,因此,我们取消每个JVM中的锁,让所有的JVM都去共用同一个锁监视器,这就是分布式锁的基本思想。
分布式锁:满足分布式系统或者集群模式下多进程可见并且互斥的锁。
实现效果:同一时刻只有一个线程能拥有互斥锁,只有该线程释放互斥锁,其他线程才能获得互斥锁的使用权。
这里我们要明确一个点,因为我们学习redis需要考虑在高并发模式下,很多线程共享资源的问题。资源共享就会涉及进程阻塞问题,因此每一步操作就需要尽量满足原子性,减少进程阻塞导致的并发问题。
因此,我们在获取锁的方式可以修改为如下方式
将设置互斥锁和超时时间同步完成,是为了解决在设置锁的时候发生进程阻塞导致锁未设置超时时间,导致锁无法释放的问题。
OK,接下来我们就根据以上方法设置我们的第一个分布式锁。
public class SimpleRedisLock implements ILock{
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeSec) {
//获取线程id
String threadId = ID_PREFIX+Thread.currentThread();
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeSec, TimeUnit.SECONDS);
//返回的是boolean类型,是基本数据类型,而我们返回的Boolean类型是包装类,避免拆箱出现空指针。
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
这里说明两点
1)什么是拆箱
• 拆箱就是自动将包装器类型转换为基本数据类型
• 拆箱调用Integer.intValue方法
2)什么是装箱
• 装箱就是自动将基本数据类型转换为包装器类型
• 装箱调用的Integer.valueOf方法
我们在尝试获取锁的方法中返回值时基本数据类型,而设置锁返回的时包装器类型,如果我们将其直接返回会涉及拆箱和装箱过程,我们在开发过程中要尽量避免拆箱和装箱,因为可能会出现空指针问题。
在秒杀券的一人一单代码实现之前获取我们设置的分布式互斥锁,添加基本的分布式锁,由于后面执行代码可能抛异常,因此需要try/finally释放锁
//添加基本的分布式锁,由于后面执行代码可能抛异常,因此需要try/finally释放锁
SimpleRedisLock redisLock = new SimpleRedisLock(redisTemplate, "order:" + userId);
boolean flag = redisLock.tryLock(5);
if(!flag){
return Result.fail("一人只能购买一张优惠券");
}
try {
//一人一单
int count = this.query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if(count>0){
return Result.fail("一个用户只能购买一个优惠券");
}
//当更新时查询的库存大于0时进行库存减一
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.gt("voucher_id", 0)
.eq("stock", voucher.getStock()).update();
if (!success) {
return Result.fail("优惠券已被抢完");
}
//6. 创建订单
//6.1 设置id
VoucherOrder voucherOrder = new VoucherOrder();
Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//6.2 设置user_id
voucherOrder.setUserId(userId);
//6.3 设置优惠券id
voucherOrder.setVoucherId(voucherId);
this.save(voucherOrder);
return Result.ok(voucherOrderId);
} finally {
redisLock.unLock();
}
我们结合下面的图进行分析一下使用上述分布式锁可能发生的情况:
我们想想之前是如何解决超卖问题呢?是不是使用了CAS方法。即扣减库存的时候判断库存是否跟之前查询的库存相等,相等再去扣减库存。
方法类似,即我们在删除锁的时候可以设置一个线程标识,标识此时是哪个线程获取了锁。假如一旦获取锁的线程1阻塞,锁超时释放,此时线程2获取了锁,将锁的标识更换了,线程2执行业务的时候线程1唤醒,想要去释放锁,结果发现锁已经不是自己的,就不去释放。问题就解决了。
ok,我们看一下解决的流程图
我们只需要在删除锁的代码进行修改即可。
public void unLock() {
//获取当前线程ID
String currentId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//线程标识
String threadId = ID_PREFIX+Thread.currentThread();
//判断当前线程与线程标识是否相同,相同再进行释放
if (threadId.equals(currentId)) {
//删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
//删除锁
//stringRedisTemplate.delete(KEY_PREFIX + name);
}
上述改进能否完美解决因线程阻塞而导致的锁错误释放问题呢?
我们不妨来看一下下面这张图。
看不明白没关系,我帮你们读懂一下。
怎么解决呢?
我们想想问题的关键是不是因为判断锁和删除锁是分开进行的,因而才会出现线程阻塞问题。那我们能不能将这两步操作改成原子级别的操作呢?
单纯靠java实现可能不太行,这里就要引入我们要讲的主角,Lua脚本。
ok,现在我们基于Lua脚本来改进一下我们的释放锁的逻辑。
Lua脚本
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
public class SimpleRedisLock implements ILock{
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
//代码开始就加载静态代码块,也就是lua脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeSec) {
//获取线程id
String threadId = ID_PREFIX+Thread.currentThread();
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeSec, TimeUnit.SECONDS);
//返回的是boolean类型,是基本数据类型,而我们返回的Boolean类型是包装类,避免拆箱出现空指针。
/*boolean flag = Boolean.TRUE.equals(success);
return flag;*/
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//使用lua脚本执行原子级别的操作,不会因为线程阻塞导致释放锁发生错误。
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX+Thread.currentThread());
}
}
执行lua脚本调用 stringRedisTemplate.execute的API,传递三个参数
经过三个版本的分布式锁迭代,我们设计的锁就基本能够满足我们的使用需求了。