缓存使用

为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而DB承担数据落盘工作。

哪些数据适合放入缓存?

  • 即时性、数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多、写少)

整合redis

1. 引入redis依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 配置

spring:
	redis:
    	host: 192.168.56.10
    	port: 6379

3. 操作

可使用SpringBoot自动配置好的StringRedisTemplate来操作redis

@Autowired
public StringRedisTemplate stringRedisTemplate;
@Test
public void redisTest() {
    ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
    valueOperations.set("k1","hello world");
    System.out.println(valueOperations.get("k1"));
}

使用redis存储数据的一般步骤为

@Override
public Map<String, List<Catalogs2Vo>> getCatalogJson() {
    // 1.从缓存中读取分类信息
    String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
    if (StringUtils.isEmpty(catalogJSON)) {
        // 2. 缓存中没有,查询数据库
        Map<String, List<Catalogs2Vo>> catalogJsonFromDB = getCatalogJsonFromDB();
        // 3. 查询到的数据存放到缓存中,将对象转成 JSON 存储
        redisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJsonFromDB));
        return catalogJsonFromDB;
    }
    return JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalogs2Vo>>>() {});
}

4. 注意

现象

在用上面的配置进行压力测试时,会出现io.netty.util.internal.OutOfDirectMemoryError的错误,即产生了堆外内存溢出,主要的原因是:springboot2.0以后默认使用lettuce作为操作redis的客户端,它使用netty进行网络通信,而由于lettuce自身的bug导致一些使用过的内存没有被及时清理掉,因此最终会出现内存溢出的问题。

解决方案

思路:切换使用jedis

<!-- 排除lettuce -->
<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>
<!-- 引入jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

高并发下缓存失效问题

缓存穿透

现象

指查询一个一定不存在的数据,由于缓存时不命中,就会去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。

风险

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

解决

null结果缓存,并加入短暂过期时间。

缓存雪崩

现象

指设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

解决

原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

缓存击穿

现象

对于一些设置了过期时间的key,如果这些key在某些时间点被超高并发地访问,这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到DB。

解决

加锁。大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去DB。

加锁解决缓存击穿问题

可以使用本地锁synchronized实现,即在每次要去数据库请求时进行加锁,只有获得锁的用户能够去数据库拿数据,拿出数据后放入缓存中,释放锁。后面获得锁的用户在缓存中先查看是否有数据,从而避免大量用户去数据库里拿数据。

存在的问题:在分布式情况下,本地锁只能锁住某一台服务器的请求,其他服务器的无法锁住,因此需要使用分布式锁。

Redisson

官方文档

1. 引入依赖

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

2. 配置

@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException {
    // 1、创建配置
    Config config = new Config();
    // Redis url should start with redis:// or rediss://
    config.useSingleServer().setAddress("redis://192.168.56.10:6379");

    // 2、根据 Config 创建出 RedissonClient 实例
    return Redisson.create(config);
}

3. 使用

@GetMapping(value = "/hello")
@ResponseBody
public String hello() {
    //1、获取一把锁,只要锁的名字一样,就是同一把锁
    RLock myLock = redisson.getLock("my-lock");

    //2、加锁
    myLock.lock();      //阻塞式等待。默认加的锁都是30s
    try {
        System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
        try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
    } catch (Exception ex) {
        ex.printStackTrace();
    } finally {
        //3、解锁  假设解锁代码没有运行,Redisson会不会出现死锁
        System.out.println("释放锁..." + Thread.currentThread().getId());
        myLock.unlock();
    }
    return "hello";
}

注意

  • lock()方法式阻塞式等待
  • lock()方法默认的加锁时间式30s,避免死锁情况的产生
  • lock()不加参数会自动触发看门狗机制
看门狗机制

lock()方法不加参数时,执行过程中会添加一个定时任务,在设置的看门狗时间(默认30s)/3的周期内会自动把锁的过期时间续成30s,这样只要业务一直在运行中,锁就不会过期。加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在30s内自动过期,不会产生死锁问题。

自定义过期时间
myLock.lock(10,TimeUnit.SECONDS);   //10秒钟自动解锁,自动解锁时间一定要大于业务执行时间

可以通过上面方法设置一个自定义过期时间,一旦设置了自定过期时间,在锁时间到了以后,不会自动续期,所以自动解锁时间一定要大于业务执行时间

读写锁

/**
     * 保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁),读锁是一个共享锁
     * 写锁没释放读锁必须等待
     * 读 + 读 :相当于无锁,并发读,只会在Redis中记录好,所有当前的读锁。他们都会同时加锁成功
     * 写 + 读 :必须等待写锁释放
     * 写 + 写 :阻塞方式
     * 读 + 写 :有读锁。写也需要等待
     * 只要有读或者写的存都必须等待
     * @return
*/
@GetMapping(value = "/write")
@ResponseBody
public String writeValue() {
    String s = "";
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    RLock rLock = readWriteLock.writeLock();
    try {
        //1、改数据加写锁,读数据加读锁
        rLock.lock();
        s = UUID.randomUUID().toString();
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.set("writeValue",s);
        TimeUnit.SECONDS.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }

    return s;
}

@GetMapping(value = "/read")
@ResponseBody
public String readValue() {
    String s = "";
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    //加读锁
    RLock rLock = readWriteLock.readLock();
    try {
        rLock.lock();
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        s = ops.get("writeValue");
        //try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }

    return s;
}

闭锁

/**
     * 放假、锁门
     * 1班没人了
     * 5个班,全部走完,我们才可以锁大门
     * 分布式闭锁
*/

@GetMapping(value = "/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {

    RCountDownLatch door = redisson.getCountDownLatch("door");
    door.trySetCount(5);
    door.await();       //等待闭锁完成

    return "放假了...";
}

@GetMapping(value = "/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) {
    RCountDownLatch door = redisson.getCountDownLatch("door");
    door.countDown();       //计数-1

    return id + "班的人都走了...";
}

信号量

/**
     * 车库停车
     * 3车位
     * 信号量也可以做分布式限流
*/
@GetMapping(value = "/park")
@ResponseBody
public String park() throws InterruptedException {

    RSemaphore park = redisson.getSemaphore("park");
    park.acquire();     //获取一个信号、获取一个值,占一个车位
    boolean flag = park.tryAcquire();

    if (flag) {
        //执行业务
    } else {
        return "error";
    }

    return "ok=>" + flag;
}

@GetMapping(value = "/go")
@ResponseBody
public String go() {
    RSemaphore park = redisson.getSemaphore("park");
    park.release();     //释放一个车位
    return "ok";
}

缓存数据一致性

当数据库数据更新时,缓存数据便不是最新的,为了保证两者数据一致性,可采取双写模式和失效模式两种解决方案。

  • 双写模式:在更新数据库时将缓存数据更新一份
  • 失效模式:更新数据库时删除缓存,下次读数据时会自动将数据库最新数据写到缓存中

解决方案

无论是双写模式还是失效模式,都会导致缓存不一致的问题,即多个实例同时更新会出事。解决方案:

  1. 如果是用户维度数据(订单数据、用户数据),这种并发几率小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
  2. 如果是菜单、商品介绍等基础数据,也可使用cannal订阅binlog的方式。
  3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
  4. 通过加读写锁。

总结

  1. 我们能放入缓存的数据不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
  2. 不应过度设计,增加系统复杂性。
  3. 遇到实时性、一致性要求高的数据,就应该查数据库。

SpringCache

官方文档

整合

1、引入依赖

<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>
    
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

2、配置redis

spring.cache.type=redis

#spring.cache.cache-names=qq,毫秒为单位
spring.cache.redis.time-to-live=3600000

#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true

#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true

3、注解介绍

  • @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.在类级别共享缓存的相同配置

4、开启缓存功能

//在入口函数上添加此注解,即开启缓存
@EnableCaching
@EnableFeignClients(basePackages = "com.atbgpi.mall.product.feign")
@MapperScan("com.atbgpi.mall.product.dao")
@EnableDiscoveryClient
@SpringBootApplication
public class MallProductApplication {

    public static void main(String[] args) {
        SpringApplication.run(MallProductApplication.class, args);
    }

}

5、测试缓存功能

@Override
/**
	*此部分返回的数据会自动缓存
	*其中value是缓存的一片空间,删除时可以删除该空间下的所有缓存
	*key为缓存的键值
	*sync默认为false,为true时表示开启同步锁,即可避免缓存击穿问题
*/
@Cacheable(value = {"category"}, key = "#root.method.name", sync = true)
public <T> getValue() {
    //正常业务查询处理
}

6、@CacheEvict使用

@Override
//执行此函数后会自动删除category这片空间下的所有缓存
@CacheEvict(value = {"category"}, allEntries = true)
public void updateDetail() {
    //正常更新业务
}

自定义缓存的格式为json

默认情况下,存储的数据是经过jdk序列化的数据,数据的扩展性不强,不能和其他平台共享,因此可统一转为json格式存储

package com.atbgpi.mall.product.config;

import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class MyCacheConfig {
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration (CacheProperties cacheProperties) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();

        //设置key和value的序列化方式
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));

        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;

    }
}

SpringCache的原理与不足

原理

CacheManager(RedisCacheManager)->Cache(RedisCache)->Cache负责缓存的读写

不足

1、读模式

  • 缓存穿透:查询一个null数据。解决方案:缓存空数据
  • 缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方案:默认是无加锁的;使用sync = true来解决击穿问题
  • 缓存雪崩:大量的key同时过期。解决:加随机时间。加上过期时间

2、写模式

  • 读写加锁。
  • 引入Canal,感知到MySQL的更新去更新Redis
  • 读多写多,直接去数据库查询就行

3、总结

  • 常规数据(读多写少,即时性,一致性要求不高的数据,完全可以使用Spring-Cache),写模式(只要缓存的数据有过期时间就足够了)
  • 特殊数据:特殊设计