高并发情况下的缓存穿透:如果某个数据项不存在于缓存中(例如数据库中没有),但是多个请求几乎同时访问此数据项,数据库的查询压力会瞬间增大,造成性能瓶颈。(比如某文章)
用分布式锁来解决
分布式锁:就是当有一个节点获取到锁后,其他节点是不可以获取锁的
为了解决这个文章的缓存击穿问题,我用的是Redis分布式锁,理由如下: Redis分布式锁:它追求的高可用性和分区容错性。Redis在追求高可用性时会在Redis写入主节点数据后,立即返回成功,不关心异步主节点同步从节点数据是否成功。Redis是基于内存的,性能极高,官方给的指标是每秒可达到10W的吞吐量。 Zookeeper分布式锁:它追求的是强一致性和分区容错性。Zookeeper在写入主节点数据后会等到从节点同步完数据成后才会返回成功。为了数据的强一致性牺牲了一部分可用性。
两者综合对比,编程客栈为了追求用户体验度,采用了Redis分布式锁来实现。
论坛项目使用Redis分布式锁的背景是,用户根据articleId查询文章详情,查询出结果返回。
如果并发量很高,若缓存中没有数据,则大量请求会到达MySQL,就会导致缓存击穿问题,所以要用到Redis分布式锁
redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS);
对应的Redis命令是
set key value EX time NX
set key value EX time NX
是一个符合操作,setNx + setEx,底层采用的是lua脚本来保证其原子性,要么全部成功,否则加锁失败。
redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS);
含义是:如果key不存在则加锁成功,返回true;负责加锁失败,返回false;
主要逻辑:当缓存中没有数据时候,开始加锁,加锁成功则允许访问数据库,加锁失败则自旋重新访问
/**
* Redis分布式锁第一种方法
*
* @param articleId
* @return ArticleDTO
*/
private ArticleDTO checkArticleByDBone(Long articleId) {
String redisLockKey =
RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
ArticleDTO article = null;
//加分布式锁:此时value为null,时间为90s(结合自己场景设置合适过期时间,这里随便设置的时间)
Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);
if(isLockSuccess) {
//加锁成功可以访问数据库
article = articleDao.queryArticleDetail(articleId);
} else{
try {
//短暂睡眠,为了让拿到锁的线程有时间访问数据库拿到数据后set进缓存,
//这样在自旋时就能从缓存中拿到数据
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
//加锁失败,采用自旋方式重新拿取数据
this.queryDetailArticleInfo(articleId);
}
return article;
}
问题:虽然我设置了过期时间,但是会出现一种情况:当业务执行之后,锁还被持有着,这是比较消耗Redis缓存资源,正确的做法应该是当业务执行完之后,立即释放锁
/**
* Redis分布式锁第二种方法
* @param articleId
* @return ArticleDTO
*/
private ArticleDTO checkArticleByDBTwo(Long articleId) {
String redisLockKey =
RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
ArticleDTO article = null;
Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);
try {
if (isLockSuccess) {
article = articleDao.queryArticleDetail(articleId);
} else {
Thread.sleep(200);
this.queryDetailArticleInfo(articleId);
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//和第一种方式相比增加了finally删除key
RedisClient.del(redisLockKey);
}
return article;
}
针对第一种问题,锁不能即使释放,我们将其进行优化为当业务执行完成后立即释放锁,则这种方法在业务执行完毕之后,立即删除了key值。
但是会有一个问题,释放别人的锁
释放别人的锁:线程A已经获取到锁,正在执行业务,但是没有执行完成,结果过期时间到了,该锁被释放了;此时线程B可以获取该锁了,且执行业务逻辑,但是此时线程A执行完成需要释放锁,释放的锁是线程B的,也就是释放了别人的锁
针对第二种加锁方式中存在误释放他,人锁的情况,我们可以采用加锁的时候设置个value值,然后在释放锁前判断给key的value是否和前面设置的value值相等,相等则说明是自己的锁可以删除,否则是别人的锁不能删除。
/**
* Redis分布式锁第三种方法
* @param articleId
* @return ArticleDTO
*/
private ArticleDTO checkArticleByDBThree(Long articleId) {
String redisLockKey =
RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
//设置value值,保证不误删除他人锁
String value = RandomUtil.randomString(6);
Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, value, 90L);
ArticleDTO article = null;
try {
if(isLockSuccess) {
article = articleDao.queryArticleDetail(articleId);
}else{
Thread.sleep(200);
this.queryDetailArticleInfo(articleId);
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//这种先get出value,然后再比较删除;这种无法保证原子性,为了保证原子性,采用了lua脚本
/*
String redisLockValue = RedisClient.getStr(redisLockKey);
if(!ObjectUtils.isEmpty(redisLockValue) && StringUtils.equals(value,redisLockValue)) {
RedisClient.del(redisLockKey);
}*/
//采用lua脚本来进行判断,在删除;和上面的这种方式相比保证了原子性
Long cad = redisLuaUtil.cad("pai_" + redisLockKey, value);
log.info("lua 脚本删除结果" + cad);
}
return article;
}
第三种方式解决了误删他人锁的问题,但是还存在一个问题---过期时间的值到底如何设置? 时间设置过短:可能业务还没有执行完毕,过期时间已经到了,锁被释放,其他线程可以拿到锁去访问DB违背了我们的初心。 时间设置过长:过长的话,可能在我们加锁成功后,还没有执行到释放锁,在这一段过程中节点宕机了,那么在锁未过期的这段时间,其他线程是不能获取锁的,这样也不好。 针对这个问题,可以写一个守护线程,然后每隔固定时间去查看业务是否执行完毕,如果没有的话就演唱其过期时间,也就是为锁续期
/**
* Redis分布式锁第四种方法
* @param articleId
* @return ArticleDTO
*/
private ArticleDTO checkArticleByDBFour(Long articleId) {
ArticleDTO article = null;
String redisLockKey =
RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
//获取分布式锁
RLock lock = redissonClient.getLock(redisLockKey);
try {
//尝试加锁,最大等待时间3秒,上锁30秒自动解锁
if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {
//3秒是最大等待时间:如果锁被其他线程持有,当前线程会等待最多3秒来获取锁
//30秒是锁持有时间:当前线程获取锁后,锁将在30秒后自动释放,防止死锁
article = articleDao.queryArticleDetail(articleId);
} else {
// 未获得分布式锁线程睡眠一下;然后再去获取数据
Thread.sleep(200);
this.queryDetailArticleInfo(articleId);
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//判断该lock是否已经锁 并且 锁是否是自己的
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return article;
}
redission首先获取锁( get lock()),然后尝试加锁,加锁成功后可以执行下面的业务逻辑,执行完毕之后,会释放该分布式锁。 redission解决了redis实现分布式锁中出现的锁过期问题,还有释放他人锁的问题 另外,它还是可重入锁:内部机制是默认锁过期时间是30s,然后会有一个定时任务在每10s去扫描一下该锁是否被释放,如果没有释放那么就延长至30s,这个机制就是看门狗机制。 如果请求没有获取到锁,那么它将 while循环继续尝试加锁。