前言、分布式锁相关
1、常见的分布式锁的解决方案有
-
基于数据库级别的
- 乐观锁
- 基于数据库级别的乐观锁,主要是通过在查询,操作共享数据记录时带上一个标识字段(version),通过version来控制每次对数据记录执行的更新操作。
- 悲观锁
- 基于数据级别的悲观锁,这里以MYSQL的innodb为例,它主要通过在访问共享的数据记录时加上for update 关键词,表示该共享的数据记录已经被当前线程锁住了,(行级锁,表级别锁,间隙锁),只有当该线程操作完成并提交事务之后,才会释放该锁,从而其他线程才能访问该数据记录。
- 乐观锁
-
基于Redis的原子操作
- setnx +expire原子操作 (时间问题)
- 主要是通过Redis提供的原子操作setnx+ expire来实现,setnx表示只有当key在redis不存在时才能设置成功,通过这个key需要设置为与共享的资源有联系,用于间接地当做锁,并采用expire操作释放获取的锁。
- setnx +expire原子操作 (时间问题)
-
基于Zookeeper的互斥排它锁
- 主要通过创建有序节点 +Watch机制
- 主要是通过zk指定的标识字符串(通常这个标识字符串需要设置为与共享资源有联系,即可以间接地当作:“锁”)下维护一个临时有序的节点列表NodeList,并保证同一时刻并发线程访问共享资源是只能有一个最小序号的节点,(即代表获取到锁的线程)。该节点对应的线程即可执行访问共享资源的操作。
- 主要通过创建有序节点 +Watch机制
-
基于Redisson的分布式锁
-
推荐
-
时间狗
-
一、redis基于setnx关键字实现分布式锁
1、在Redis中,可以实现分布式锁的原子操作主要是Set和Expire操作。从redis的2.6x开始,提供了set命令如下:
set key value [EX seconds] [PX milliseconds] [NX|XX]
在改命令中,对应key和value,给为应该很熟悉了。
- EX 是指key的存活时间秒单位
- PX 是指key的存活时间毫秒单位
- NX是指当Key不存在的时候才会设置key值
- XX是指当Key存在时才会设置key的值
从该操作命令不难看出,NX机制其实就是用于实现分布式锁的核心,即所谓的SETNX操作,但是在使用setnx实现分布式锁需要注意以下几点:java setIfAbsent === redis-cli setnx
获取锁 1 ,获取不到就是:0
- 使用setnx命令获取“锁”时,如果操作结果返回0.(表示key已经对应的锁)已经不存在,即当前已被其他的线程获取了锁,则获取“锁”失败,反之获取成功
- 为了防止并发线程在获取锁之后,程序出现异常的情况,从而导致其他线程在调用setnx命令时总是返回1而进入死锁状态,需要为key设置一个“合理”的过期时间
- 当成功获取:“锁”并执行完成相应的操作之后,需要释放该“锁”,可以通过执行del命令讲“锁”删除,而在删除的时候还需要保证所删除的锁,是当前线程所获取的,从而避免出现误删除的情况。
- 幂等性问题:
就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用
2、redis实现分布式锁的缺点
基于setnx的分布式存在一种集群缺陷。
1、客户端A从master拿到了锁lock
2、master正要把lock同步到(redis的主从是一种异步处理机制)给slave的时候,突然master因为故障宕机了,导致lock没有同步给slave节点。
3、触发redis的主从切换机制,slave被晋升为master节点。
4、客户端B到master拿lock锁,依然可以拿到。
这样的问题就是:同一把锁被多人同时获取,这样在高并发环境下就会引发很大的故障问题。以上这种情况下:其实就是CAP定理,即保证了数据一致性,不论redis部署是单机、主从、哨兵还是集群都存在这种风险。如何解决呢?
redis之父antirez提出了红锁的算法,就是为了去解决这个问题。
二、基于Redisson实现分布式锁
1、基本指令
实现一次性锁:lock.lock
实现可重入锁:lock.tryLock
-
lock():会阻塞,一直等到能获得锁。无返回值
-
lock(long leaseTime, TimeUnit timeUnit):会阻塞,设定租期,租期到后自动释放锁。无返回值
-
tryLock():不阻塞,只检查一次,即只检查当前时刻能否获得锁。返回boolean
-
tryLock(long time, TimeUnit unit):在设定的time(等待时间)内阻塞,如果能获得锁则立即返回true,超过等待时间仍不能获得则返回false
-
tryLock(long waitTime, long leaseTime, TimeUnit unit):设定了等待时间以及租期
-
unlock():释放锁,如果已经是释放状态则会抛出异常(例如其他线程已经释放,或者租期到释放),只能是持有锁的线程进行释放否则抛出异常。
-
isLock():判断锁是否被占用。注意:不是判断锁是否被当前线程占用,锁被其他线程占用的情况也会返回true
-
isHeldByCurrentThread():判断锁是否被当前线程占用。这个才是判断锁是否被当前线程占用(持有)
其他注意:unlock() 之前最好判断是否锁被占用,才进行解锁
2、续期机制
Redisson 提供了一个续期机制, 只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog。如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。但是如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。
Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP
里面,然后每隔 10 秒 (internalLockLeaseTime
(过期时间) / 3) 检查一下,如果客户端 1 还持有锁 key(判断客户端是否还持有 key,其实就是遍历 EXPIRATION_RENEWAL_MAP
里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。
3、只能释放自己的锁(项目易错点)
在加锁的时候设置一个「唯一标识」作为 value 代表加锁的客户端。SET resource_name random_value NX PX 30000
在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁。
4、可重入锁
Redis Hash 可重入锁
Redisson 类库就是通过 Redis Hash 来实现可重入锁
当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。
退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。
可以看到可重入锁最大特性就是计数,计算加锁的次数。
所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。
5、锁超时时间设置
一般要根据在测试环境多次测试,然后压测多轮之后,比如计算出平均执行时间 200 ms。
那么锁的超时时间就放大为平均执行时间的 3~5 倍。
为啥要放放大呢?
因为如果锁的操作逻辑中有网络 IO 操作、JVM FullGC等,线上的网络不会总一帆风顺,我们要给网络抖动留有缓冲时间。
三、项目实战
1、一次性锁(用户注册)
在分布式锁的获取过程中,高并发产生的多线程时,如果当前线程获取到分布式锁,其他的线程就会全部失败,获取不到的线程,就像有一道天然的屏障一样,永远地阻隔了该线程与共享资源的相见。
此种方式适合于那些在同一时刻,而且是在很长时间内仍然只允许一个线程访问共享资源的场景,比如:用户注册,重复提交,抢红包、提现等业务场景。在开源中间件Redisson中,主要通lock.lock()方法实现。
通过设置key名称来控制锁的粒度:
String key = "redissolock_"+userRegVo.getUserName()
- 锁粒度不能太大,保证用户注册时,只会锁住当前用户,而不会锁其他用户;
- 锁粒度也不能太小,保证能锁住当前用户,不让他进行多次注册。
@Autowired
private RedissonClient redissonClient;
@Override
public void regUserRedissionLock(UserRegVo userRegVo) {
// D yykk_lock 1 ?--晚于 A B C
// 1: 设置redis的sexnx的key,考虑到幂等性,这个key有如下做法
// 前提是:前userRegVo.getUserName()必须唯一的,
// 为什么这样要唯一:A 注册 B也能够注册
String key = "redissolock_"+userRegVo.getUserName() ;
// 获redisson分布式锁
RLock lock = redissonClient.getLock(key);
try {
// 访问共享资源前上锁
// 这里主要通过lock.lock方法进行上锁。
// 上锁成功,不管何种情况下,10s后会自动释放。
lock.lock(10,TimeUnit.SECONDS);
// 根据用户名查询用户实体信息,如果不存在进行注册
LambdaQueryWrapper<UserReg> userRegLambdaQueryWrapper = new LambdaQueryWrapper<>();
// 根据用户名查询对应的用户信息条件
userRegLambdaQueryWrapper.eq(UserReg::getUserName, userRegVo.getUserName());
// 执行查询语句
UserReg userReg = this.getOne(userRegLambdaQueryWrapper);
if (null == userReg) {
// 这里切记一定要创建一个用户
userReg = new UserReg();
// 使用BeanUtils.copyProperties进行两个对象相同属性的的复制和赋值,如果不同自动忽略
BeanUtils.copyProperties(userRegVo, userReg);
// 设置注册时间
userReg.setCreateTime(new Date());
// 执行保存和注册用户方法
this.saveOrUpdate(userReg);
} else {
// 如果存在就返回用户信息已经存在
throw new RuntimeException("用户信息已经注册存在了...");
}
} catch (Exception ex) {
log.error("---获取Redisson的分布式锁失败!---");
throw ex;
} finally {
// ---------------------释放锁--不论成功还是失败都应该把锁释放掉
// 不管发生任何情况,都一定是自己删除自己的锁
if (lock!=null) {
//释放锁
lock.unlock();
// 在一些严格的场景下,也可以调用强制释放锁
//
//
// lock.forceUnlock();
}
}
}
2、可重入锁(解决商品超卖问题)
分布式锁的可重入是指:当高并发产生多线程时,如果当前线程不能获取分布式锁,它并不会立即抛弃,而是会等待一定时间,重新尝试去获取分布式锁,如果可以获取成功,则执行后续操作共享资源的步骤,如果不能获取到锁而且重试的时间到达了上限,则意味着该线程将被抛弃。
lock.tryLock(10,seconds) 表示当前线程在某一个时刻如果能获取到锁,则会在10秒之后自动释放,如果不能获取到锁,则会一直处于进入尝试的状态。
// A线程 拿到锁 时间只有10s中,如果10s执行不完,会自动释放
// B线程不会释放,阻塞在位置,但是它最多只能阻塞100s
注册 1w lock 30s 1
下单 1w lock 30s 0.01s 100s 几千单 key
lock.tryLock(100,10,seconds) ,表示这个上限是100s
直到尝试的实际达到一个上限 尝试加锁,最多等待·100s 上锁以后10s会自动释放。
//定义Redisson的客户端操作实例
@Autowired
private RedissonClient redissonClient;
/**
* 处理书籍抢购逻辑-加Redisson分布式锁
* @param dto
* @throws Exception
*/
@Transactional(rollbackFor = Exception.class)
public void robWithRedisson(BookRobDto dto) throws Exception{
final String lockName="redissonTryLock-"+dto.getBookNo()+"-"+dto.getUserId();
RLock lock=redissonClient.getLock(lockName);
try {
Boolean result=lock.tryLock(100,10,TimeUnit.SECONDS);
if (result){
//TODO:真正的核心处理逻辑
//根据书籍编号查询记录
BookStock stock=bookStockMapper.selectByBookNo(dto.getBookNo());
//统计每个用户每本书的抢购数量
int total=bookRobMapper.countByBookNoUserId(dto.getUserId(),dto.getBookNo());
//商品记录存在、库存充足,而且用户还没抢购过本书,则代表当前用户可以抢购
if (stock!=null && stock.getStock()>0 && total<=0){
//当前用户抢购到书籍,库存减一
int res=bookStockMapper.updateStockWithLock(dto.getBookNo());
//如果允许商品超卖-达成饥饿营销的目的,则可以调用下面的方法
//int res=bookStockMapper.updateStock(dto.getBookNo());
//更新库存成功后,需要添加抢购记录
if (res>0){
//创建书籍抢购记录实体信息
BookRob entity=new BookRob();
//将提交的用户抢购请求实体信息中对应的字段取值
//复制到新创建的书籍抢购记录实体的相应字段中
entity.setBookNo(dto.getBookNo());
entity.setUserId(dto.getUserId());
//设置抢购时间
entity.setRobTime(new Date());
//插入用户注册信息
bookRobMapper.insertSelective(entity);
log.info("---处理书籍抢购逻辑-加Redisson分布式锁---,当前线程成功抢到书籍:{} ",dto);
}
}else {
//如果不满足上述的任意一个if条件,则抛出异常
throw new Exception("该书籍库存不足!");
}
}else{
throw new Exception("----获取Redisson分布式锁失败!----");
}
}catch (Exception e){
throw e;
}finally {
//TODO:不管发生何种情况,在处理完核心业务逻辑之后,需要释放该分布式锁
if (lock!=null){
lock.unlock();
//在某些严格的业务场景下,也可以调用强制释放分布式锁的方法
//lock.forceUnlock();
}
}
}