锁在应用开发中使用非常广泛,哪些场景需要使用锁呢?
我们先来看抢购优惠卷的场景,代码如下:
public void rushToPurchase() throws InterruptedException {
//获取优惠券数量
Integer num = (Integer) redisTemplate.opsForValue().get(“num”);
//判断是否抢完
if (null == num || num
throw new RuntimeException(“优惠券已抢完");
}
//优惠券数量减一,说明抢到了优惠券
num = num - 1;
//重新设置优惠券的数量
redisTemplate.opsForValue().set("num", num);
}
流程图如下:
当有多个线程同时访问这段代码时可能出现的状况:
线程1查询优惠卷假设查出来是1张,还没有扣减库存时线程2也查询优惠卷也是1张,都满足扣减库存的条件,接着线程1扣减库存变成0张,线程2也接着扣减库存,于是出现超卖。
这就是在多线程下的超卖问题,如何解决呢?
可以通过加锁来解决,通常我们可以在这段代码上加上syconized锁,加锁以后一次只能有一个线程进入,扣减库存完毕后其他线程才能进入,就可以避免超卖问题,代码如下:
public void rushToPurchase() throws InterruptedException {
synchronized (this){
//查询优惠券数量
Integer num = (Integer) redisTemplate.opsForValue().get("num");
//判断是否抢完
if (null == num || num
throw new RuntimeException("商品已抢完");
}
//优惠券数量减一(减库存)
num = num - 1;
//重新设置优惠券的数量
redisTemplate.opsForValue().set("num", num);
}
}
问题是syconized锁是基于JVM的,只能在在单机下有效,如果在分布式的环境下就要用到分布式锁:
分布式锁是单独的部署在一台服务器上的如图:
线程一获取分布式锁以后,其他本机线程或其它机器上的线程就都不能再获取锁。直到锁被释放。
实现分布式锁有多种方式,最为常见的是:
基于数据库实现,基于Redis缓存实现,基于Zookeeper实现
我们来看一下基于Redis如何实现分布式锁:
Redis实现分布式锁主要利用了redis的setnx命令,setnx就是set if not exits(如果不存在则set)的简写,也就是在保存缓存的时候会去检测该key是否已经存在如果不存正则正常设置值,如果存在则不设置。如果设置成功则获取锁,否则不能获取锁。
可以执行这个命令获取锁:SET lock value NX EX 10
含义是将键lock的值设置为value,但只有在该键不存在的情况下才执行设置操作,并且设置的键值对会在 10 秒后自动过期。
具体解释如下:
SET: 设置给定键的值。
lock: 键的名称为 "lock"。
value: 键的值为 "value"。
NX: 只有当键不存在时才执行设置操作。
EX 10: 设置键值对的过期时间为 10 秒。在 10 秒后,键值对会自动从Redis中删除。
这个命令可以分成两个步骤1.设置键值对2.设置超时时间。这两个步骤是否可以分开呢?
答案是不能,如果分开就不满足原子性在多线程执行时有可能出现问题。当写成一条命令时通过lua能够保证其原子性。
释放锁直接删除key就可以:DEL key
这个命令里设置了超时时间,试想一下如果没有设置超时时间会发生什么?
如果一个线程获取锁在执行业务的过程中发生了宕机,导致锁无法释放其他线程无法获取锁这样就发生了死锁。
流程图如下:
问题又来了,既然要设置超时时间那这个时间到底设置多少合适呢?如果时间太短可能导致业务还没有执行完就已经超时,锁被释放,其他线程获取锁不满足互斥的要求。
我们可以通过锁续期来解决这个问题,锁续期(Lock Renewal)是指对已经获取的锁进行更新或延长其有效期。当一个进程或线程获得了一个分布式锁后,在锁的有效期内,它可以通过定期更新锁的时间戳或重置锁的过期时间来延长自己持有锁的时间。要自己实现锁续期相当麻烦,在实际应用中一般使用redisson来做分布式锁。
Redis的客户端redisson实现的分布式锁使用了锁续期的方案。
下面我们来看一下使用redisson实现分布式锁的执行流程。
在redisson中有一个看门狗机制,线程一获取锁成功,操作redis,完毕释放锁在此期间每隔一段时间会去做一次续期,所谓续期也就是每隔一段时间(过期时间/3)检查一下看看业务有没有执行完,如果没有执行完则将过期时间设置为初始值30秒。这里的过期时间是redisson自定义的过期时间默认30秒。
在线程一持有锁的过程中线程二尝试获取锁不成功,他会不断尝试去获取锁(可以自定义等待时间,超时后返回失败),代码如下:
public void redisLock(){
RLock lock = redissionClient.getLock("mylock");
try{
booleanisLock = lock.tryLock(10, TimeUnit.SECONDS);//获取锁,重试时间10秒
if(isLock){
System.out.println("执行业务....");
}
}finally {
lock.unlock();
}
}
使用redisson实现的分布式锁是可重入锁,所谓可重入锁指的是对于同一个线程,可以多次获取同一把锁而不需要等待锁被释放。
例如有这样一段代码:
public void add1(){
RLock lock = redissonClient.getLock(“mylock");
booleanisLock = lock.tryLock();
//执行业务
add2();
//释放锁
lock.unlock();
}
public void add2(){
RLock lock = redissonClient.getLock(“mylock");
booleanisLock = lock.tryLock();
//执行业务
//释放锁
lock.unlock();
}
在add1方法中获取了锁:mylock,add1方法调用了add2方法,add2方法中又一次获取同样的锁。因为redisson实现的锁是可重入锁,所以这样是可以获取的。Redisson利用hash结构记录线程id和重入次数:
Key是锁的名字,value值中的field是线程名,value是重入的次数。
另外我们看看在主从集群模式下redisson实现分布式锁的主从一致性。
在主从集群模式下因为主节点负责写,从节点负责读,当线程在主节点上获取锁,在执行同步操作的时候主节点宕机,写入的key还没来得及同步到从节点,依照主从集群的机制,会有一个从节点变成主节点。此时如果另一个线程在新的主节点上获取锁是可以获取到的,同步锁的互斥机制就被破坏了。
如何解决这个问题呢?
可以使用红锁,RedLock(红锁)指的是在多个redis实例上创建锁(redis实例个数 / 2 + 1),避免在一个redis实例上加锁。
红锁的机制虽然可以解决主从一致性问题,但是它实现复杂,性能差,维护起来也很不方便,因此在实际应用中通常不被采用,如果业务中非要保证数据的强一致性,建议采用zookeeper实现的分布式锁。