结合电商模式打造校园交易平台之Redis缓存篇(全文总共13万字,超详细)

文章详细介绍了Redis作为缓存数据库的使用,包括缓存穿透、雪崩、击穿问题的解决方案,以及SpringCache的使用和其在处理并发问题时的不足。文中提到了Redisson作为分布式锁的解决方案,并探讨了分布式锁和本地锁的区别。此外,还讨论了缓存一致性问题,如双写模式和失效模式,以及如何在SpringCache中通过`@CacheEvict`和`@Cacheable`注解实现缓存更新和删除。
摘要由CSDN通过智能技术生成

Redis缓存数据库的使用

为什么使用缓存?

对于复杂的业务,已经不能够通过代码层面的优化和数据库层面的优化来达到增加吞吐量的目的,这时我们可以考虑使用缓存。

为了系统性能的提升,我们一般都会将部分数据放入缓存中来加速对这些数据的访问。而数据库(如mysql)则承担数据落盘工作即数据的持久化工作。

那么哪些数据适合放入缓存呢?

  • 即时性,数据一致性要求不高的
  • 访问量大且更新频率不高的数据即读多写少的。如我们项目中商品分类就属于读多写少的数据,因为它是一个抽象的数据。

缓存使用流程如下图:

image-20221022223345035

本地缓存

如果我们是单体服务,也就是只有一个服务器的情况下,我们如果想使用缓存的话可以使用本地缓存,最简单的就是new一个HashMap来保存我们的数据,之后再需要这些数据的话直接从这个map(因为我们自己new的对象保存在堆中其实就是我们电脑的内存中)中获取就可以了。本地缓存流程如下图:

image-20221023101303341

但是我们的项目一般都是分布式的,并且还是集群式部署的(分布式就意味着多个服务之间,集群意味着一个服务的多台机器之间的缓存共享和一致性问题)。那么比如集群就会有多台服务器一起完成我们的(如商品)服务。那么如果我们还是用本地缓存的话就需要在每一个服务器上都维持一个缓存,这种情况会有很多问题:

(1)缓存不共享

在这种情况下,每个服务都有一个缓存,但是这个缓存并不共享,水平上当调度到另外一台设备上的时候(比如我们的系统会由网关做负载均衡),可能它的服务中并不存在这个缓存,因此需要重新查询。

(2)缓存一致性问题

在一台设备上的缓存更新后,其他设备上的缓存可能还未更新。这样当从其他设备上获取数据(还是负载均衡)的时候,得到的可能就是未更新的数据。

集群本地缓存流程如下图:

image-20221023101722829

分布式缓存

在微服务分布式架构模式下,一个服务的不同副本就需要共享同一个缓存空间,所以把缓存放置到缓存中间件中,这个缓存中间件可以是redis等,而且缓存中间件也是可以水平或纵向扩展的,如Redis可以使用redis集群。它打破了缓存容量的限制(因为理论上redis缓存空间不够我们就可以就可以加redis机器),能够做到高可用高性能。具体流程如下:

image-20221023102224244

整合Redis

接下来我们就把Redis缓存中间件整合进我们的项目中,步骤如下:

  1. 首先我们把Redis的依赖引入我们的项目

    <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
  2. 配置Redis的配置信息

    spring:
      redis:
        host: 192.168.10.10//部署了Redis的主机ip地址
        port: 6379//Redis服务的端口号,默认就是6379
    

这两步我们就已经把Redis整合进我们的项目了,接下来就可以使用Redis了。

Redis官方已经帮我们配置好了操作Redis的两种方式,具体配置在RedisAutoConfiguration配置类中,源码如下图:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
 
   @Bean
   @ConditionalOnMissingBean(name = "redisTemplate")
   //将保存进入Redis的键值都是Object
   public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
         throws UnknownHostException {
      RedisTemplate<Object, Object> template = new RedisTemplate<>();
      template.setConnectionFactory(redisConnectionFactory);
      return template;
   }
 
   @Bean
   @ConditionalOnMissingBean
   //保存进Redis的数据,键值是(String,String)
   public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory)
         throws UnknownHostException {
      StringRedisTemplate template = new StringRedisTemplate();
      template.setConnectionFactory(redisConnectionFactory);
      return template;
   }
}

这里我们项目中使用的是StringRedisTemplate来操作Redis,这个类主要是用来存储字符串缓存数据的(key是对象名,value就是对象序列化之后的字符串表示)。但Redis中存储的字符串比较特殊(不然也不会专门为它创建一个注册方法了),Redis的字符串最大可以支持512M的大小,其字符串可以表示任何二进制数据(比如图片,视频等),因此我们大多使用的都是StringRedisTemplate这个类操作字符串数据。

下面我们简单测试一下:

@Autowired
StringRedisTemplate redisTemplate;//引入这个实例就可以进行操作了

    @Test
    public void testStringRedisTemplate(){
        //首先使用Template获取我们想要操作数据的操作对象(比如redis的五大基本类型,下面的value就表示操作的简单字符串)
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();//ops=operations
        //保存
        ops.set("hello","world_"+ UUID.randomUUID().toString());
        //查询
        String hello = ops.get("hello");
        System.out.println("之前保存的数据是:"+hello);
    }

这里StringRedisTemplate表示我们向redis中存的数据形式都是字符串(KV都是String类型序列化结果),而不是Object类型。

image-20221227202609155

而之后的OPS才表示redis中真正操作的缓存对象的数据类型是什么。

我们的Redis已经整合好了,下面就开始使用Redis优化一下我们的业务吧。

改造三级分类业务

我们只需要获取数据时先判断一下缓存中是否有我们的三级分类数据,如果没有再从数据库中查询,并且将查询结果以JSON字符串的形式存放到Reids缓存中的,所以我们取数据时取出的也是JSON字符串形式的数据,那么我们就需要将其逆转解析为我们能用的对象类型(这里的存取对应的就是对象的序列化与反序列化)。使用JSON的好处就是JSON是跨语言,跨平台兼容的,这样就能保证我们缓存的高可用。下面的代码中就有序列化(JSON.toJSONString(catelogJsonFromDB))与反序列化(JSON.parseObject(catalogJSON,new TypeReference<Map<String,List<Catelog2Vo>>>(){}))的过程:

@Override
public Map<String, List<Catelog2Vo>> getCatelogJson() {
    //给缓存中放json字符串,当我们需要数据时从缓存中拿出的json字符串还要逆转为能用的对象类型【序列化与反序列化】
    //1、加入缓存逻辑
    //JSON好处是跨语言,跨平台兼容。
    String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
    if (StringUtils.isEmpty(catalogJSON)){
        //2、缓存中没有,查询数据库
        Map<String, List<Catelog2Vo>> catelogJsonFromDB = getCatelogJsonFromDB();
        //3、将查到的数据再放入缓存,将对象转为JSON在缓存中
        String jsonString = JSON.toJSONString(catelogJsonFromDB);
        redisTemplate.opsForValue().set("catalogJSON",jsonString);
        return catelogJsonFromDB;
    }
    //转为我们指定的对象。
    Map<String,List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new 										TypeReference<Map<String,List<Catelog2Vo>>>(){});
    return result;
}
private Map<String, List<Catelog2Vo>> getDataFromDB() {
    String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
    if (!StringUtils.isEmpty(catalogJSON)) {
        //如果缓存不为null直接从缓存中取数据
        Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, 			List<Catelog2Vo>>>(){});
        return result;
    }
    System.out.println("查询了数据库");
    List<CategoryEntity> selectList = baseMapper.selectList(null);
    //查出所有一级分类
    List<CategoryEntity> level1Category = getParent_cid(selectList, 0L);
    //2、封装数据
    Map<String, List<Catelog2Vo>> parent_cid = level1Category.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
        //1、每一个的一级分类,查到这个一级分类的二级分类
        List<CategoryEntity> categoryEntities = getParent_cid(selectList, v.getCatId());
        //2、封装上面的结果
        List<Catelog2Vo> catelog2Vos = null;
        if (categoryEntities != null) {
            catelog2Vos = categoryEntities.stream().map(l2 -> {
                Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());
                //1、找当前二级分类的三级分类封装vo
                List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId());
                if (level3Catelog != null) {
                    List<Catelog2Vo.Catalog3Vo> collect = level3Catelog.stream().map(l3 -> {
                        //2、封装成指定格式
                        Catelog2Vo.Catalog3Vo catelog3Vo = new Catelog2Vo.Catalog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
                        return catelog3Vo;
                    }).collect(Collectors.toList());
                    catelog2Vo.setCatalog3List(collect);
                }
                return catelog2Vo;
            }).collect(Collectors.toList());
        }
        return catelog2Vos;
    }));
    //3、将查到的数据再放入缓存,将对象转为JSON在缓存中
    String jsonString = JSON.toJSONString(parent_cid);
    redisTemplate.opsForValue().set("catalogJSON", jsonString, 1, TimeUnit.DAYS);
    return parent_cid;
}

但这里我们改进之后进行压力测试时,会出现堆外内存溢出问题,其原因如下:

image-20221023104303189

这里我们选择修改为使用jedis操作redis数据库,只需要修改一下pom文件(排除掉lettuce的依赖,然后引入jedis的依赖)即可:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <exclusions>
            <exclusion>
                <groupId>io.lettuce</groupId>
                <artifactId>lettuce-core</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
    </dependency>

最后我们说一下关于redisTempla和lettuce,jedis的关系:

lettuce和jedis实际上才是封装了对redis底层客户端操作的API,而redisTemplate是Spring对这两者进一步封装的结果。

image-20221227210023657

我们可以看到Redis的自动配置类引入了这两者的连接类,并且我们我们无论使用哪个连接最后都会得到一个RedisConnectionFactory连接工厂。

高并发下缓存失效问题

缓存虽好,但使用不当的话也会有很多问题,比如高并发环境下缓存就会出现很典型的三个问题:缓存穿透、缓存雪崩、缓存击穿

缓存穿透

image-20221023104807898

缓存雪崩

image-20221023104852925

缓存击穿

image-20221023104930975

总的来说:缓存穿透是指查询一个永不存在(也许只是当前时间点不会存在)的数据;缓存雪崩是值大面积缓存同时失效(即同一时间缓存的key过期了)问题;缓存击穿是指高频key失效(热点缓存的key同时过期)问题;

缓存击穿问题的解决

使用本地锁解决缓存击穿

缓存击穿就是某一时间要查的数据在redis中没有缓存(也许是第一次查也许是缓存失效),但是这时间同时会有大量并发同时查,这时因为没有缓存数据,这大量并发就都会查数据库。这个问题解决方法很简单,既然并发的进行查数据库,那么我们只需要给查数据库这个操作加一把锁,同一时间只能一个线程去查数据库不就好了吗。

这时我们就给查询数据库的操作加上了一把锁:

	@Override
    public Map<String, List<Catelog2Vo>> getCatelogJson() {
        /**
         * 加锁,解决缓存击穿
         */
        String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
        if (StringUtils.isEmpty(catalogJSON)){
            System.out.println("缓存不命中.....将要查询数据库...");
            Map<String, List<Catelog2Vo>> catelogJsonFromDB = getCatelogJsonFromDB();
            //3、将查到的数据再放入缓存,将对象转为JSON在缓存中
            String jsonString = JSON.toJSONString(catelogJsonFromDB);
            redisTemplate.opsForValue().set("catalogJSON",jsonString,1, TimeUnit.DAYS);
            return catelogJsonFromDB;
        }
        System.out.println("缓存命中...直接返回...");
        //转为我们指定的对象。
        Map<String,List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new 										TypeReference<Map<String,List<Catelog2Vo>>>(){});
        return result;
    }
	//从数据库查询并封装分类数据
    public Map<String, List<Catelog2Vo>> getCatelogJsonFromDB() {
        //只要同一把锁就能锁住需要这个锁的所有线程
        //1、synchronized(this):SpringBoot所有的组件在容器中都是单例的
        // TODO 本地锁:synchronized,JUC(lock)。在分布式情况下想要锁住所有,必须使用分布式锁
        //使用DCL(双端检锁机制)来完成对于数据库的访问
        synchronized (this){
            //得到锁以后,我们应该再去缓存中确定一次,如果没有才需要继续查询
            String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
            if (!StringUtils.isEmpty(catalogJSON)){
                //如果缓存不为null直接缓存
                Map<String,List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new 										TypeReference<Map<String,List<Catelog2Vo>>>(){});
                return result;
                ......
                return catelog2Vos;
            }));
            return parent_cid;
        }
    }

修改完上述方法后,我们将业务逻辑中的确认缓存没有查数据库放到了锁里,但是最终控制台却打印了两次查询了数据库。这是因为在将结果放入缓存的这段时间里,会有其他线程确认缓存没有,又再次查询了数据库,因此我们要将结果放入缓存也进行加锁。上述流程如下图:

image-20221023112602607

上述问题也可以叫做锁时序问题,那么接下来我们就把查询数据库的操作,以及将其返回结果的操作一起进行加锁。

public Map<String, List<Catelog2Vo>> getCatelogJsonFromDB() {
        //只要同一把锁就能锁住需要这个锁的所有线程
        //1、synchronized(this):SpringBoot所有的组件在容器中都是单例的
        // TODO 本地锁:synchronized,JUC(lock)。在分布式情况下想要锁住所有,必须使用分布式锁
        //使用DCL(双端检锁机制)来完成对于数据库的访问
        synchronized (this){
            //得到锁以后,我们应该再去缓存中确定一次,如果没有才需要继续查询
            String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
            if (!StringUtils.isEmpty(catalogJSON)){
                //如果缓存不为null直接缓存
                Map<String,List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new 										TypeReference<Map<String,List<Catelog2Vo>>>(){});
                return result;
            }
            System.out.println("查询了数据库。。。。。");
......
                return catelog2Vos;
            }));
            //3、将查到的数据再放入缓存,将对象转为JSON在缓存中之后才释放本地锁
            String jsonString = JSON.toJSONString(parent_cid);
            redisTemplate.opsForValue().set("catalogJSON",jsonString,1, TimeUnit.DAYS);
            return parent_cid;
		}
}

这时流程就如下图所示了:

image-20221023112900992

这里我们使用了双端检锁机制来控制线程的并发访问数据库。一个线程进入到临界区之前,进行查找缓存中是否有数据,进入到临界区后,再次判断缓存中是否有数据,这样做的目的是避免阻塞在临界区的多个线程在其他线程释放锁后,重复进行数据库的查询和放缓存操作,即这些阻塞线程在阻塞时已经有线程向缓存中放了数据而这些线程并不知道。

测试之后我们就会发现优化后的代码在多线程访问时仅查询一次数据库,其他线程都是直接从缓存中获取到数据的。到此我们就在单体应用上实现了多线程的并发访问。

本地锁在分布式下的问题

但是由于这里我们的“gulimall-product”就部署了一台,所以看上去一切祥和,但是在如果部署了多台,问题就出现了,主要问题就集中在我们所使用的锁上。我们锁使用的是“synchronized ”,这是一种本地锁,它只是在一台设备上有效(也就是本地服务器),无法实现分布式和集群情况下,锁住其他设备(同一服务的其他服务器)的相同操作。

我们现在的操作模型,表现为如下的形式:
image-20221023113508174

我们如果把本地商品服务复制多份,配置不同端口号,然后同时启动。那么我们就可以模拟出删除的分布式模式即我们有多台商品服务的服务器(一个集群)。然后我们再进行压力测试,因为我们之前配置了nginx,且在其配置文件中配置了upstream(本地的88端口),所以它会将请求转给网关,然后网关会负载均衡到服务名为“gulimall-podcut”服务的多个实例上。这样我们就模拟出了商品服务的分布式环境。我们这里再进行压力测试后,就会发现负载均衡后我们每一个商品服务的实例在第一次查询数据时都会去数据库查,然后才会从缓存中查数据。

这就表明了我们的synchronize锁未能实现限制其他服务实例进入临界区即没有锁住其他商品服务器,也就印证了在分布式情况下,本地锁只能针对于当前的服务生效(集群情况下,只对当前服务器生效)。

分布式锁原理与使用

我们已经知道了单体项目在高并发环境下需要加锁,但本地锁只能锁住当前服务,如果是分布式项目我们就需要改为使用分布式锁。

分布式锁原理如下图:

image-20221023114605042

这里我们使用redis实现分布式锁就是因为redis本身就是处于一个(缓存)数据库的身份在我们的项目中,而我们设置分布式锁就需要所有服务都去一个地方(中间件)占坑(即存储一个占位数据),而redis正好有一个setnx …或者 set … nx命令可以帮我们判断是否存在这个占坑数据,且redis是基于内存的性能极高,所以我们选用redis作为我们分布式锁的实现。

这个setnx命令只会在要插入的数据不存在时才会返回ok,其他都会返回一个null

分布式锁阶段一:

这里我们可以直接使用redis存储的key来实现分布式锁(key值唯一)。也就是我们业务中进行查询数据之前都先去redis中判断一下当前我们自定义的分布式锁(就是一个标志位)是否被占用,没有才可以进行查询操作。代码如下:

	public Map<String, List<Catalog2Vo>> getCatalogJsonDbWithRedisLock() {
        //阶段一
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
        //获取到锁,执行业务
        if (lock) {
            //加锁成功。。。执行业务
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
            redisTemplate.delete("lock");//删除锁
            return dataFromDB;
        }else {
            //没获取到锁,等待100ms重试
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //相当于自旋锁
            return getCatalogJsonDBWithRedisLock();
        }
    }

流程图如下:

image-20221023121359392
分布式锁阶段二:

上述代码是有问题的,如果我们的线程获取到锁执行业务时突然出现异常或者突然断电,这时就不会有删除锁的操作,那么锁会一直存在,其他线程就会被一直阻塞造成了死锁的局面。

这时我们就有必要在获取锁时给锁加一个过期时间,使锁即使我们没有删除,它也会自动删除。代码如下:

public Map<String, List<Catelog2Vo>> getCatelogJsonFromDBWithRedisLock() {
        //1、占分布式锁。去redis占坑
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
        if (lock){
            //加锁成功。。。执行业务
            //2、设置过期时间
            redisTemplate.expire("lock",30,TimeUnit.SECONDS);
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
            redisTemplate.delete("lock");//删除锁
            return dataFromDB ;
        }else {
            //加锁失败。。。重试。 synchronized()
            //没获取到锁,等待100ms重试
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return getCatelogJsonFromDBWithRedisLock();//自旋的方式
        }
    }

流程图如下:

image-20221023122247236
分布式锁阶段三:

但这又会出现问题,比如我们的分布式锁刚设置好,进入了锁中正要去设置过期时间,宕机,这就又发生死锁了。 原因就是我们获取锁和设置锁过期时间不是原子性操作。

因此我们接下来就需要把获取锁与设置锁过期时间改为原子性操作。这里redis支持使用setnxex命令在获取锁同时设置过期时间。代码如下:

 public Map<String, List<Catelog2Vo>> getCatelogJsonFromDBWithRedisLock() {
     	//获取锁同时设置过期时间,必须和加锁是同步的,原子的
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
 
        if (lock){
            //加锁成功。。。执行业务
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
            redisTemplate.delete("lock");//删除锁
            return dataFromDB;
        }else {
            //加锁失败。。。重试。 synchronized()
            //没获取到锁,等待100ms重试
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return getCatelogJsonFromDBWithRedisLock();//自旋的方式
        }
    }

流程图如下:

image-20221023122414796
分布式锁阶段四:

但是这又会出现问题:就是我们删除锁是直接删除的,那如果由于我们业务代码执行时间过长,在执行过程期间锁自己过期了,但是当我们业务代码执行完毕删除锁时,因为之前我们锁过期了,会有别的线程抢占这把锁,那么最一开始锁过期的那个线程执行完毕删除锁时就有可能把别人正在持有的锁删除了。

所以我们线程在获取锁占锁的时候,需要指定锁值(value)为一个uuid,每个线程删除锁时匹配是自己的锁才会删除。

代码如下:

public Map<String, List<Catelog2Vo>> getCatelogJsonFromDBWithRedisLock() {
        //1、占分布式锁。去redis占坑
        String uuid = UUID.randomUUID().toString();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
        if (lock){
            //加锁成功。。。执行业务
            //2、设置过期时间,必须和加锁是同步的,原子的
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
            String lockValue = redisTemplate.opsForValue().get("lock");
            if (uuid.equals(lockValue)) {
                //删除我自己的锁
                redisTemplate.delete("lock");//删除锁
            }
            return dataFromDB;
        }else {
            //加锁失败。。。重试。 synchronized()
            //没获取到锁,等待100ms重试
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return getCatelogJsonFromDBWithRedisLock();//自旋的方式
        }
    }

流程图如下:

image-20221023122904145
分布式锁阶段五最终形态

但这也会出现问题,如果删除锁前判断当前锁值是我们线程对应锁的值,正要删除锁的时候,锁已经过期,别人已经设置到了新的值。那么我们删除的仍是别人的锁。

这里我们仍要保证判断锁值与删除锁也要是原子操作。可以使用redis+Lua脚本完成,redis对lua脚本的调用是原子性的,代码如下:

public Map<String, List<Catelog2Vo>> getCatelogJsonFromDBWithRedisLock() {
    	//双端检索机制
        String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
        if(!StringUtils.isEmpty(catalogJSON)) {
            // 缓存不为null直接返回
            Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new 									TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
            return result;
        }
        //1、占分布式锁。去redis占坑
        String uuid = UUID.randomUUID().toString();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
        if (lock){
            //加锁成功。。。执行业务
            //2、设置过期时间,必须和加锁是同步的,原子的
            Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB();
            String script = "if   redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) 			 else return 0 end";
            //删除锁
            Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), 						Arrays.asList("lock"), uuid);
            return dataFromDB;
        }else {
            //加锁失败。。。重试。 synchronized()
            //没获取到锁,等待100ms重试
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return getCatelogJsonFromDBWithRedisLock();//自旋的方式
        }
    }

流程图如下:

image-20221023123139004

至此我们就保证了加锁【占位+过期时间】和删除锁【判断+删除】的原子性操作,但还有一个问题就是锁的过期时间(这里我们进行简化处理,直接设置锁的过期时间远大于我们正常业务查询数据库的时间)。

综上:我们对于缓存击穿问题的解决是通过对所有查询数据库的代码前都加一把通过redis设定的分布式锁。这把分布式锁需要加上过期时间(以防程序异常导致死锁)并且需要和创建锁属于原子操作,然后查询完数据库后需要删除锁,在删除锁之前需要进行判断之前创建的锁是否过期,没过期才删,判断和删除也是原子操作

PS:此处开始直到Redisson属于redis拓展内容,不需要的读者可以直接跳过

为什么最后我们需要是用lua脚本的,redis本身不是支持原子性的吗?

因为我们调用的redisAPI执行任务其底层都是会由Redis的线程去负责执行,所以我们调用API的过程并不是原子性的,因为我们调用了多个API比如判断和删除锁时。,而redis是单线程的,所以redis的任务要么执行成功,要么执行失败,这就是Redis的命令是原子性的原因,但是很重要的一点是redis是单线程。那有同学会问,单线程?不是说redis性能很高吗?单线程为什么性能还高呢?

redis性能高有以下三个原因:

  1. redis是基于内存的,内存的读写速度非常快;
  2. redis是单线程的,省去了很多上下文切换线程的时间;
  3. redis使用多路复用技术,可以处理并发连接。

第一点基于内存我们大家都知道,cpu对内存的读取效率是远远高于磁盘的。

第二点就是说到单线程,其实redis性能高和单线程是有很大关系的,正因为redis采用了单线程,那么它就可以避免上下文即多个线程之间来回切换导致消耗CPU,并且redis不用再去考虑各种加锁释放锁等同步问题。

比如同时有三个命令同时发给redis进行处理,那么由于Redis是使用单线程来处理命令的,所以一条命令来了之后其实并不会立即执行,而是将命令加到一个队列中,然后逐个被执行,这样就能保证在不加锁的情况下保证不会产生并发问题。如果Redis采用多线程执行命令,那么对于这三条命令就会同时执行,这时候就需要添加锁来保证线程的安全性了,会大大降低执行效率。

当然了,单线程机制也不是万能的,也会存在一个致命问题:它对于每个命令的执行时间是有要求的。如果某个命令执行时间过长,会导致其他命令被阻塞,对于Redis这种高性能的服务来说是致命的。

第三点就是redis因为单线程,那么想要处理多个网络连接,就需要采取一些手段,这里redis采用非阻塞IO,使用的就是IO多路复用技术。所谓多路复用,多路指的是多个socket网络连接,复用指的是复用一个线程。

Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现的。

多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。redis中就是采用的epoll作为IO多路复用的实现,再加上Redis自身的事件处理模型将epoll中的连接、读写、关闭都转为事件,不在网络IO上浪费过多的时间。、

**总结:**其实我认为这三点是相互联系起来的,首先redis是基于内存的,这就导致其每次读写速度非常快,有了这个原因redis才会选择使用单线程,因为单线程需要保证每条命令执行时间不能过长,由于单线程,所以最后才会采用多路复用技术,对此我个人是这么理解的。

Redisson

通过以上问题,我们知道了分布式锁的使用,但是过程比较复杂,尤其是我们想要保证分布式锁的删除时还需要我们来写一个脚本。这一个脚本还好,但是一个服务器可能就会有很多需要分布式锁的方法,那么分布式环境下,就会有更多的地方需要用到分布式锁,我们再编写脚本来实现功能就显得有些太过费力。而且JUC包下的那些高级锁其实都是本地锁,我们一旦想要在我们的分布式项目中使用这些高级锁就需要我们手动的编写这些高级锁在分布式环境下的应用,这太过麻烦。因此我们引出了Redisson,它相当于一个加强版的操作redis的(分布式)框架(帮我们封装了更多功能如分布式锁,分布式对象)即Redisson中的锁默认就是分布式锁,因为它本身就是对redis进行封装的,我们之前的分布式锁也是自己对redis进行操作,模拟了一个分布式锁

这里我认为我们之前的例子用的并不是分布式锁,而只是集群锁。因为我们所有调用查数据库的那个方法都是我们的商品服务,只是有很多不同的商品服务的服务器去查(相当于商品服务集群中的一个个节点)。但其实原理是一样的,我们都需要处理在多服务器时的本地锁问题,因为集群和分布式只是针对的对象不同(分布式针对的是不同业务,集群针对的是相同业务的不同服务器)但本质上它们都是多服务器,而不再是单机系统了。

整合Redisson

我们要想使用Redisson,这里仍然需要导入其依赖:

<dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson</artifactId>
     <version>3.12.5</version>
</dependency>

或者我们也可以直接使用Redison提供的一个集成到SpringBoot上的starter。

接下来我们需要配置一下Redisson的配置信息,这里使用配置类的方式:

@Configuration
public class MyRedisConfig {
    	//服务停止后其销毁方法
    	@Bean(destroyMethod="shutdown")
        public RedissonClient redisson() throws IOException {
        	Config config = new Config();
            //因为我们单节点模式即只有一台redis服务器,所以使用单节点方法配置单节点信息
         	config.useSingleServer().setAddress("redis://192.168.10.10:6379");
        	RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

配置完后我们就可以在我们的项目里面使用Redisson了:

@Autowired
RedissonClient redissonClient;

具体测试使用Redisson代码如下:

@GetMapping("/hello")
@ResponseBody
public String hello(){
    //1.获取一把锁,只要名字一样,就是同一把锁
    RLock lock = redisson.getLock("my-lock");
    //2.加锁和解锁
    try {
        lock.lock();
        System.out.println("加锁成功,执行业务方法..."+Thread.currentThread().getId());
        Thread.sleep(30000);
    } catch (Exception e){
    }finally {
        lock.unlock();
        System.out.println("释放锁..."+Thread.currentThread().getId());
    }
    return "hello";
}

Redisson-lock的看门狗机制

既然Redisson给我们封装好了那么多分布式锁,那么我们加下来测试一下如果某一个线程的请求在执行业务方法的时候,突然发生了中断,此时没有来得及执行释放锁操作,那么同时等待的另外一个线程是否会发生死锁。

为了模拟这种情形,我们同时启动10000和10001商品服务

image-20221023162808733

然后同时发送请求:

然后我们在10000端口上的服务获取锁后,突然中断它的运行。

image-20221023162955525

这时我们从redis中仍可以查到锁,但是其上有一个过期时间,那么我们等一下。此时,我们会发现控制台打印了10001端口的服务获取到了锁

image-20221023163112384

redis中的锁也变为10001端口的锁了。

这个结果告诉我们,在我们的线程执行方法加锁后,即便我们没有释放锁,它也会自动的释放锁。这是因为Redisson会为每个锁加上“leaseTime”,默认是30秒。而且我们都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期(以防我们的请求执行时间超时)使这个锁一直有效,但一旦我们的服务器宕机,那么这个看门狗机制也就失效了,无人继续维持这个锁了,那么其在有效期到了之后就会失效。

默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。

由此我们得知redisson的lock具有如下特点

  1. 阻塞式等待。默认的锁的时间是30s。
  2. 锁定的制动续期,如果业务超长,运行期间会自动给锁续上新的30s,无需担心业务时间长,锁自动被删除的问题。
  3. 加锁的业务只要能够运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。

知道这些redisson中的lock的性质后,我们就可以对缓存击穿问题刚才的方案进行简化了,即不需要我们再模拟一个分布式锁(创建锁,加过期时间,怕过期了删错锁加uuid,怕判断后删除又碰巧判断后过期删错锁而使用lua脚本实现原子性),而是使用redisson直接生成一个lock锁就可以实现我们刚才的分布式锁解决击穿问题了。因为其会创建锁自己就会加默认30s过期时间,且不会出现任务执行过程中锁过期别的线程抢占到锁的问题,且任务执行完毕,即使我们不释放它因为有过期时间也会自动释放。代码如下:

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedissonlock() {
        //双端检索机制
        String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
        if(!StringUtils.isEmpty(catalogJSON)) {
            // 缓存不为null直接返回
            Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new 									TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
            return result;
        }
        // 锁的名字,这里不要随便起,最好名字能说明锁的粒度
        RLock lock = redisson.getLock("CatalogJson-lock");
        lock.lock();
        Map<String, List<Catelog2Vo>> dataFromDB;
        try {
            //finally就确保无论出不出异常都会释放锁,不然等过期时间对于并发情况下时间开销太大了
            dataFromDB = getDataFromDB();
        } finally {
            lock.unlock();
        }
        return dataFromDB;
}

lock方法还有一个重载的方法,lock(long leaseTime, TimeUnit unit) :

@Override
public void lock(long leaseTime, TimeUnit unit) {
    try {
        lock(leaseTime, unit, false);
    } catch (InterruptedException e) {
        throw new IllegalStateException();
    }
}

那么我们使用这个方法手动指定锁的有效期之后,锁在其有效期到之后,是否还会自动续期呢?

答案是并不会自动续期。此时如果有多个线程,即便业务仍然在执行,超时时间到了后,锁也会失效,其他线程就会争抢到锁。

  • 这是因为如果我们设置了超时时间后,这个超时时间就会配置到Redisson源码中Redis的执行的lua脚本进行占锁,默认超时就是我们指定的时间。

  • 如果我们未指定超时时间,就使用30*1000【LockWatchdogTimeout看门狗的默认时间】的时间作为超时时间,并且之后会自动续期超时时间(也为30s,实现方式是一个TimeTask定时任务)。

    之所以会有这样不同的情况是因为redisson底层源码进行了if判断,如果我们没有传入超时时间它会调用其看门狗的方法,如果传入了它就不会启用看门狗的方法。但是设置不设置其底层源码设置过期时间都是通过lua脚本实现的(因为要保证原子性)。Rdisson中的锁和我们JUC包中的锁使用方式差不多,因为其实现时就继承了JUC包下的锁进行设计分布式锁。而又为了保证原子性,所以其底层用到了lua脚本。

关于续期周期,只要锁占领成功,就会自动启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10s都会自动再次续期,续成30s。这个10s中是根据( internalLockLeasTime)/3得到的。

尽管相对于lock(),lock(long leaseTime, TimeUnit unit)存在到期后自动删除的问题,但是我们对于它的使用还是比较多的,通常都会评估一下业务的最大执行用时,在这个时间内,如果仍然未能执行完成,则认为出现了问题,则释放锁执行其他逻辑。

Redisson读写锁

读写锁可以保证一定能够读取到最新的数据。因为在修改期间,写锁是一个排他锁(互斥锁),读锁是一个共享锁,写锁如果没有被释放,那么读操作就必须等待。

测试读写锁代码如下:

@GetMapping("/write")
@ResponseBody
public String writeValue(){
    RReadWriteLock writeLock=redisson.getReadWriteLock("rw-loc");
    String uuid = null;
    RLock lock = writeLock.writeLock();
    lock.lock();
    try {
        uuid = UUID.randomUUID().toString();
        redisTemplate.opsForValue().set("writeValue",uuid);
        Thread.sleep(30-000);
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        lock.unlock();
    }
    return uuid;
}
 
@GetMapping("/read")
@ResponseBody
public String redValue(){
    String uuid = null;
    RReadWriteLock readLock=redisson.getReadWriteLock("rw-loc");
    RLock lock = readLock.readLock();
    lock.lock();
    try {
         uuid = redisTemplate.opsForValue().get("writeValue");
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        lock.unlock();
    }
    return uuid;
}

然后我们启动我们的商品服务,分别访问“http://localhost:10000/read”和“http://localhost:10000/write”,观察一下会发现以下现象:

  • 执行写操作时,读操作必须要等待;

  • 可以同时执行多个读操作,读操作之间互不影响;

  • 在写操作时我们可以查看到读写锁

    image-20221023190430392

我们还可以对读写方法进行改进,以此对读写锁的性质进一步了解,代码如下:

    @GetMapping("/write")
    @ResponseBody
    public String writeValue(){
        RReadWriteLock writeLock=redisson.getReadWriteLock("rw-loc");
        String uuid = null;
        RLock lock = writeLock.writeLock();
        lock.lock();
        try {
            log.info("写锁加锁成功");
            uuid = UUID.randomUUID().toString();
            redisTemplate.opsForValue().set("writeValue",uuid);
            Thread.sleep(30000);
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
            log.info("写锁释放");
 
        }
        return uuid;
    }
 
    @GetMapping("/read")
    @ResponseBody
    public String redValue(){
        String uuid = null;
        RReadWriteLock readLock=redisson.getReadWriteLock("rw-loc");
        RLock lock = readLock.readLock();
        lock.lock();
        try {
            log.info("读锁加锁成功");
             uuid = redisTemplate.opsForValue().get("writeValue");
            Thread.sleep(30000);
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
            log.info("读锁释放");
        }
        return uuid;
    }
}

这时我们首先发送一个读请求,然后发送四个读请求,这时我们观察redis中我们获取的读写锁(“rw-loc”)的状态就会发现其状态现在为写状态

image-20221023191540842

我们等待写操作完成后再次查看redis中的“rw-loc”状态,状态为读状态

image-20221023191626508

并且控制台会同时打印这三个读操作获取到锁。

由此我们知道:

  • 读+读:相当于无锁,并发读,只会在redis中记录,所有当前的读锁,都会同时加锁成功

  • 写+读:等待写锁释放;

  • 写+写:阻塞方式

  • 读+写:写锁等待读锁释放,才能加锁

所以只要存在写操作,不论前面是或后面执行的是读或写操作,都会阻塞。

闭锁

闭锁就是我们JUC包下的CountDownLatch。作用是让一线程阻塞直到另一些线程完成一系列操作才被唤醒。代码如下:

image-20221023192019008

信号量测试

首先我们在redis中设置一个值,比如park(停车位)为3

image-20221023192146237

测试代码如下:

image-20221023192228572

每次获取信号量时如果有进行减一操作,没有就会阻塞直到其他线程新增信号量。信号量常作为分布式限流使用,还可以使用tryAcquire方法,这个方法不会在信号量为0时进行阻塞,而是直接返回一个false。其常用作限流处理,只有返回true才会进行复杂的业务逻辑处理,false就不处理。

我们之后使用所有分布式锁都可以直接使用redisson获取就可以了,其下的所有锁都是基于JUC包下的锁直接继承进行实现的,且底层都使用了lua脚本保证redis任务执行的原子性。

缓存一致性解决

缓存一致性是为了解决数据库和缓存的数据不同步问题的。也就是说我们向数据库中的修改操作对缓存来说是不可见的,这样如果我们修改数据库中的数据之后,如果这个数据之前有缓存,那么我们查的数据就仍是缓存中的数据而不是数据库中的数据,这就会出现数据不同步的问题。对于此问题,我们有两种解决方案:

缓存一致性解决方案1——双写模式
image-20221023192731493
缓存一致性解决方案2——失效模式
image-20221023192801597

但是这两种方案(都没有加锁,只是采用逻辑上的策略解决的)还是都会导致缓存不一致问题,虽然对于所有这种并发问题,我们都可以进行加锁来解决,但是加锁是否有必要呢?锁是很笨重的一个东西,加锁可能能解决问题,但是并发性即吞吐量一定是会下降的。

下面我们对不同场景使用不同解决方案:

  1. 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,所以我们不用考虑这个问题(因为上述缓存不一致问题都是某一时刻同时出现修改请求而用户不太可能很短时间内如1s,多次提交修改请求),缓存数据加上过期时间,这样每隔一段时间缓存失效然后触发读的主动更新即可。其实这就足够解决大部分业务对于缓存的要求了。
  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
  3. 通过加分布式读写锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可不适用读写锁);

由此我们需要知道我们放入缓存的数据本就不应该是实时性、一致性要求超高的。所以一般采取缓存数据的时候加上过期时间的方案就足以解决大部分问题,因为不是实时性的所以只要保证每天拿到当前最新数据即可。如果不想拿到脏数据,我们就使用读写锁

如果遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点(读写锁比较耗费时间因为写操作本身就是单线程的)

其实上述数据一致性问题主要就是因为我们对数据库的最后一次修改无法映射到redis上导致的,所以最完美的解决方案其实是使用canal,其功能就相当于一个有一个服务专门监控数据库,binlog就相当于一个数据库的从库,每次只记录mysql中的写操作,也就是说只要mysql数据库有更新就会同步更新redis跟数据库有关数据的更新操作(这样在我们的业务逻辑中就不需要管缓存数据一致性问题了,全部交给canal去监听更新的数据就可以了)。但是其缺点也很明显,我们需要在我们的服务中再加一个中间件,这也是很笨重的行为。其流程如下图:

image-20221023194007731

由于我们系统中使用缓存的数据本就是读多写少的,且一致性要求不高,所以我们系统的一致性解决方案如下:

1、缓存的所有数据都有过期、时间,数据过期下一次查询触发主动更新

2、数据需要进行读写时,加上分布式的读写锁(这样即使偶尔写操作也不会出现数据不一致性问题,并且性能也不会有很大的影响,因为可能很多天才会有一次写操作)。

3、在更新分类数据的时候,删除缓存中的旧数据。

这里进行额外的拓展一下延时双删方案:

其实所有的缓存不一致性问题都是由于缓存和数据库我们需要进行处理,而谁放在后面处理,只要它失败或者有延迟就会出现缓存不一致,这也正是缓存不一致的根本原因所在。所有解决方案和讨论都是围绕这一点来进行的。

因为有先后顺序所以其实共有两种双写方案。但是其实我们更多的是删除缓存而不是更新缓存,为什么呢?
举个例子:

如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能只在最后一次更新后被读取了1次,那么前999次的更新就没有任何必要。反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除(删除前判断key是否存在),只有当缓存真正被读取的时候才去数据库加载

并且由于并发,是很有可能出现你更新数据之后又被其他线程写入旧的缓存

所以加上前面写缓存共有四种方案,但这里我们只讨论删缓存的方案:

  1. 先删除缓存,再更新数据库。解决方案是使用延迟双删。
  2. 先更新数据库,再删除缓存。解决方案是消息队列或者监听binlog同步,引入消息队列会带来更多的问题,对业务代码有一定侵入性,并不推荐直接使用。

由此这里重点介绍延时双删方案

延时双删的方案的思路是为了避免更新数据库的时候,其他线程从缓存中读取不到数据而去查数据库进而把旧数据写入缓存,所以在更新完数据库之后,再sleep一段时间,然后再次删除缓存。

sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于写缓存的时间即可。

延时双删流程:
  1. 线程1删除缓存,然后去更新数据库。这次删除的目的是为了保证在数据库数据修改和第二次redis数据被删除的间隔时间内,如有命中,保证旧数据不存在redis中。如果没有这一次删除,当数据库数据已经被修改了,但是还是可以从redis中读出旧数据,导致数据不一致,因为之后我们有延时策略,所以其实那个间隔时间还是很长的,而从缓存中读取旧数据是比从数据库中读数据是快很多的。
  2. 线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
  3. 线程1根据估算的写缓存时间进行sleep,由于sleep的时间大于线程2读数据+写缓存的时间,所以缓存被再次删除。延时的目的就是怕我们在第二次删除缓存后,重新写入旧的数据进入缓存中。但是这不可避免的可能会让某些线程仍然拿到旧的数据。所以最完美的方法还是要加锁。
  4. 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值

image-20221027220749325

具体业务场景看下图:

image-20221028091622764

显然,以上延时双删的方法仍然有一些局限性。其一,在a时间段内,也就是缓存仍为旧数据且来不及删除旧缓存时间内,仍然会有事务查询得到旧缓存,存在若干次脏数据;其二,b时间段内,我们应尽可能缩短b时间段,以保证A事务尽快更新完数据库后,B事务查询数据库能拿到真数据,所以说延时的目的就是防止在删除旧缓存到更新数据库数据这时间内有并发请求从数据库得到旧数据并写入缓存。

延时如何实现?
  • 比较好的:项目整合quartz等定时任务框架,去实现延时3–5s再去执行最后一步任务
  • 比较一般的:创建线程池,线程池中拿一个线程,线程体中延时3-5s再去执行最后一步任务(不能忘了启动线程),因为线程池中拿到的线程都是空闲的。
  • 比较差的:单独创建一个线程去实现延时执行或者就是本线程直接等待进行延时(但这最差,因为你请求时间就被延长了)
但是删除缓存也有一个问题:如果缓存删除失败了怎么办?

**方案一:**设置过期时间

缓存设置一个过期时间,比如5分钟。当然这种方案只适合数据更新不是太频繁的业务。但是我们一般是都会加上延时时间的

**方案二:**同步重试

在接口中判断是否删除成功,如果失败就重试,直到成功或超过最大重试次数为止,返回数据。当然,这种方案的缺点就是可能影响接口性能。

**方案三:**消息队列

将删除缓存任务写入mq等消息中间件中,在mq的consumer中处理。但问题也很多:

引入消息中间件之后,问题更复杂了,对业务代码有一定侵入性且如果消息丢失怎么办,消息本身的延迟也会带来短暂的不一致性,不过这个延迟相对来说还是可以接受的
**方案四:**订阅mysql的binlog

我们可以借助监听binlog的消息队列来做删除缓存的操作。这样做的好处是,删除动作无需侵入到业务代码,消息中间件帮你做了解耦,同时,中间件的这个东西本身就保证了高可用。

三大缓存问题

缓存穿透

缓存穿透(cache penetration)是用户访问的数据既不在缓存当中,也不在数据库中。出于容错的考虑,如果从底层数据库查询不到数据,则不写入缓存。这就导致每次请求都会到底层数据库进行查询,缓存也失去了意义。当高并发或有人利用不存在的Key频繁攻击时,数据库的压力骤增,甚至崩溃,这就是缓存穿透问题。

缓存穿透发生的场景一般有两类:

  • 原来数据是存在的,但由于某些原因(误删除、主动清理等)在缓存和数据库层面被删除了,但前端或前置的应用程序依旧保有这些数据;
  • 恶意攻击行为,利用不存在的Key或者恶意尝试导致产生大量不存在的业务数据请求。

缓存穿透通常有四种解决方案,我们逐一介绍分析。

**方案一:**缓存空值(null)或默认值

分析业务请求,如果是正常业务请求时发生缓存穿透现象,可针对相应的业务数据,在数据库查询不存在时,将其缓存为空值(null)或默认值。需要注意的是,针对空值的缓存失效时间不宜过长,一般设置为5分钟之内。当数据库被写入或更新该key的新数据时,缓存必须同时被刷新,避免数据不一致。

**方案二:**业务逻辑前置校验

在业务请求的入口处进行数据合法性校验,检查请求参数是否合理、是否包含非法值、是否恶意请求等,提前有效阻断非法请求。比如,根据年龄查询时,请求的年龄为-10岁,这显然是不合法的请求参数,直接在参数校验时进行判断返回。

**方案三:**使用布隆过滤器请求白名单

在写入数据时,使用布隆过滤器进行标记(相当于设置白名单,意思是在布隆过滤器中的字段都是有数据的),业务请求发现缓存中无对应数据时,可先通过查询布隆过滤器判断数据是否在白名单内,如果不在白名单内,则直接返回空或失败,这样就不需要去查数据库了。

**方案四:**用户黑名单限制

当发生异常情况时,实时监控访问的对象和数据,分析用户行为,针对故意请求、爬虫或攻击者,进行特定用户的限制;

当然,可能针对缓存穿透的情况,也有可能是其他的原因引起,可以针对具体情况,采用对应的措施。

总结

在我们的项目中使用的是数据校验+缓存空值进行处理,数据校验可以把一些错误请求或者恶意请求直接拦截掉。或者可以使用数据校验+布隆过滤器,这样其实更好,但是我们项目中没有用到。

缓存雪崩

在使用缓存时,通常会对缓存设置过期时间,一方面目的是保持缓存与数据库数据的一致性,另一方面是减少冷缓存占用过多的内存空间。但当缓存中大量热点缓存采用了相同的失效时间,就会导致缓存在某一个时刻同时实效,请求全部转发到数据库,从而导致数据库压力骤增,甚至宕机,从而形成一系列的连锁反应,造成系统崩溃等情况,这就是缓存雪崩(Cache Avalanche)。

上面讲到的是热点key同时失效的场景,另外就是由于某些原因导致缓存服务宕机、挂掉或不响应,也同样会导致流量直接转移到数据库。所以,缓存雪崩的场景通常有两个:

  • 大量热点key同时过期;

  • 缓存服务故障;

缓存雪崩的解决方案:

  1. 通常的解决方案是将key的过期时间后面加上一个随机数(比如随机1-5分钟),让key均匀的失效。但是这个是有一点问题的,可能原本两个失效时间不一致的缓存在加了随机数之后失效时间就一致了。

  2. 考虑用队列或者锁的方式,保证缓存单线程写,但这种方案可能会影响并发量。

  3. 热点数据可以考虑不失效,后台异步更新缓存,适用于不严格要求缓存一致性的场景。

  4. 双key策略,主key设置过期时间,备key不设置过期时间,当主key失效时,直接返回备key值。但是这会导致缓存大小变大一倍。

  5. 构建缓存高可用集群(针对缓存服务故障情况)。

  6. 当缓存雪崩发生时,服务熔断、限流、降级等措施保障。

总结

在我们的项目中我们使用的是随机数以及ridisson分布式锁

缓存击穿

缓存雪崩是指只大量热点key同时失效的情况,如果是单个热点key,在不停的扛着大并发,在这个key失效的瞬间,持续的大并发请求就会击破缓存,直接请求到数据库,好像蛮力击穿一样。这种情况就是缓存击穿(Cache Breakdown)。

从定义上可以看出,缓存击穿和缓存雪崩很类似,只不过是缓存击穿是一个热点key失效,而缓存雪崩是大量热点key失效。因此,可以将缓存击穿看作是缓存雪崩的一个子集。

缓存击穿的解决方案:

  1. 使用互斥锁(Mutex Key),只让一个线程构建缓存,其他线程等待构建缓存执行完毕,重新从缓存中获取数据。单机通过synchronized或lock来处理,分布式环境采用分布式锁。
  2. 热点数据不设置过期时间,后台异步更新缓存,适用于不严格要求缓存一致性的场景。
  3. ”提前“使用互斥锁(Mutex Key):在value内部设置一个比缓存(Redis)过期时间短的过期时间标识,当异步线程发现该值快过期时,马上延长内置的这个时间,并重新从数据库加载数据,设置到缓存中去,相当于更新了缓存数据和过期时间,类似于redisson的看门狗机制。

总结

在我们的项目中我们使用的是ridisson分布式锁

SpringCache

之前的我们每次进行缓存操作都需要自己使用redisTemplate写很多代码,比如需要我们自己判断是否有缓存数据。如果我们不使用redis了而使用别的缓存技术,我们的代码甚至需要全部进行改动,这是非常麻烦的。

因此Spring从3.1开始定义了Cache(缓存)和CacheManager(管理缓存的)接口来统一不同的缓存技术(其提供了多种缓存技术的实现),并且支持使用JCache注解简化缓存的开发工作。这样我们就不用编写很多操作缓存的代码了(比如双端检索缓存等)。

SpringCache中的CacheManager是用来管理缓存的,它相当于一种规范,基于这种规范SpringCache可以创建出很多Cache组件并对其进行管理,这些Cache组件中存放的才是真正的缓存数据,可以理解为Cache是一个小的缓存数据库,它可以对存储在里面的缓存数据进行CRUD操作,它存放的是相同类型的缓存数据,比如商品的缓存数据全部放在一个Cache中,这样便于管理缓存数据(比如我们可以直接一步将所有商品缓存(Cache)数据删除)。

整合SpringCache

  1. 引入SpringCache的依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    
  2. 引入redis的依赖:因为SpringCache只是帮助我们统一和简化了缓存的开发,所以我们在使用时应该把我们真正实用的缓存中间件的依赖导入我们的项目

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <exclusions>
            <exclusion>
                <groupId>io.lettuce</groupId>
                <artifactId>lettuce-core</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    
  3. 编写SpringCache的配置信息

    这里很多配置信息SpringCache都已经在CacheAutoConfiguration配置好了,比如它会自动导入RedisCacheConfiguration(也会导入其他缓存中间件的配置,这就是其统一缓存技术的体现),那么自然而然也会自动配置了Redis的缓存管理器RedisCacheManager。

    那我们需要配置的就是告诉SpringCache我们使用的缓存中间件是redis即可,因为CacheAutoConfiguration中会默认导入很多缓存配置类(其中就有redis的的缓存配置类RedisCacheConfiguration)

    修改“application.properties”文件,指定使用redis作为缓存,即spring.cache.type=redis

  4. 最后在我们的主启动类上加上@EnableCaching注解即可

到此我们就整合好了Spring Cache。

SpringCache的使用

首先我们对我们刚整合好的SpringCache进行一下测试。使用最基础的SpringCache只需要在使用缓存的方法上加上一个@Cacheable注解即可然后指定一下缓存的名字(即Cache缓存分区的name,这样方便CacheManager进行管理),测试代码如下:

@Cacheable({"category"})
@Override
public List<CategoryEntity> getLevel1Categories() {
    //找出一级分类
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(new QueryWrapper<CategoryEntity>			().eq("cat_level", 1));
    return categoryEntities;
}

这里这个注解的实现是使用AOP来实现的,在执行方法之前查这个key是否存在(实现方式是调用RedisCache的get方法,这里说明一下一个业务方法对应一个key,没有设置那么它有一个默认key)。key存在直接取出缓存,不存在则执行我们的方法去数据库取出数据然后存缓存(调用RedisCache的put方法,这里缓存一定也会有一个key)。

然后我们就可以在我们的redis中惊喜的发现缓存数据了,只不过这里默认是序列化的格式,而并不是json字符串。

这里简要介绍一下我们使用的@Cacheable注解。方法上加上该注解后,表示当前方法将进行读缓存,如果缓存中有,方法则是无效调用;如果缓存中没有,则会调用方法,最后将方法的结果放入到缓存中。

然后下列是缓存中常用注解:

@Cacheable: Triggers cache population. 触发将数据保存到缓存的操作即读缓存
@CacheEvict: Triggers cache eviction. 触发将数据从缓存中删除的操作即失效模式的写缓存
@CachePut: Updates the cache without interfering with the method execution. 在不影响方法执行的情况下更新缓存。即双写模式的写缓存
@Caching: Regroups multiple cache operations to be applied on a method. 组合以上多个操作
@CacheConfig: Shares some common cache-related settings at class-level.在类级别上共享一些公共的与缓存相关的设置。

@Cacheable细节设置

但是我们发现了只是使用@Cacheable缓存的数据格式和其名字都不是我们想要的,所以我们需要对其进行更细致的设置。

上面我们将一级分类数据的信息缓存到Redis中了,缓存到Redis中数据具有如下的特点:

  1. 如果缓存中有,方法不会被调用;

  2. key默认自动生成;形式为"缓存的名字::SimpleKey ";

  3. 缓存的value值,默认使用jdk序列化机制,将序列化后的数据缓存到redis;

  4. 默认ttl时间为-1,表示永不过期

然而这些并不能够满足我们的需要,我们希望能够指定生成的缓存数据所使用的key,并且缓存的数据的存活时间是我们自己指定的时间,将缓存的数据保存为json形式
针对于第一点,我们使用@Cacheable注解的时候,设置key属性,其接受一个SpEL表达式,这里我们只需要设置为一个字符串

@Cacheable(value = {"category"},key = "'level1Categorys'")//双引号中必须加单引号表示表达式是个字符串

针对于第二点,在application.[properties]配置文件中指定ttl:

spring.cache.redis.time-to-live=3600000 #这里指定存活时间为1小时,因为毫秒为单位

自定义缓存配置

上面我们解决了第一个命名问题和第二个设置存活时间问题,但是如何将数据以JSON的形式缓存到Redis呢?

这涉及到修改缓存管理器的设置,CacheAutoConfiguration导入了RedisCacheConfiguration,而RedisCacheConfiguration中自动配置了缓存管理器RedisCacheManager,而RedisCacheManager要初始化所有的缓存,每个缓存决定使用什么样的配置,如果RedisCacheConfiguration有,就用其已有的(把其配置信息取出赋给对应的变量),没有就用默认配置。默认配置的源码如下:

private RedisCacheConfiguration createConfiguration(
      CacheProperties cacheProperties, ClassLoader classLoader) {
   Redis redisProperties = cacheProperties.getRedis();
   RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
   config = config.serializeValuesWith(
         SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(classLoader)));
   if (redisProperties.getTimeToLive() != null) {
      config = config.entryTtl(redisProperties.getTimeToLive());
   }
   if (redisProperties.getKeyPrefix() != null) {
      config = config.prefixKeysWith(redisProperties.getKeyPrefix());
   }
   if (!redisProperties.isCacheNullValues()) {
      config = config.disableCachingNullValues();
   }
   if (!redisProperties.isUseKeyPrefix()) {
      config = config.disableKeyPrefix();
   }
   return config;
}

所以我们想要修改缓存的配置,只需要给容器中放一个**“RedisCacheConfiguration**”即可,这样就会应用到当前RedisCacheManager管理的所有缓存分区中。

image-20221230180458362

在Redis中放入自动配置类,设置JSON序列化机制

@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {
    /**
     * 配置文件中的东西没有用到
     * 1、原来和配置文件绑定的配置类是这样的
     * @ConfigurationProperties(prefix="spring.cache")
     * public class CacheProperties,这里我们还需要让这个配置文件生效,那么只需要加载进这个配置类就可以了
     * @EnableConfigurationProperties(CacheProperties.class)
     * @return
     */
    @Autowired
    CacheProperties cacheProperties;//因为我们就是配置类,默认就从容器中取出数据
    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));//这四行就是修改其数据存储形式的,下面的配置我们需要就改,不需要就粘贴源码
        //将配置文件中的所有配置都生效
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        //设置配置文件中的各项配置,如过期时间
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }
}

这样我们就成功的修改了Redis原本的配置文件了,有点小激动哦。

除此之外,在配置文件中,我们还可以指定一些缓存的自定义配置

spring.cache.type=redis
#设置超时时间,默认是毫秒
spring.cache.redis.time-to-live=3600000
#设置Key的前缀,如果指定了前缀,则使用我们定义的前缀,否则使用缓存的名字作为前缀
spring.cache.redis.key-prefix=CACHE_
#是否开启key的前缀
spring.cache.redis.use-key-prefix=true
#是否缓存空值,可以用来防止缓存穿透
spring.cache.redis.cache-null-values=true

@CacheEvict

在上面实例中,在读模式@Cacheable中,我们将一级分类信息缓存到redis中,当请求再次获取数据时,直接从缓存中进行获取。但是如果执行的是写模式呢?

写模式下,有两种方式来解决缓存一致性问题,双写模式和失效模式,在SpringCache中可以通过**@CachePut来实现双写模式**,使用**@CacheEvict来实现失效模式**。

实例:使用缓存失效机制实现更新数据库中值时使得缓存中的数据失效

修改updateCascade方法,添加@CacheEvict注解,指明要删除哪个分类下的数据,并且确定key:

	@CacheEvict(value = "category")  // 失效模式
    @Transactional
    @Override
    public void updateCasecade(CategoryEntity category) {
        this.updateById(category);
        categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
    }

这时只要我们执行更新操作,原本的缓存数据就会被清除。

另外在修改了一级分类缓存时,对应的二级分类缓存也需要更新(因为二级分类是在一级分类下的),这时需要修改原来二级分类的执行逻辑。

将“getCatelogJson”恢复成为原来的逻辑,但是设置@Cacheable,非侵入的方式将查询结果缓存到redis中:

@Cacheable(value = {"category"},key = "#root.methodName")//key值是spel表达式,这里#r
@Override
public Map<String, List<Catelog2Vo>> getCatelogJson() {
    log.info("查询数据库");
    //一次性查询出所有的分类数据,减少对于数据库的访问次数,后面的数据操作并不是到数据库中查询,而是直接从这个集合中获取,
    // 由于分类信息的数据量并不大,所以这种方式是可行的
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(null);
    //1.查出所有一级分类
    List<CategoryEntity> level1Categories = getParentCid(categoryEntities,0L);
    Map<String, List<Catelog2Vo>> parent_cid = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), level1 -> {
        //2. 根据一级分类的id查找到对应的二级分类
        List<CategoryEntity> level2Categories = getParentCid(categoryEntities,level1.getCatId());
        //3. 根据二级分类,查找到对应的三级分类
        List<Catelog2Vo> catelog2Vos =null;
        if(null != level2Categories || level2Categories.size() > 0){
            catelog2Vos = level2Categories.stream().map(level2 -> {
                //得到对应的三级分类
                List<CategoryEntity> level3Categories = getParentCid(categoryEntities,level2.getCatId());
                //封装到Catalog3List
                List<Catalog3List> catalog3Lists = null;
                if (null != level3Categories) {
                    catalog3Lists = level3Categories.stream().map(level3 -> {
                        Catalog3List catalog3List = new Catalog3List(level2.getCatId().toString(), level3.getCatId().toString(), level3.getName());
                        return catalog3List;
                    }).collect(Collectors.toList());
                }
                return new Catelog2Vo(level1.getCatId().toString(), catalog3Lists, level2.getCatId().toString(), level2.getName());
            }).collect(Collectors.toList());
        }
        return catelog2Vos;
    }));
    return parent_cid;
}

我们此时再次访问首页,发现控制台数据未更新,还是第一次访问时的输出:即缓存数据仍在

上面我们将一级和三级分类信息都缓存到了redis中,现在我们想要实现一种场景是,更新分类数据的时候,将缓存到redis中的一级和三级分类数据都清空。

这时可以借助于“@Caching”组合失效模式的写缓存操作来完成(指定上需要组合删除的缓存的key)

@Caching(evict={
   @CacheEvict(value = {"category"},key = "'level1Categorys'"),
   @CacheEvict(value = {"category"},key = "'getCatelogJson'")
})
@Override
@Transactional
public void updateCascade(CategoryEntity category) {
    this.updateById(category);
    relationService.updateCategory(category.getCatId(),category.getName());
}

除了可以使用@Caching外,还可以使用@CacheEvict来完成:

@CacheEvict(value = {"category"},allEntries = true)

它表示要删除“category”分区下的所有数据。

可以看到存储同一类型的数据,都可以指定同一个分区,那么我们也就可以批量删除这个分区下的数据。以后我们再缓存数据时就可以把同类的数据指定同一个分区(value属性值),这里老师没有讲明白,我自己查了查资料,在redis官方中说key的命名其实是可以加分割符的,比如:分隔不同的层次即key的命名空间,如:我们 set user:id12345:contact,那么在RDM可视化工具中就会展示如下形式

image-20221024104230643

如果某个对象有字段的字段,用.连接。如user:id12345:contact.mail

单冒号:和双冒号::都可以指定key的命名空间,只不过双冒号会计算该命名空间下的所有key总数,这里SpringCache的分区概念应该就是借鉴的redis的命名空间概念,我们可以理解key为一个个文件,而SpringCache的缓存分区和Redis的命名空间都可以理解为是一个文件夹

SpringCache-原理与不足

Spring-Cache的不足:

  1. 读模式有以下几个并发问题

    • 缓存穿透: 查询数据库结果是null值,那么就没有缓存导致一直查数据库。解决方法是缓存空数据;cache-null-value=true;
    • 缓存击穿: 大量并发进来同时查询一个正好过期的数据。解决方法是进行加锁,默认是没有加锁的,查询时设置Cacheable的sync=true即可解决缓存击穿(设置一个本地锁,但是已经够用了)。
    • 缓存雪崩: 大量的key同时过期。解决方法是加上随机时间;加上过期时间。spring.cache.redis.time-to-live=3600000
  2. 写模式(在并发下会存在缓存与数据库数据一致性问题

    • 读写加锁;适用于读多写少的环境,那么我们加读写锁就可以了

      • 引入canal,感知到mysql的更新去更新数据库;

      • 读多写多,直接去数据库查询就行;

这里我们就看到了关于读模式的缓存穿透问题,SpringCache是解决了的。那么缓存雪崩其实对于一些小项目来说并不会存在这种问题,基本上给缓存加一个随机事件就可以了。这里我们就来看一下关于缓存击穿问题SpringCahce有没有解决。

SpringCache流程解析:

我们知道SpringCache的原理是我们导入SpringCache的依赖之后会导入一个CacheAutoConfiguration类,这个类会加载很多缓存配置类,这里我们只说加载的RedisCacheConfiguration类,这个类会自动配置RedisCacheManager,然后初始化其下的所有缓存(同时约定这些缓存的配置信息),初始化之后所有的Cache就创建完了(这里创建的就是RedisCache),这个Cache负责缓存数据的读写。

那么在我们执行读缓存方法时究竟有没有加锁呢?

通过给源码打断点我们知道首先会执行RedisCache的lookup方法去缓存中查是否有这个key的缓存,然后调用get方法,如果没有这个key的缓存数据那么之后就会返回null。然后缓存未命中(cacheHit==null)之后就会执行我们的业务方法去数据库中取数据。,之后调用RedisCache的put方法向缓存中放数据。执行到这里我们就会发现去缓存查数据和执行业务方法上并没有加锁,即SpringCache默认是没有给读缓存加锁的也就没有解决缓存击穿问题。

但是@Cacheable这个注解有一个sync属性可以给读缓存的方法加上锁,具体流程如下:

设置sync=true。然后还是会先执行lookup方法之后一样调用get方法,之后这里调用有点多不细讲了,最后会调用一个加了synchronized关键字的get方法。

image-20221230194559653

在这个方法中去redis中读取缓存,没有就去数据库查数据然后放入缓存中,其整个流程都是同步的,但是因为加的synchronized关键字,所以这里是一个本地锁并不是分布式锁。

到这里我们就知道了SpringCache实际上也解决了读模式的缓存击穿的问题

但是写模式是由于我们实际业务不同情况而不同执行的,所以SpringCache没有解决写缓存下缓存数据一致性的问题的

总结:

常规数据(读多写少,即时性,一致性要求不高的数据)完全可以使用spring-cache因为其读模式没有任何问题(可以加锁也可以不加,看具体业务需求),写模式因为一致性要求不高,所以只要缓存的数据有过期时间就足够了(保证修改后一段时间可以更新修改后的数据进缓存);特殊数据(一致性要求高的数据):那么就需要进行特殊设计,目前本项目涉及不到。


感谢耐心看到这里的同学,觉得文章对您有帮助的话希望同学们不要吝啬您手中的赞,动动您智慧的小手,您的认可就是我创作的动力!
之后还会勤更自己的学习笔记,感兴趣的朋友点点关注哦。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值