【Redis 缓存计谋】更新、穿透、雪崩、击穿、布隆过滤

[复制链接]
发表于 2025-12-20 22:04:40 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
目次

  缓存简单先容
  缓存更新计谋
  缓存更新需求
  数据库缓存差别等办理方案
  先操纵缓存照旧先操纵数据库?
  先删除缓存,再操纵数据库 
  先操纵数据库,再删除缓存
  总结
  删除缓存照旧更新缓存?
  包管缓存与数据库的操纵的同时乐成或失败
  代码实现与调试
  缓存穿透计谋
  缓存穿透先容
  缓存穿透的办理方式
  缓存空对象
  实现流程
  代码实现
  布隆过滤
  布隆过滤器的实现原理
  映射一个值到布隆过滤器
  判定值是否存在布隆过滤器
  布隆过滤器的干系代码
   缓存雪崩计谋
  缓存雪崩先容
  办理缓存雪崩的方法
  缓存击穿计谋
  缓存击穿先容
  缓存击穿的办理方法
  互斥锁
   互斥锁的代码方式
  逻辑逾期方案
  逻辑逾期的代码方式
  
本章代码坐标:redis 缓存计谋
  


缓存简单先容


  

          用户的数据一样平常都是存储于数据库,数据库的数据是落在磁盘上的,磁盘的读写速率非常慢。在高并发的情况下,须要对数据库举行巨大的 CRUD,很容易导致数据库瓦解。以是为了克制用户直接访问数据库,一样平常都会用 Redis 作为缓存层。
  

          Redis 是基于内存的,我们可以将数据库的数据缓存在 Redis 里,相称于数据缓存在内存,内存的读写速率比硬盘快好几个数量级。如许用户在访问数据的情况下,可以直接在缓存中获取,如许大大进步了步伐的运行服从,也在肯定程度上低沉了数据库的访问压力。
          总结:缓存就是数据互换的缓冲区,是存贮数据的临时地方,一样平常读写性能较高。 
 缓存的作用:
缓存作用低沉后端负载进步读写服从,低沉相应时间缓存虽好,但是须要思量的东西可不少:
缓存的本钱数据同等性本钱代码维护本钱运维本钱        这里先简单提一下数据的同等性,数据的同等性紧张发生在用户修改数据库的数据后,redis 来不及更新所造成缓存数据与数据库数据差别等的标题。关于怎么办理,请看以下分解。

缓存更新计谋


  

  

          在哀求到达后端之后,对须要举行缓存的接口,会先去 Redis 中找有无数据,没有的话会继续走正常的业务流程,会去查询数据库的信息然后将查询到的结果返回给客户端的同时也放在 Redis 中一份,下次雷同哀求进来后,就可以直接从 Redis中拿到数据。假如数据库中也没有这个哀求信息,则阐明这是一个 “非法” 哀求,须要返回404之类的提示信息。
          在举行缓存之后,雷同的哀求在缓存时间内是不会去读取数据库的,但是此时假如修改了数据库,则接口返回的数据就不能包管和数据库同等,因此在增、删、改时我们须要革新(更新)缓存。
  缓存更新需求

           缓存更新是 redis 为了节流内存而计划出来的一个东西,紧张是由于内存数据名贵,当我们向 redis 插入太多数据,此时就大概会导致缓存中的数据过多,以是 redis 会对部分数据举行更新,大概把他叫为镌汰更符合。
   缓存更新的计谋有很多,这里比力三种情况: 
内存镌汰超时剔除自动更新阐明不消自己维护,使用 Redis 的内存镌汰机制,当内存不敷时自动镌汰部分数据。下次查询时更新缓存。给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。编写业务逻辑,在修改数据库的同时,更新缓存。同等性差一样平常好维护本钱无低高业务场景:
低同等性需求:使用内存镌汰机制。比方店肆范例的查询缓存。
高同等性需求:自动更新,并以超时剔除作为兜底方案。比方店肆详情查询的缓存。

数据库缓存差别等办理方案

           由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变革的。因此,假如当数据库中数据发生变革,而缓存却没有同步;此时就会有同等性标题存在。其结果是:用户使用缓存中的过期数据,就会产生雷同多线程数据安全标题,从而影响业务,产物口碑等。那么该怎样办理呢?
  这里有三个标题须要思量:
  先操纵缓存照旧先操纵数据库?

先删除缓存,再操纵数据库先操纵数据库,再删除缓存
先删除缓存,再操纵数据库 


        起首以上步调是正常情况,一个线程先删除缓存,在更新数据库,另一个线程开始查询的时间写入缓存。这是很理想的状态。

        但是留意,由于数据库的操纵是基于硬盘的,以是 CRUD 的服从是很慢的,在这个空档期很容易被其他线程趁虚而入。好比还没来得及更新数据库,另一个线程直接去举行查询操纵,发现没有缓存,操纵数据库后将旧数据写入缓存。固然数据库反面更新了,但是缓存仍然是旧数据。这就导致数据的差别等。
先操纵数据库,再删除缓存


        以上的步调是正常情况,一个先更新数据库,在删除缓存,另一个线程再次访问的时间没有掷中缓存就会去查询数据库,并将查询的结果写入缓存(查询结果存在的情况下)。

         但是这个方法依然有大概会发生线程安全标题:一个线程查询数据的时间,恰好缓存的有效期过了,以是缓存没有掷中正预备去操纵数据库。偏偏在这个时间另一个线程将数据库中的值给修改了。此时,轮到第一个线程举行操纵将旧的数据写入内存。如许就导致了缓存数据与数据库数据差别等。
总结

           固然以上两种方法都存在线程安全的标题,但是想要满足 “先操纵数据库,再删除缓存” 发生的条件是不容易的。(1) 须要缓存的有效期突然失效;(2) redis是基于内存的,运行速率快,而操纵数据库是基于硬盘的,运行速率是很慢的。以是出现在操纵缓存的时间中,乐成操纵数据库的情况是非常少的。
  
        以是我们一样平常选着先操纵数据库再删除缓存。
  
  删除缓存照旧更新缓存?

更新缓存:每次更新数据库都更新缓存,无效写操纵较多删除缓存:更新数据库时让缓存失效,查询时再更新缓存这里我们接纳删除缓存的方法,缘故起因如下:
缘故起因删除缓存更新缓存数据同等性直接更新缓存大概导致缓存与数据库之间的数据差别等。假如在更新缓存的同时有其他哀求正在读取或修改数据库,就大概出现数据差别等的情况。删除缓存后,下一次读取会从数据库中获取最新数据,从而包管数据的同等性。简化操纵更新缓存须要处置惩罚缓存中的数据布局,这大概比简单地删除缓存更复杂。删除缓存后,可以克制复杂的同步逻辑,简化体系的维护。克制竞态条件在高并发的体系中,多个进程或线程大概同时操纵缓存和数据库。假如直接更新缓存,大概会出现竞态条件,导致数据差别等。删除缓存可以镌汰这种风险。        由于更新缓存的操纵比力复杂、维护本钱较高、还容易发生高并发标题。我们最好使用删除缓存这种方法。

  包管缓存与数据库的操纵的同时乐成或失败

单体体系,将缓存与数据库操纵放在一个事件。分布式体系,使用TCC中分布式事件方案。        在更新数据库和Redis时,我们须要包管操纵的原子性,即要么全部乐成,要么全部失败。 否则,大概导致部分更新乐成,数据差别等。 办理方法如上,在单体体系中接纳事件机制,即可在操纵失误时,回滚之前的已经做过的操纵,包管操纵原子性;在分布式体系中也可以接纳相应的分布式事件举行操纵。

代码实现与调试


【更新店肆缓存流程】

  • 更新商店信息 -> 访问数据库并修改信息
  • 删除 redis 缓存
  • 根据 id 查询数据库
  • 更新商店缓存
  • 这里再提一点:由于 redis 缓存是基于内存的,而内存的空间非常有限,以是我们一样平常须要给缓存设置一个有效期,让那些不是热门的缓存(根本上没用户访问的)不再占用内存空间。

 Controller
  1.     // 根据 id 查找商铺
  2.     @GetMapping("/{id}")
  3.     public Result queryShopById(@PathVariable("id") Long id) {
  4.         return Result.ok(shopService.getById(id));
  5.     }
  6.    
  7.     // 更新商铺信息
  8.     @PutMapping
  9.     public Result updateShop(@RequestBody Shop shop) {
  10.         // 写入数据库
  11.         shopService.update(shop);
  12.         return Result.ok();
  13.     }
复制代码
  1.     public class RedisConstants {
  2.         public static final Long CACHE_SHOP_TTL = 30L;
  3.         public static final String CACHE_SHOP_KEY = "cache:shop:";
  4.     }
复制代码
Serivce 
  1.     @Override
  2.     public Result getById(Long id){
  3.         Shop shop = updateRedis(id);
  4.         if (shop == null){
  5.             return Result.fail("商铺信息不存在");
  6.         }
  7.         // 返回
  8.         return Result.ok(shop);
  9.     }   
  10.     // 更新缓存代码
  11.     public Shop updateRedis(Long id){
  12.         // 从 redis 查询商铺缓存
  13.         String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
  14.         // 判断 redis 是否存在商铺信息
  15.         if(StrUtil.isNotBlank(shopJson)){
  16.             // 存在直接返回
  17.             log.debug("redis 存在数据缓存,直接返回");
  18.             return JSONUtil.toBean(shopJson,Shop.class);
  19.         }
  20.         // 缓存不存在,根据 id 查询数据库
  21.         Shop shop = baseMapper.selectById(id);
  22.         if(shop == null){
  23.             log.debug("商铺信息不存在");
  24.             // 数据库信息不存在,返回null
  25.             return null;
  26.         }
  27.         // 存在将商铺信息写入 redis, 并设置有效期
  28.         log.debug("将数据库数据写入 redis 缓存");
  29.         stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL , TimeUnit.MINUTES);
  30.         return shop;
  31.     }
  32.     // 删除缓存代码
  33.     @Override
  34.     @Transactional
  35.     public Result update(Shop shop) {
  36.         // 判断店铺是否存在
  37.         Long id = shop.getId();
  38.         if(id==null){
  39.             return Result.fail("店铺id不能为空");
  40.         }
  41.         // 更新数据库
  42.         updateById(shop);
  43.         // 删除缓存
  44.         log.debug("删除缓存");
  45.         stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
  46.         return Result.ok();
  47.     }
复制代码
代码调试:

        此时 redis 只有一个用户的 token 数据。


        当我们点击1412餐厅时,数据库将数据写入缓存,于是 redis 数据库就可以望见了。

        此时我们使用 postman 给后端发送更新哀求,将餐厅名字改成520餐厅。
         此时商店的缓存便已经删撤消了,随之用户再次根据 id 再次查询该餐厅;数据库将会把更新的数据写入缓存:

        根据上述前端与 redis 调试,我们发当代码的逻辑是没有标题的。但是仍旧有瑕疵,假如用户量巨大,高并发大概受到黑客攻击的情况下,将会出现以下标题:缓存穿透、缓存雪崩、缓存击穿。

缓存穿透计谋


缓存穿透先容

           当我们客户端访问不存在的数据时,先哀求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,于是这个数据穿透了缓存,直击数据库,我们都知道数据库可以大概承载的并发不如redis这么高,假如大量的哀求同时过来访问这种不存在的数据,这些哀求就都会访问到数据库,大概会导致数据库的瓦解。
  

   举个例子:
          淘宝的一家商店非常火爆,动了某些人的蛋糕,于是就遭到黑客的攻击,而攻击的方式则大概是缓存穿透。数据库的主键Id都是从0开始自增的,而黑客就使用这一点,不停使用小于0的参数给后端发送哀求:而 redis 中并没有Id小于0的数据,则这个哀求就会打在(操纵)数据库上,而数据库中也肯定是没有的,就会给前端返回 404 干系的报错信息。由于redis不能拦截哀求,以是redis给如许的数据给 “穿透” 了,直接穿透到数据库的内里。导致数据库的瓦解。
  

          总结:缓存穿透是指客户端哀求的数据在缓存中和数据库中都不存在,如许缓存永世不会收效,这些哀求都会打到数据库。
          遇到这类情况,我们可以接纳很多步伐,好比对增强id的复杂度,克制被推测id规律,对 id 举行校验,做好热门参数的限流,当网站遭到缓存穿透时立刻拉黑该用户的IP等等。固然这些步伐可以有效的防范肯定的缓存击穿,但是还不能真正的办理标题。 
缓存穿透的办理方式

  缓存空对象

优点:实现简单,维护方便缺点:
    * 额外的内存斲丧
    * 大概造成短期的差别等缓存空对象思绪分析

           缓存空对象:哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去。如许,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到数据库了。
  实现流程


【防止缓存穿透流程】

  • 缓存未掷中根据id查询数据库
  • 假如数据库中存在该信息,就将其写入缓存
  • 假如数据库不存在该信息,我们就将此id作为key将空值写入缓存中
  • 当下次该使用该id作为参数举行查询时,便可直接返回缓存中的空值
代码实现

  1.     // 防范穿透的代码
  2.     public Shop queryWithPassThrough(Long id){
  3.         // 从 redis 查询商铺缓存
  4.         String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
  5.         // 判断 redis 是否存在商铺信息
  6.         if(StrUtil.isNotBlank(shopJson)){
  7.             // 存在直接返回
  8.             log.debug("redis 存在数据缓存,直接返回");
  9.             return JSONUtil.toBean(shopJson,Shop.class);
  10.         }
  11.         // StrUtil.isNotBlank("") == false 过滤不了空值
  12.         // 防范 redis 穿透,判断
  13.         if(shopJson != null){
  14.             // 返回错误信息
  15.             log.info("防范穿透的处理");
  16.             return null;
  17.         }
  18.         // 不存在,根据 id 查询数据库
  19.         Shop shop = baseMapper.selectById(id);
  20.         if(shop == null){
  21.             log.debug("商铺信息不存在");
  22.             // 防范 redis 穿透,将空值写入数据库
  23.             stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
  24.             return null;
  25.         }
  26.         // 存在将商铺信息写入 redis
  27.         log.debug("将数据库数据写入 redis 缓存");
  28.         stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL , TimeUnit.MINUTES);
  29.         return shop;
  30.     }
复制代码
        以上的方法固然可以缓解同一个非法key对数据库的连续攻击,但是假如攻击者不停使用多个随机的key举行访问;也照旧会存在雷同的标题,以是我们可以使用布隆过滤器来实现。

        我们把全部的哀求key全部缓存到布隆过滤器内里,当攻击者使用差别的key来举行哀求的时间;先去布隆过滤器中举行查询,假如不存在,则意味着这个key肯定在数据库中也不存在,以是这个时间就不会去访问数据库。
  布隆过滤

优点:内存占用较少,没有多余 key缺点:
    * 实现复杂
    * 存在误判大概布隆过滤器的实现原理

Redis 实现布隆过滤器的底层就是通过 bitmap 这种数据布局:

           布隆过滤器实在接纳的是哈希头脑来办理这个标题,通过一个巨大的二进制数组,走哈希头脑去判定当前这个要查询的数据是否存在,假如布隆过滤器判定存在,则放行,这个哀求会去访问redis,哪怕此时redis中的数据逾期了,但是数据库中肯定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中。(这里有误,假如发生误判,该数据大概不存在于数据库)假设布隆过滤器判定这个数据不存在,则直接返回。
  
        这种方式优点在于节流内存空间,存在误判,误判缘故起因在于:布隆过滤器走的是哈希头脑,只要哈希头脑,就大概存在哈希辩论。
  映射一个值到布隆过滤器

        假如我们要映射一个值到布隆过滤器中,我们须要使用多个差别的哈希函数天生多个哈希值,并对每个天生的哈希值指向的位置设置为 1。如下图,针对 "hello redis" 它须要履历三个哈希函数(根据现本相况而定),这三个哈希函数会盘算出三个哈希值,他就会把三个哈希值映射到数组内里去。好比说,hash1算出的结果为4,他就会把数组4的位置设置为1,其他同理。如许的话,"hello redis" 这个数据就算存到了布隆过滤器中。

判定值是否存在布隆过滤器

        也就是说 "hello redis" 会在下标 4、6、9 这些位置体现 "hello redis" 这个数据。
   举个例子:
  

          “hello world” 的哈希函数返回了 4、7、9 三个值,结果我们发现 7 这个位置值是0,阐明没有任何一个值映射到这个位置,我们就可以很明白的知道,这个 "hello world" 不存在与数据库中。
  

          假如须要查询 "hello redis" 这个值是否存在,则须要判定返回的 4、6、9 三个值,我们发现这三个值都为 1,那么就能确定这个 "hello redis" 肯定存在于数据库吗?答案是否定的,我们只能说大概存在。由于随之存入的数据越来越多,就会发生哈希辩论。
          如下图,假如此时有别的值刚好映射到这个位置,就会间接的资助我们的 “hello world” 逃过查抄,此时就会去操纵数据库。这个这也是我之前所说的误判。
  

          哈希辩论是克制不了的,我们只能通过扩容等操纵来缓解这种辩论,来包管容错率;然而这些操纵也是非常斲丧性能的。关于设置怎样的容错率还须要根据场景的需求来决定。
  总结:(1)要责备部的映射位置都为1,才气判定这个值大概存在于数据库;假如有一个不为1,那么肯定不存在于数据库。(2)在数据量大的情况下,会产生哈希辩论,从而造成误判。 
误判的其他例子:
比方:此时有两个值映射在了同一个位置4,那么删撤消一个值的映射大概会对另一个值造影响。
布隆过滤器的干系代码

  1. package com.ys.rediscluster.bloomfilter.redisson;
  2. import org.redisson.Redisson;
  3. import org.redisson.api.RBloomFilter;
  4. import org.redisson.api.RedissonClient;
  5. import org.redisson.config.Config;
  6. public class RedissonBloomFilter {
  7.     public static void main(String[] args) {
  8.         Config config = new Config();
  9.         config.useSingleServer().setAddress("你的redis ip");
  10.         config.useSingleServer().setPassword("你的 redis 密码");
  11.         // 构造Redisson
  12.         RedissonClient redisson = Redisson.create(config);
  13.         RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");
  14.         // 初始化布隆过滤器:预计元素为100000000L,误差率为3%
  15.         bloomFilter.tryInit(100000000L,0.03);
  16.         // 将号码10086插入到布隆过滤器中
  17.         bloomFilter.add("10086");
  18.         // 判断下面号码是否在布隆过滤器中
  19.         System.out.println(bloomFilter.contains("123456"));//false
  20.         System.out.println(bloomFilter.contains("10086"));//true
  21.     }
  22. }
复制代码
          这是单节点的 Redis 实现布隆过滤器的方式,假如数据量比力大,盼望的偏差率又很低,那单节点所提供的内存是无法满足的,这时间可以使用分布式布隆过滤器。
  
 缓存雪崩计谋


缓存雪崩先容


           缓存雪崩是指在某一时候,大量缓存同时失效,导致大量哀求直接打到数据库层,造成数据库压力骤增,乃至大概导致数据库瓦解、体系不可用的情况。
  造成缓存雪崩的缘故起因:
  
同一时段大量的缓存 key 同时失效
Redis 服务宕机
办理缓存雪崩的方法

   针对同一时段大量的缓存 key 同时失效
  (1) 设置缓存失效时间,让它们不要在同一时间失效,在设置缓存的失效时间的时间,我们可以随机初始化失效时间,如许就不会让缓存在同一时间失效,将缓存打在数据库上
  (2) 设置定时任务,定时的去革新缓存
  (3) 不设置缓存失效的时间,一样平常针对热门key
  (4) 给一个热门业务添加多级缓存
  (5) redis 一样平常都是集群摆设,我们把热门 key 放在差别的节点上去,让这些热门的缓存均匀分布在差别的 redis 的节点上
  (6) 发现访问的数据不在Redis中,加上互斥锁,包管同一时间内只有一个哀求来构建缓存
    Redis 服务宕机
  
  (1) 启动服务熔断机制,停息业务应用对缓存服务的访问,直接返回错误,停息使用数据库,等Redis规复正常后,在答应业务应用缓存服务
  (2) 启动哀求限流机制,只将少部分哀求发送到数据库举行处置惩罚,再多的哀求就在入口直接拒绝服务
  (3) 假如 redis 的一个节点故障宕机,从该节点可以切换成为另一个节点,继续提供缓存服务
  
缓存击穿计谋


缓存击穿先容


          缓存击穿是一个热门的key标题,有大量并发哀求会合对一个服务举行访问,突然间这个缓存的key突然失效了,导致大量并发哀责备部打在数据库上,导致数据库压力剧增。这种征象就叫做缓存击穿。
  缓存击穿的办理方法


  互斥锁

假设现在线程1过来访问,他查询缓存没有掷中,但是此时他得到到了锁的资源,那么线程1就会一个人去实行逻辑,假设现在线程2过来,线程2在实行过程中,并没有得到到锁,那么线程2就可以举行到休眠,直到线程1把锁开释后,线程2得到到锁,然后再来实行逻辑,此时就可以大概从缓存中拿到数据了。 互斥锁的代码方式


           相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是举行查询之后,假如从缓存没有查询到数据,则举行互斥锁的获取,获取互斥锁后,判定是否得到到了锁,假如没有得到到,则休眠,过一会再举行实行,直到获取到锁为止,才气举行查询。假如获取到了锁的线程,再去举行查询,查询后将数据写入redis,再开释锁,返回数据,使用互斥锁就能包管只有一个线程去实行操纵数据库的逻辑,防止缓存击穿。
  1. public class RedisConstants {
  2.     public static final Long CACHE_SHOP_TTL = 30L;
  3.     public static final String CACHE_SHOP_KEY = "cache:shop:";
  4.     public static final String LOCK_SHOP_KEY = "lock:shop:";
  5.     public static final Long LOCK_SHOP_TTL = 10L;
  6. }
复制代码
  1.     // redis 自定义锁,加锁
  2.     private boolean tryLock(String key){
  3.         Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"自定义锁",LOCK_SHOP_TTL , TimeUnit.SECONDS);
  4.         return BooleanUtil.isTrue(flag);
  5.     }
  6.     // redis 自定义锁,解锁
  7.     private void unLock(String key){
  8.         stringRedisTemplate.delete(key);
  9.     }
  10.     // 防范击穿的代码(互斥锁)
  11.     public Shop queryWithMutex(Long id) {
  12.         // 从 redis 查询商铺缓存
  13.         String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
  14.         // 判断 redis 是否存在商铺信息
  15.         if (StrUtil.isNotBlank(shopJson)) {
  16.             // 存在直接返回
  17.             log.debug("redis 存在数据缓存,直接返回");
  18.             return JSONUtil.toBean(shopJson, Shop.class);
  19.         }
  20.         // StrUtil.isNotBlank("")// false
  21.         // 防范 redis 穿透,判断
  22.         if (shopJson != null) {
  23.             // 返回错误信息
  24.             log.info("防范穿透的处理");
  25.             return null;
  26.         }
  27.         // 1.缓存未命中,实现缓存重建
  28.         String LockKey = null;
  29.         Shop shop;
  30.         try {
  31.             // 2.获取互斥锁
  32.             LockKey = LOCK_SHOP_KEY + id;
  33.             boolean isLock = tryLock(LockKey);
  34.             // 3.判断是否获取成功
  35.             if (!isLock) {
  36.                 // 没有获取到锁的线程阻塞等待
  37.                 Thread.sleep(50);
  38.                 queryWithMutex(id);
  39.             }
  40.             // 获取到锁的线程执行以下程序
  41.             // 不存在,根据 id 查询数据库
  42.             shop = baseMapper.selectById(id);
  43.             if (shop == null) {
  44.                 log.debug("商铺信息不存在");
  45.                 // 防范 redis 穿透,将空值写入数据库
  46.                 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
  47.                 return null;
  48.             }
  49.             // 存在将商铺信息写入 redis
  50.             log.debug("将数据库数据写入 redis 缓存");
  51.             stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
  52.         } catch (InterruptedException e) {
  53.             throw new RuntimeException(e);
  54.         }finally {
  55.             // 4.释放掉锁
  56.             unLock(LockKey);
  57.         }
  58.         return shop;
  59.     }
复制代码
          由于包管了互斥性,以是数据同等,且实现简单,由于仅仅只须要加一把锁而已,也没其他的变乱须要费心,以是没有额外的内存斲丧,缺点在于有锁就有死锁标题的发生,且只能串行实行性能肯定受到影响。 
  

  逻辑逾期方案

我们把逾期时间设置在 redis 的 value 中,这个逾期时间并不会直接作用于redis,而是我们后续通过逻辑行止置惩罚。假设线程1查询缓存,未掷中直接返回,掷中判定是否逾期,没逾期直接返回数据;已逾期,就会实行获取锁,然后现在开启新的线程举行缓存重修,线程1返回旧数据,其他线程获取锁失败都返回旧数据。逻辑逾期的代码方式


           当用户开始查询 redis 时,判定是否掷中,假如没有掷中则直接返回空数据,不查询数据库,而一旦掷中后,将 value 取出,判定 value 中的逾期时间是否满足,假如没有逾期,则直接返回 redis 中的数据,假如逾期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后开释互斥锁。
  设置逾期时间与重修缓存
  1. @Data
  2. public class RedisData {
  3.     private LocalDateTime expireTime;
  4.     private Object data;
  5. }
复制代码
  1.     public void saveShop2Redis(Long id, Long expireSeconds){
  2.         RedisData redisData = new RedisData();
  3.         Shop shop = shopMapper.selectById(id);
  4.         redisData.setData(shop);
  5.         redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
  6.         stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
  7.     }
复制代码
  1.     // 防范击穿的代码(逻辑过期)
  2.     // 用线程池创建10个线程
  3.     private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
  4.     public Shop queryWithLogicalExpire (Long id){
  5.         // 查询 redis 的商铺缓存
  6.         String key = CACHE_SHOP_KEY + id;
  7.         String shopCache = stringRedisTemplate.opsForValue().get(key);
  8.         if(StrUtil.isBlank(shopCache)){
  9.             // 未命中返回null
  10.             return null;
  11.         }
  12.         // 命中缓存,检查是否过期
  13.         // 未过期,直接返回
  14.         // 需要先把 json反序列化为对象
  15.         RedisData redisData = JSONUtil.toBean(shopCache, RedisData.class);
  16.         JSONObject jsonObject = (JSONObject) redisData.getData();
  17.         Shop shop = JSONUtil.toBean(jsonObject, Shop.class);
  18.         // 取出过期时间
  19.         LocalDateTime expireTime = redisData.getExpireTime();
  20.         if(expireTime.isAfter(LocalDateTime.now())){
  21.             // 未过期,直接返回店铺
  22.             return shop;
  23.         }
  24.         // 过期,获取互斥锁
  25.         String lockKey = LOCK_SHOP_KEY  + id;
  26.         boolean isLock = tryLock(lockKey);
  27.         if(isLock){
  28.             // 再次检查Redis缓存是否逻辑过期
  29.             if(expireTime.isAfter(LocalDateTime.now())){
  30.                 // 没过期
  31.                 return shop;
  32.             }
  33.             // 成功,开启独立线程,开启缓存重建
  34.             CACHE_REBUILD_EXECUTOR.submit(()->{
  35.                 try {
  36.                     // 重建缓存
  37.                     this.saveShop2Redis(id, 20L);
  38.                 } catch (Exception e) {
  39.                     throw new RuntimeException(e);
  40.                 } finally {
  41.                     unLock(lockKey);
  42.                 }
  43.             });
  44.         }
  45.         // 返回数据
  46.         return shop;
  47.     }
  48.     // redis 自定义锁,加锁
  49.     private boolean tryLock(String key){
  50.         Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"自定义锁",LOCK_SHOP_TTL , TimeUnit.SECONDS);
  51.         return BooleanUtil.isTrue(flag);
  52.     }
  53.     // redis 自定义锁,解锁
  54.     private void unLock(String key){
  55.         stringRedisTemplate.delete(key);
  56.     }
复制代码
          线程读取过程中不须要期待,性能好,有个额外的线程持有锁去举行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,导致缓存数据与数据库数据差别等;且实现起来比力贫苦。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表