当前位置: 首页 > 科技观察

Redis场景-缓存穿透,击穿问题

时间:2023-03-12 12:01:49 科技观察

场景问题及原因缓存穿透:原因:客户端请求的数据在缓存和数据库中都不存在,所以缓存永远不会生效,所有的请求都会进入数据库,导致数据库连接异常。解决方案:缓存空对象,为不存在的数据在Redis中创建缓存,值为空,设置短的TTL时间问题:实现简单,维护方便,但短期数据不一致问题缓存雪崩:原因:同一时间大量缓存键同时失效或者Redis服务在某段时间内宕机,导致大量请求到达数据库,带来巨大压力。解决方案:在不同Key的TTL中加入随机值(简单),在缓存服务中加入降级限流策略(复杂),在服务中加入多级缓存(复杂),缓存击穿(热点Key):前提:HotspotKey&某段时间高并发访问&缓存重建耗时长原因:hotkey突然过期,因为重建耗时长,期间大量请求落到数据库这一时期,带来了巨大的影响解决思路:Mutexlockforcache重构过程加锁,保证重构过程中只有一个线程在执行,其他线程在等待。通过判断逻辑过期时间来决定是否重建缓存;重建缓存也是通过一个mutex保证单线程执行,但是重建缓存是使用独立线程异步执行,其他线程不需要等待,直接查询旧数据即可。问题:不保证一致性,有额外的内存消耗,实际解决复杂场景问题。完整代码地址:https://github.com/xbhog/hm-dianping分支:20221221-xbhog-cacheBrenkdown分支:20230110-xbhog-Cache_Penetration_Avalance缓存穿透:代码实现:12345678910111213141516171819202122publicShopqueryWithPassThrough){QueryLongshop来自redis的信息StringshopInfo=stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY+id);//命中缓存并返回店铺信息if(NOtshopBflank.)){returnJSONUtil.toBean(shopInfo,Shop.class);}//redis既没有key缓存,但是查到的信息不为null,则为空字符串if(shopInfo!=null){returnnull;}//错过缓存Shopshop=getById(id);if(Objects.isNull(shop)){//将null加入缓存,过期时间会减少stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,"",5L,TimeUnit.MINUTES);返回空值;}//对象到字符串stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,JSONUtil.toJsonStr(shop),30L,TimeUnit.MINUTES);returnshop;}上面的流程图和代码已经很清楚了,由于缓存雪崩的简单实现(复杂的做法不会)增加随机TTL值,缓存穿透和缓存雪崩不解释太多缓存击穿:缓存击穿逻辑分析:首先是线程1在查询缓存时miss,然后查询数据库,重建缓存。注意上述缓存击穿的情况,高并发访问&缓存重建耗时长;由于缓存重建耗时较长,线程2、3、4在此期间进入;那么这些线程就无法从缓存中查询到数据,同时访问数据库,同时执行数据库操作代码,数据库访问压力太大。互斥锁:解决方案:锁;****可以使用**tryLock方法+doublecheck**来解决这个问题。当线程2正在执行时,线程2被锁定,因为线程1正在重建缓存。阻塞、睡眠并等待线程1执行完毕并查询缓存。导致在重建缓存时进程被阻塞,效率降低,存在死锁风险。12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455privateShopqueryWithMutex(Longid){//从redis查询商铺信息StringshopInfo=stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY+id);//生命中存储,返回店铺信息if(StrUtil.isNotBlank(shopInfo)){returnJSONUtil.toBean(shopInfo,Shop.class);}//redis既没有key缓存,但是查到的信息不为null,则为空字符串if(shopInfo!=null){returnnull;}//实现缓存RebuildStringlockKey="lock:shop:"+id;商店商店=空;try{BooleanaBoolean=tryLock(lockKey);if(!aBoolean){//加锁失败,休眠Thread.sleep(50);//递归等待返回queryWithMutex(id);}//成功获取锁应该检查redis缓存是否还存在,做doubleCheck。如果存在,则无需重建缓存。synchronized(this){//从redis查询店铺信息StringshopInfoTwo=stringRedisTemplate.opsForValue().get(SHOP_CACHE_KEY+id);//命中缓存并返回店铺信息if(StrUtil.isNotBlank(shopInfoTwo)){returnJSONUtil.toBean(shopInfoTwo,Shop.class);}//redis既没有key缓存,但是查到的信息不为null,则为""if(shopInfoTwo!=null){returnnull;}//未命中缓存shop=getById(id);//5.不存在,返回错误if(Objects.isNull(shop)){//将null加入缓存,减少过期时间stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,"",5L,时间单位.MINUTES);返回空值;}//模拟重建延迟Thread.sleep(200);//对象转字符串stringRedisTemplate.opsForValue().set(SHOP_CACHE_KEY+id,JSONUtil.toJsonStr(shop),30L,TimeUnit.MINUTES);}}catch(InterruptedExceptione){抛出新的RuntimeException(e);}最后{解锁(锁键);}returnshop;}当获取锁失败时,证明现有线程正在重建缓存,使当前线程休眠重试(递归实现)。代码中要注意synchronizedkey这个词的使用,在获取锁的时候,判断缓存是否存在(失败)double-check,关键字锁定当前对象。在其关键字{}中是同步处理。推荐博客:https://blog.csdn.net/u013142781/article/details/51697672然后测试代码,进行压力测试(jmeter),先去掉缓存中的值,模拟缓存失效。设置1000个线程,多线程执行间隔为5s。请求全部成功,qps在200左右,吞吐量相当可观。然后检查缓存是否成功(只查询一次数据库);逻辑过期:思路分析:当用户开始查询redis时,判断是否命中,如果没有命中,直接返回空数据,不查询数据库,一旦命中,将设置值取出来,判断是否命中满足价值的到期时间。如果没有过期,则直接返回redis中的数据。如果过期,则启动独立线程后直接返回之前的数据。独立线程会重构数据,重构完成后释放。互斥体。封装数据:这里我们使用一个新的实体类来实现12345678910/***@authorxbhog*@describe:*@date@DatapublicclassRedisData{privateLocalDateTimeexpireTime;privateObjectdata;}这样过期时间就和数据关联起来了,这里的数据类型是Object,方便后续不同类型的封装。123456789101112131415161718192021222324252627282930313233343536373839publicShopqueryWithLogicalExpire(Longid){Stringkey=CACHE_SHOP_KEY+id;//1.从redis查询商铺存储Stringjson=stringRedisTemplate.opsForValue().get(key);//2.判断是否存在if(StrUtil.isBlank(json)){//3.存在,直接returnreturnnull;}//4.命中,需要先将json反序列化成对象RedisDataredisData=JSONUtil.toBean(json,RedisData.class);Shopshop=JSONUtil.toBean((JSONObject)redisData.getData(),Shop.class);LocalDateTimeexpireTime=redisData.getExpireTime();//5.判断是否过期if(expireTime.isAfter(LocalDateTime.now())){//5.1.未过期,直接返回店铺信息returnshop;}//5.2。已过期,需要缓存重建//6.缓存重建//6.1。获取mutexStringlockKey=LOCK_SHOP_KEY+id;布尔isLock=tryLock(lockKey);//6.2。判断是否获取锁成功if(isLock){exectorPool().execute(()->{try{//重建缓存this.saveShop2Redis(id,20L);}catch(Exceptione){thrownewRuntimeException(e);}最后{解锁(锁键);}});}//6.4。returnexpiredshopinformationreturnshop;}当前的执行流程和mutex基本一样,需要注意的是在成功获取锁后,我们将缓存重构放到线程池中异步执行线程池代码:12345678910111213141516/***创建线程池*@return*/privatestaticThreadPoolExecutorexectorPool(){ThreadPoolExecutorexecutor=newThreadPoolExecutor(5,//根据自己的处理器数+1Runtime.getRuntime().availableProcessors()+1,2L,TimeUnit.SECONDS,newLinkedBlockingDeque<>(3),Executors.defaultThreadFactory(),newThreadPoolExecutor.AbortPolicy());returnexecutor;}缓存重建代码:1234567891011121314/***重建缓存*@paramid重建ID*@paraml过期时间*/publicvoidsaveShop2Redis(Longid,longl){//查询店铺信息Shopshop=getById(ID);//封装逻辑过期时间RedisDataredisData=newRedisData();redisData.setData(商店);redisData.setExpireTime(LocalDateTime.now().plusSeconds(l));stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));}测试条件:100个线程,线程间隔时间1s,缓存失效时间10s。测试环境:缓存中有对应的数据,在缓存快过期前修改了数据库中的数据,导致缓存和数据库不一致。通过压力测试,可以查看相关线程返回的数据。从上面两张图可以看出,前几个线程执行的时候storename是102。当执行时间从19-20变化时,storename变化为105,满足了逻辑过期时异步执行缓存重建的需求。