redis缓存常见问题及解决方案
1、缓存穿透
缓存穿透: 是指查询一个不存在的数据,由于缓存无法命中,将去查询数据库,但是数据库也无此记录,并且出于容错考虑,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
-
解决1 :空结果也进行缓存,但它的过期时间会很短,最长不超过五分钟,但是不能防止随机穿透。
-
解决2 :使用布隆过滤器或者Redis的Bitmap来解决随机穿透问题
Redis的Bitmap解决缓存穿透
setbit key offset value
:设置或清除指定偏移量上的位(bit)。offset
是从0开始的位索引,value
可以为 0 或 1。getbit key offset
:返回指定偏移量上的位值。
实例
public solution(){
String key = "sku:product:data";
//查询mysql里面商品skuId
List<ProductSku> productSkuList = productSkuMapper.selectList(null);
productSkuList.forEach(item -> {
//将所有商品的SkUId添加到redis里面的bitmap中
redisTemplate.opsForValue().setBit(key,item.getId(),true);
});
}
// 测试
public void getProductSku(Long skuId) {
//调用商品接口之前 提前知道用户访问商品SKUID是否存在于bitmap中
String key = "sku:product:data";
//根据skuId和可以查询redis中的数据
Boolean flag = redisTemplate.opsForValue().getBit(key, skuId);
if (!flag) {
log.error("用户查询商品sku不存在:{}", skuId);
//查询数据不存在直接返回空对象
throw new ServiceException("用户查询商品sku不存在");
}
}
注意当数据库商品表进行更新时,bitmap也要及时更新。
2、缓存雪崩
缓存雪崩:是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
-
解决1:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
-
解决2:如果单节点宕机,可以采用集群部署方式防止雪崩
// 设置随机过期时间
redisTemplate.opsForValue().set(key,value,time, TimeUnit.SECONDS);
3、缓存击穿
缓存击穿: 是指对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:如果这个key在大量请求同时进来之前正好失效,那么所有对这个key的数据查询都落到db,我们称为缓存击穿。
与缓存雪崩的区别:
- 击穿是一个热点key失效
- 雪崩是很多key集体失效
解决:加锁
当一些key在大量请求同时进来之前正好失效,那么我们需要加锁,只放行一个请求去数据库查询,并把查询到的结果缓存到redis中。后面其他请求进来时都从redis中快速获取数据。
进程内锁:synchronized和lock锁
不能解决多进程之间的多线程并发问题。
public synchronized void testLock() {
// 查询Redis中的num值
String value = (String)this.stringRedisTemplate.opsForValue().get("num");
// 没有该值return
if (StringUtils.isBlank(value)){
return ;
}
// 有值就转成成int
int num = Integer.parseInt(value);
// 把Redis中的num值+1
this.stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
}
进程外锁:分布式锁
分布式锁主流的实现方案:
- 基于数据库实现分布式锁
- 基于缓存( Redis等)
- 基于Zookeeper
每一种分布式锁解决方案都有各自的优缺点:
- 高性能:Redis最高
- 可靠性:zookeeper最高
分布式锁使用的逻辑如下:
尝试获取锁
成功:执行业务代码
执行业务
try{
获取锁
业务代码-宕机
} catch(){
}finally{
释放锁
}
失败:等待(回旋);
代码
/**
* 采用SpringDataRedis实现分布式锁
* 原理:执行业务方法前先尝试获取锁(setnx存入key val),如果获取锁成功再执行业务代码,业务执行完毕后将锁释放(del key)
*/
public void testLock() {
//0.先尝试获取锁 setnx key val
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", "lock");
if(flag){
//获取锁成功,执行业务代码
//1.先从redis中通过key num获取值 key提前手动设置 num 初始值:0
String value = stringRedisTemplate.opsForValue().get("num");
//2.如果值为空则非法直接返回即可
if (StringUtils.isBlank(value)) {
return;
}
//3.对num值进行自增加一
int num = Integer.parseInt(value);
stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
//4.将锁释放
stringRedisTemplate.delete("lock");
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4、数据一致性
在当前环境下,通常我们会首选redis缓存来减轻我们数据库访问压力。但是也会遇到以下这种情况:大量用户来访问我们系统,首先会去查询缓存, 如果缓存中没有数据,则去查询数据库,然后更新数据到缓存中,并且如果数据库中的数据发生了改变则需要同步到redis中,同步过程中需要保证 MySQL与redis数据一致性问题
解决1:使用延时双删策略
延时双删策略是一种常见的保证MySQL和Redis数据一致性的方法。其主要流程包括:先删除缓存,然后更新数据库。这个过程完成后,大约在数据库从库更新后再次删除缓存。具体的步骤如下:
第一步,先执行redis.del(key)操作删除缓存;
第二步,然后执行写数据库的操作;
第三步,休眠一段时间(例如500毫秒),根据具体的业务时间来定;
第四步,再次执行redis.del(key)操作删除缓存。
延时双删策略通过这种方式尝试达到最终的数据一致性,但是这并不是强一致性,因为MySQL和Redis主从节点数据的同步并不是实时的,所以需要等待一段时间以增强它们的数据一致性。同时,由于读写是并发的,可能出现缓存和数据库数据不一致的问题
//修改
@Transactional
@Override
public int updateProduct(Product product) {
//1 删除缓存(获取spu下的sku id列表)
List<Long> skuIdList = product.getProductSkuList().stream()
.map(ProductSku::getId).collect(Collectors.toList());
//从redis中删除每个sku的缓存
skuIdList.forEach(skuId -> {
String dataKey = "product:sku:" + skuId;
this.redisTemplate.delete(dataKey);
});
//2 之前的业务代码,执行更新商品操作.....
//3 休眠一段时间
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//4 再次执行操作删除缓存
skuIdList.forEach(skuId -> {
String dataKey = "product:sku:" + skuId;
this.redisTemplate.delete(dataKey);
});
return 1;
}