目录
缓存
1.缓存的使用
使用缓存的目的就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性 能,带来更高的并发量。Redis 的读写性能比 Mysql 好的多,我们就可以把 Mysql 中的热点数据缓 存到 Redis 中,提升读取性能,同时也减轻了 Mysql 的读取压力。
具体流程:
data = cache.load(id);//从缓存加载数据
If(data == null){
data = db.load(id);//从数据库加载数据
cache.put(id,data);//保存到 cache 中
}
return data
2.整合redis
会产生堆外内存溢出异常:OutOfDirectMemoryError。
原因:
- SpringBoot 2.0 以后默认使用 lettuce 作为操作 redis 的客户端,它使用 netty 进行网络通信;
- lettuce 的 bug 导致 netty 堆外内存溢出;
- netty 如果没有指定堆外内存,默认使用 -Xmx 参数指定的内存;
- 可以通过 -Dio.netty.maxDirectMemory 进行设置;
解决方案:
升级 lettuce 客户端,或使用 jedis 客户端
1、引入 redis-starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、配置 redis
spring:
redis:
host: 192.168.56.10
port: 6379
3、使用 RedisTemplate 操作 redis
@Autowired
StringRedisTemplate stringRedisTemplate;
@Test
public void testStringRedisTemplate(){
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("hello","world_"+ UUID.randomUUID().toString());
String hello = ops.get("hello");
System.out.println(hello);
}
4、切换使用 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>
给业务中加入缓存
这里使用依赖中自带的 StringRedisTemplate 来操作 Redis。这里存储的值为转化成 JSON 字符串的对象信息。
@Autowired
StringRedisTemplate redisTemplate;
@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>>>(){});
}
/**
* 加缓存前,只读取数据库的操作
*
* @return
*/
public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDB() {
System.out.println("查询了数据库");
// 性能优化:将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
//1、查出所有分类
//1、1)查出所有一级分类
List<CategoryEntity> level1Categories = getParentCid(selectList, 0L);
//封装数据
Map<String, List<Catalogs2Vo>> parentCid = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//1、每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());
//2、封装上面的结果
List<Catalogs2Vo> catalogs2Vos = null;
if (categoryEntities != null) {
catalogs2Vos = categoryEntities.stream().map(l2 -> {
Catalogs2Vo catalogs2Vo = new Catalogs2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName().toString());
//1、找当前二级分类的三级分类封装成vo
List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());
if (level3Catelog != null) {
List<Catalogs2Vo.Category3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
Catalogs2Vo.Category3Vo category3Vo = new Catalogs2Vo.Category3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
return category3Vo;
}).collect(Collectors.toList());
catalogs2Vo.setCatalog3List(category3Vos);
}
return catalogs2Vo;
}).collect(Collectors.toList());
}
return catalogs2Vos;
}));
return parentCid;
}
缓存失效问题
1.缓存穿透
缓存穿透是指 查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的 null 写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
解决方法:缓存空结果、并且设置短的过期时间。
2.缓存雪崩
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
解决方法:原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
3.缓存击穿
对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:如果这个 key 在大量请求同时进来前正好失效,那么所有对这个 key 的数据查询都落到 db,我们称为缓存击穿。
解决方法:加锁。大量并发只让一个人去查,其他人等待,查到之后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去查数据库。
缓存数据一致性
当数据库有数据更新时,在很长的一段时间内(决定于缓存的过期时间),用户请求从缓存中获取到的都可能是旧值,而非数据库的最新值。那么,该如何更新缓存呢?目前有以下四种解决方案:
先写缓存,再写数据库(差)
缺点:如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。这样缓存中的数据就变成脏数据,这个问题非常严重,也是最差的一种解决方案。
先写数据库,再写缓存(一般)
缺点一:问题又来了,写数据库成功,但写缓存失败了,依然会造成缓存脏数据的问题。但写缓存失败比写数据库失败的概率要小很多了(因为数据库可能有加锁、外键约束、超时等机制限制),所以此方案要比第一种方案好一点。
如果对接口性能要求不高,还可以把写数据库和写缓存放到一个事务中,写缓存失败就回滚数据库。
缺点二:高并发情况下:
- 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
- 这时候请求b过来了,先写了数据库。
- 接下来,请求b顺利写了缓存。
- 此时,请求a卡顿结束,也写了缓存。
也就是说:在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。
先删缓存,再写数据库(能接受)
缺点:
- 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 请求c将数据库中的旧值,更新到缓存中。
- 此时,请求d卡顿结束,把新值写入数据库。
这种极端情况下依然会导致写入的缓存为旧值。
延迟双删
为了避免以上,写完数据库后,再删除一次。
该方案有个非常关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后。
sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。
那么,为什么一定要间隔一段时间之后,才能删除缓存呢?
请求d卡顿结束,把新值写入数据库后,请求c将数据库中的旧值,更新到缓存中。此时,如果请求d删除太快,在请求c将数据库中的旧值更新到缓存之前,就已经把缓存删除了,这次删除就没任何意义。必须要在请求c更新缓存之后,再删除缓存,才能把旧值及时删除了。
先写数据库,再删缓存(比较优秀)
缓存和数据库,无论先处理谁,只要后者有延迟/失败,都会导致不一致的情况,这也正是缓存不一致的根本原因所在。所有解决方案和讨论都是围绕这一点来进行的。
- 请求1先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。
- 请求2查询缓存,发现缓存中有数据,直接返回该数据。
- 请求1删除缓存
这种情况下,只会影响f或类似f的少数请求读了一次脏数据,看起来好多了。
但如果是读数据请求先过来呢?
- 请求2查询缓存,发现缓存中有数据,直接返回该数据。
- 请求1先写数据库。
- 请求1删除缓存。
总结:
- 想要提高应用的性能,可以引入「缓存」来解决
- 引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」
- 更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况
- 采用「先删除缓存,再更新数据库」方案,在「并发」场景下依旧有不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估
- 采用「先更新数据库,再删除缓存」方案,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据最终一致
- 采用「先更新数据库,再删除缓存」方案,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率
具体参考:http://kaito-kidd.com/2021/09/08/how-to-keep-cache-and-consistency-of-db/
分布式锁
本地锁只能锁住当前服务的进程,每一个单独的服务都会有一个进程读取数据库,不能达到只读取依次数据库的效果,所以需要分布式锁。
1.使用 Redis 作为分布式锁
redis 中有一个 SETNX 命令,该命令会向 redis 中保存一条数据,如果不存在则保存成功,存在则返回失败。
我们约定保存成功即为加锁成功,之后加锁成功的线程才能执行真正的业务操作。
/**
* 从数据库查询并封装数据::分布式锁
*
* @return
*/
public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1、占分布式锁。去redis占坑 设置过期时间必须和加锁是同步的,保证原子性(避免死锁)
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
if (lock) {
System.out.println("获取分布式锁成功...");
Map<String, List<Catalogs2Vo>> dataFromDb = null;
try {
//加锁成功...执行业务
dataFromDb = getCatalogJsonFromDB();
} finally {
// lua 脚本解锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 删除锁
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList("lock"), uuid);
}
//先去redis查询下保证当前的锁是自己的
//获取值对比,对比成功删除=原子性 lua脚本解锁
// String lockValue = stringRedisTemplate.opsForValue().get("lock");
// if (uuid.equals(lockValue)) {
// //删除我自己的锁
// stringRedisTemplate.delete("lock");
// }
return dataFromDb;
} else {
System.out.println("获取分布式锁失败...等待重试...");
//加锁失败...重试机制
//休眠一百毫秒
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCatalogJsonFromDbWithRedisLock(); //自旋的方式
}
}
2.Redisson 作为分布式锁
1.引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.1</version>
</dependency>
2.配置redisson
@Configuration
public class MyRedissonConfig {
/**
* 所有丢redis的使用都是通过RedissonClient对象
* @return
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() {
// 1、创建配置
Config config = new Config();
// Redis url should start with redis:// or rediss://
//使用单节点方式
config.useSingleServer().setAddress("redis://192.168.56.10:6379");
RedissonClient redissonClient = Redisson.create(config);
// 2、根据 Config 创建出 RedissonClient 实例
return redissonClient;
}
3.使用
// 1. 获取一把锁
Rlock lock = redisson.getLock("my-lock");
// 2. 加锁, 阻塞式等待
lock.lock();
try {
System.out.println("加锁成功,执行业务...");
} catch (Exception e) {
} finally {
// 3. 解锁 假设解锁代码没有运行,Redisson 会出现死锁吗?(不会)
lock.unlock();
}
- 锁的自动续期,如果业务时间很长,运行期间自动给锁续期 30 s,不用担心业务时间过长,锁自动过期被删掉;
- 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动续期,默认也会在 30 s 后解锁;
修改上述代码为
/**
* 缓存里的数据如何和数据库的数据保持一致??
* 缓存数据一致性
* 1)、双写模式
* 2)、失效模式
*
* @return
*/
public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDbWithRedissonLock() {
//1、占分布式锁。去redis占坑
//(锁的粒度,越细越快:具体缓存的是某个数据,11号商品) product-11-lock
//RLock catalogJsonLock = redissonClient.getLock("catalogJson-lock");
//创建读锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("catalogJson-lock");
RLock rLock = readWriteLock.readLock();
Map<String, List<Catalogs2Vo>> dataFromDb = null;
try {
rLock.lock();
//加锁成功...执行业务
dataFromDb = getCatalogJsonFromDB();
} finally {
rLock.unlock();
}
return dataFromDb;
}
SpringCache
1.引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
2.添加配置
spring.cache.type=redis
#spring.cache.cache-names=
#毫秒为单位
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=false
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {
/**
* 配置文件中的东西没用上(ttl);应该将配置文件中的所有配置都生效
* <p>
* 1.原来和配置文件绑定的配置类是这样子
*
* @return
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties
* 2.要让他生效
* @EnableConfigurationProperties(CacheProperties.class)
*/
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
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;
}
}
3.常用注解
- @Cacheable :触发将数据保存到缓存的操作;
- @CacheEvict : 触发将数据从缓存删除的操作;
- @CachePut :不影响方法执行更新缓存;
- @Cacheing:组合以上多个操作;
- @CacheConfig:在类级别共享缓存的相同配置;
4.业务实现
/**
* 查询所有的一级分类
*
* @return
* @Cacheable: 当前方法的结果需要缓存 如果缓存中有方法不用调用,缓存中没有 调用方法 最后将方法的结果放进缓存
* <p>
* 默认行为:
* 1) 如果缓存中有方法不用调用,缓存中没有 调用方法
* 2) key是默认自动生成的,包含缓存名字::SimpleKey [](自主生成的key值)
* 3)缓存的value的值,默认使用jdk序列化机制,将序列化后的数据存在redis
* 4)默认时间-1(永不过期 ttl=-1)
* <p>
* <p>
* <p>
* 自定义操作(规范些)
* 1.指定生成的缓存使用的key key属性接受一个SpEl表达式
* 2.指定缓存数据的存活时间 可以在配置文件中指定时间
* 3.将数据保存为json格式
* CacheAutoConfiguration
* RedisCacheConfiguration
*
* springCache的不足:
* 1) 读模式:
* 缓存穿透:查询一个null数据 解决办法:缓存空数据 spring.cache.redis.cache-null-values=true
* 缓存击穿:大量并发进来同时查询一个正好过期的数据 解决办法:加锁 默认无锁 sync = true(解决击穿)
* 缓存雪崩:大量的key同时过期 解决办法:加随机时间 加过期时间 spring.cache.redis.time-to-live=3600000
* 2) 写模式:(缓存与数据库的一致)
* 读写加锁
* 引入中间键canal,感知到mysql的更新去更新数据库
* 读多写多 :直接去数据库查询就行
*
* 总结:常规数据(读多写少的即时性一致性要求不高的)缓存的使用可以使用springCache,写模式只要缓存的数据有过期时间就足够了
* 特殊数据:特殊设计
* 原理:springCache有个CacheManager(RedisCacheManager)-->cache(RedisCache)组件-->cache负责缓存的读写
*
*/
//每一个需要缓存的数据都需要指定名字(缓存的分区)按照业务类型划分
@Cacheable(value = {"category"}, key = "#root.method.name",sync = true)
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("getLevel1Categorys");
long l = System.currentTimeMillis();
List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
System.out.println("消耗时间:" + (System.currentTimeMillis() - l));
return categoryEntities;
}