子丹商城——使用缓存优化三级分类目录查询

首先,为什么时候缓存,就不用说了;

一、使用SpringCache+Redis实现缓存

SpringCache使用Cache和CacheManager接口来统一不同的缓存技术,而Redis只是其中的一种实现,SpringCache的官方手册:

https://docs.spring.io/spring/docs/5.2.7.RELEASE/spring-framework-reference/integration.html#cache-annotations

1、pom.xml:

<!--使用SpringCache实现缓存-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!--SpringCache缓存实现我们选择Redis,而redis客户端我们使用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>

2、配置文件中,我们只需要配置Redis的连接信息和缓存使用Redis即可:

#我们使用redis作为springCache的缓存实现
spring: 
  redis:
    host: 192.168.174.141
    port: 6379
    timeout: 5000
  cache:
    type: redis

3、在启动类上开启缓存:

@EnableCaching

4、使 @用Cacheable 注解,表示对某个方法的返回结果进行缓存

@Override
@Cacheable(cacheNames = {"category"}, key = "#root.method.name")
public List<CategoryEntity> getLevel1Categorys() {
    System.out.println("进入getLevel1Categorys方法");
    return this.list(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
}

5、测试,看看缓存是否生效,我们,多访问几次首页后:

18.jpg

显然,缓存已经生效;但是缓存的TTL=-1,即永不过期,所以我们可以在配置文件中增加一个缓存过期时间;

spring: 
  cache:
    type: redis
    redis:
      time-to-live: 60000   #单位ms

再次测试:

19.jpg


二、将缓存在Redis数据保存为json格式

Redis实现SpringCache的原理:

CacheAutoConfiguration —> RedisCacheConfiguration ——> 自动配置了 RedisCacheManager ——>初始化所有的缓存,每个缓存决定使用什么配置 ——>如果 RedisCacheConfiguration 有就用已有的,没有就使用默认配置 

——>所以,如果想修改缓存的配置,只需要给容器中放一个 RedisCacheConfiguration 即可

——>就会应用到当前RedisCacheManager管理的所有缓存分区中

1、新增我们自己的缓存配置 MyCacheConfig.java

@Configuration
@EnableCaching
public class MyCacheConfig {
    @Autowired
    StringRedisTemplate redis;

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return config;
    }
}

2、再次进行测试:

20.jpg

显然,我们的Json序列化已经完成了,但是出现了另一个问题,就是TTL时间又变为-1(即永不过期了);

该怎么办呢?

3、我们需要将原有的缓存配置,拷贝一份出来,到新的缓存配置中:

//让CacheProperties的配置生效
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {

    /**
     * 将原配置文件中的所有配置,都继承过来
     * @return
     */
    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .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;
    }
}

4、再次测试:

21.jpg

5、再看看其他几个配置项:

spring: 
  cache:
    type: redis
    redis:
      time-to-live: 60000   #单位ms
      key-prefix: CACHE_    #添加统一缓存前缀
      use-key-prefix: true   #是否使用上面添加的缓存前缀
      cache-null-values: true   #是否缓存null空值,防止缓存穿透

三、为不同的cacheName设置不同的过期时间

如果,我们想为不同的场景下,不同的cacheName配置不同的缓存参数,如过期时间怎么办?

1、我们在配置文件中,自定义配置:application.yml:

cache:
  specs:
    category1:
      timeToLiveInSeconds: 60
    category2:
      timeToLiveInSeconds: 200

2、使用配置对象收集:

CacheSpecs.java:

@Data
public class CacheSpecs {
    private Integer timeToLiveInSeconds;
}

CacheSpecConfig.java:

@Configuration
@ConfigurationProperties(prefix = "cache")
@Data
public class CacheSpecConfig {
    private Map<String, CacheSpecs> specs=new HashMap<>();
}

3、改造我们刚刚的Cache配置类,MyCacheConfig.java:

//让CacheProperties的配置生效
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {

    @Autowired
    CacheSpecConfig cacheSpecConfig;

    /**
     * 如果有,就将原配置文件中的所有配置,都继承过来
     * @return
     */
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory cf, CacheProperties cacheProperties) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .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 RedisCacheManager.builder(cf).cacheDefaults(config).initialCacheNames(cacheSpecConfig.getSpecs().keySet())
                .withInitialCacheConfigurations(getRedisCacheConfigurationMap(config)).build();
    }

    private Map<String, RedisCacheConfiguration> getRedisCacheConfigurationMap(RedisCacheConfiguration defaultConfig) {
        Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();
        Map<String, CacheSpecs> specs = cacheSpecConfig.getSpecs();
        for (Map.Entry<String, CacheSpecs> item : specs.entrySet()) {
            redisCacheConfigurationMap.put(item.getKey(),defaultConfig.entryTtl(Duration.ofSeconds(item.getValue().getTimeToLiveInSeconds())));
        }
        return redisCacheConfigurationMap;
    }
}

4、然后我们需要缓存的地方如何写那?

24.jpg

5、启动测试:

22.jpg
23.jpg

显然,我们为各自定义的过期时间已经生效;

且JSON序列化器也生效;


四、使用@CacheEvict、@CachePut注解,删除或更新缓存

1、比如,我们分类的更新操作:

@Transactional
@Override
@CacheEvict(cacheNames = "catelog1", key = "'getLevel1Categorys'")
public void updateCascade(CategoryEntity category) {
     this.updateById(category);
     //更新其他受影响数据
     categoryBrandRelationService.updateCategoryName(category.getCatId(), category.getName());
}

代表,此方法调用成功后,则删除 cacheName为catelog1的缓存;

2、但是实际情况是,我们想一次删除多个缓存呢?应该使用@Caching包装多个操作:

@Transactional
@Override
@Caching(evict = {
        @CacheEvict(cacheNames = "category1", allEntries = true),
        @CacheEvict(cacheNames = "category2", allEntries = true)
})
public void updateCascade(CategoryEntity category) {
    this.updateById(category);
    //更新其他受影响数据
    categoryBrandRelationService.updateCategoryName(category.getCatId(), category.getName());
}

3、测试可行!

4、使用 @CachePut 可用来更新缓存,即当前操作有返回值,清除缓存的同时,加入新的缓存

@CachePut(cacheNames = "category1", key = "'getLevel1Categorys'")

五、高并发场景下的缓存问题:

1、缓存穿透

指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无记录,然后我们也没有将这次查询得到的null写入缓存,这将导致这个不存在的数据每次请求都要到数据库中去查询,缓存失去了作用;

风险:

利用一定不存在的数据进行攻击,数据库瞬间压力增大,最终导致崩溃;

解决:

null结果也放入缓存,并加入短暂过期时间,我们使用SpringCache默认null值就是写入缓存的,

cache-null-values: true

如果部分情况下,我们不想将null 写入缓存,可以使用

unless = "#result == null"

2、缓存雪崩

指我们有大量的缓存key在同一时间,同时失效,请求一下子全部到了数据库,DB瞬间压力过重而雪崩

解决:

在缓存时间增加随机数,降低时间重复率,但是我觉得意义不大,反倒容易弄巧成拙;

3、缓存击穿

指对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是非常“热点”的数据;如果这个key在大量请求同时进来的时候正好失效,那么所有的对这个key的数据查询都会落到数据库,我们称为缓存击穿;比如:后半夜所有人睡觉时候有些key失效了,但是到了早上8点钟,突然很多人一下子进来,这个时候就相当于缓存击穿了;

解决:

加锁,大量并发去查同一个值,指让一个人查,其他人等待,在SpringCache中默认缓存方法是没加synchronized锁的,但是如果想加,我们可以增加同步

sync = true

这样,同一时间进来的查询就会被加锁;

但是,只是加的本地锁,其实是已经够用了,如果我们想实现集群之间的锁,可以使用分布式锁,可以使用Redisson、Curator进行实现,分布式锁环节有讲过;

4、缓存数据一致性问题(缓存和数据库不一致)

指,在我们更新数据的时候,肯定要更新缓存,不然就不一致了;但是如何更新呢?两种方案:

  • 双写模式:更新数据库后更新缓存

  • 失效模式:更新数据库后,清空缓存

但是,无论“双写模式”还是“失效模式”,严格上,都还是有可能出现缓存不一致问题,即多个实例同时更新会出事,或者出现了更新数据库时成功,更新或清除缓存时不成功的情况,然后缓存里面存的还是旧数据(错数据);这种情况该怎么办?

1、无论是用户维度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间都会自动失效一次;(所以,一定要为缓存设置过期时间)

2、缓存数据+过期时间也足以解决大部分业务对缓存的要求;

3、通过加锁保证并发读写,写写的时候按顺序排好队,读读无所谓,所以适合选择使用读写锁。

4、如果很重要的缓存,可以使用Canal,Canal可以将自己伪装成DB数据库的从节点,订阅主节点的binlog日志方式,在数据库发生改变的时候,主动去更新缓存,与主程序分离;

25.jpg

jiguiquan@163.com

文章作者信息...

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐