缓存使用
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而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";
}
缓存数据一致性
当数据库数据更新时,缓存数据便不是最新的,为了保证两者数据一致性,可采取双写模式和失效模式两种解决方案。
- 双写模式:在更新数据库时将缓存数据更新一份
- 失效模式:更新数据库时删除缓存,下次读数据时会自动将数据库最新数据写到缓存中
解决方案
无论是双写模式还是失效模式,都会导致缓存不一致的问题,即多个实例同时更新会出事。解决方案:
- 如果是用户维度数据(订单数据、用户数据),这种并发几率小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
- 如果是菜单、商品介绍等基础数据,也可使用cannal订阅binlog的方式。
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
- 通过加读写锁。
总结
- 我们能放入缓存的数据不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
- 不应过度设计,增加系统复杂性。
- 遇到实时性、一致性要求高的数据,就应该查数据库。
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),写模式(只要缓存的数据有过期时间就足够了)
- 特殊数据:特殊设计