前言
在上篇 SpringBoot_redis使用实战(一)_docker环境 记录了
- redis docker环境安装
- redis和springboot的集成基本使用
本文主要学习springboot中使用redis作为缓存的相关实战内容, 及各种缓存问题产生原因及解决方式,代码部分主要涉及
- 不同缓存类型设置不同设置过期时效
- null值缓存时间自定义(也能实现同类缓存+随机数)
- 应用启动缓存预热
一 springboot使用redis实现缓存
springboot和redis基础整合请参考: SpringBoot_redis使用实战(一)_docker环境
1. maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
2. 开启缓存配置
添加spring-boot-starter-cache 并开启@EnableCaching 会自动注入CacheManager的实现
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
}
3. 使用缓存
缓存–查询
@Cacheable(value = "demo-app:business-cache", key = "'account-id' + #id")
@Override
public Account findAccountByPrimaryKey(long id) {
return this.accountMapper.selectById(id);
}
效果:查询结果自动缓存到redis,重复查询同一个key,不会发起数据查询请求
缓存–更新
@CachePut(value = "demo-app:business-cache", key = "'account-id:' + #account.id")
@Override
public Account updateAccount(Account account) {
this.accountMapper.updateById(account);
return account;
}
缓存–删除
@CacheEvict(value = "demo-app:business-cache", key = "'account-id:' + #account.id")
@Override
public void deleteAccount(Account account) {
this.accountMapper.deleteById(account.getId());
}
简化写法@CacheConfig
@CacheConfig
作用在标注在类上,抽取缓存相关注解的公共配置,可抽取的公共配置有缓存名字、主键生成器等
@CacheConfig(cacheNames = RedisKeyConstant.CACHE_BUSINESS)
@Service("userService")
@Transactional(rollbackFor=Exception.class)
public class AccountServiceImpl implements AccountService {
private static final Logger LOGGER = LoggerFactory.getLogger(AccountServiceImpl.class);
@Autowired
private AccountMapper accountMapper;
@CachePut(key = "'account-id:' + #account.id")
@Override
public Account updateAccount(Account account) {
this.accountMapper.updateById(account);
return account;
}
@CacheEvict(key = "'account-id:' + #account.id")
@Override
public void deleteAccount(Account account) {
this.accountMapper.deleteById(account.getId());
}
@Cacheable(key = "'account-id:' + #id")
@Override
public Account findAccountByPrimaryKey(long id) {
return this.accountMapper.selectById(id);
}
}
二 高级–缓存的问题及处理
缓存可视化(json序列化)
- 场景:默认装配缓存虽然可以用,但是存在redis中的数据,无法直接阅读. 不利于排查问题
- 分析: springboot 自动装配类org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 默认使用jdk序列化
- 解决:配置RedisCacheConfiguration并制定json的序列化方式
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
// 键序列化方式 redis字符串序列化
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
// 值序列化方式 简单json序列化
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));
return defaultCacheConfiguration;
}
- 效果:
缓存雪崩
- 现象: 大量key在短时间内同时过期,导致缓存无法命中,大量请求直接到数据库, 压垮数据库
1. 有效期均匀分布
不同类型缓存时长错开过期, 同类缓存加随机数
- 代码
@Configuration @EnableCaching public class CacheConfig extends CachingConfigurerSupport { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { // 不同类型缓存---使用不同过期策略 Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap(); // 系统缓存过期时间(s):24 * 60 * 60 cacheConfigMap.put(RedisKeyConstant.SYSTEM_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(24 * 60 * 60))); // 业务缓存过期时间(s):20 * 60 cacheConfigMap.put(RedisKeyConstant.BUSINESS_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(20 * 60))); RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(this.useJsonCacheConfiguration()) .withInitialCacheConfigurations(cacheConfigMap) .build(); return redisCacheManager; } private RedisCacheConfiguration useJsonCacheConfiguration(Duration ttl) { RedisCacheConfiguration defaultCacheConfiguration = this.useJsonCacheConfiguration(); defaultCacheConfiguration.entryTtl(ttl); return defaultCacheConfiguration; } /**使用json序列化的缓存配置*/ private RedisCacheConfiguration useJsonCacheConfiguration() { GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration .defaultCacheConfig() // 键序列化方式 redis字符串序列化 .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer)) // 值序列化方式 简单json序列化 .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer)); return defaultCacheConfiguration; } }
2. 数据预热
对于即将来临的大量请求,我们可以提前走一遍系统,将数据提前缓存在Redis中,并设置不同的过期时间。
- 场景:新系统第一次上线,此时在缓存里是没有数据的。
TODO 代码
3. 保证redis高可用
Redis的哨兵模式和集群模式,为防止Redis集群单节点故障,可以通过这两种模式实现高可用。
TODO 补充docker部署哨兵和集群模式
缓存击穿
- 现象: 某一热点key缓存过期失效,瞬时大流量压垮数据库
1. 设置热点数据永不过期
对于某个需要频繁获取的信息,缓存在Redis中,并设置其永不过期。当然这种方式比较粗暴,对于某些业务场景是不适合的。
2. 定时更新(延长有效期)
比如这个热点数据的过期时间是1h,那么每到59minutes时,通过定时任务去更新这个热点key,并重新设置其过期时间。
3.互斥锁
这是解决缓存击穿比较常用的方法。
互斥锁简单来说就是在Redis中根据key获得的value值为空时,先锁上,然后从数据库加载,加载完毕,释放锁。若其他线程也在请求该key时,发现获取锁失败,则睡眠一段时间(比如100ms)后重试
缓存穿透
- 现象:缓存和数据库中都没有的数据,可用户还是源源不断的发起请求,导致每次请求都会到数据库,从而压垮数据库
1. 业务层校验
请求参数校验,明显错误直接拦截返回(比如ID的长度, 自增ID有效范围,明显不符合直接返回错误
2. 不存在数据设置短过期时间
对于某个查询为空的数据,可以将这个空结果进行Redis缓存,但是设置很短的过期时间,比如30s,可以根据实际业务设定。注意一定不要影响正常业务
- 代码分析
springboot的缓存配置类RedisCacheConfiguration.defaultCacheConfig()方法,默认已经开启null值缓存
可以用,但是nullValue默认缓存时间是和正常值一样的(RedisCacheConfiguration定义).那么如果短时间请求大量key,就会占用redis内存.所以我们只要实现nullValue值情况,走特殊ttl时间即可. 缓存时间一般都是CacheManage.put(缓存, ttl)操作设置,我们从自动装配类RedisCacheConfiguration找redis的缓存管理器实现入口
进入RedisCacheManager
进入RedisCacheManager的静态内部类RedisCacheManagerBuilder
查看DefaultRedisCacheWriter,找到设置缓存的地方.
下面就开始具体的改造任务.
自定义CustomRedisCacheWriter(覆盖DefaultRedisCacheWriter.put逻辑)
CustomRedisCacheWriter必须实现RedisCacheWriter, 所有方法实现和DefaultRedisCacheWriter保持一致. put方法增加ObjectUtils.nullSafeEquals(value, RedisSerializer.java().serialize(NullValue.INSTANCE)空值判断. 为空设置ttl=30
/**
* 重写DefaultRedisCacheWriter.put方法-- 修改null数据缓存时间为20秒
* @see org.springframework.data.redis.cache.DefaultRedisCacheWriter
*/
public class CustomRedisCacheWriter implements RedisCacheWriter {
private final RedisConnectionFactory connectionFactory;
private final Duration sleepTime;
public CustomRedisCacheWriter(RedisConnectionFactory connectionFactory) {
this(connectionFactory, Duration.ZERO);
}
CustomRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime) {
Assert.notNull(connectionFactory, "ConnectionFactory must not be null!");
Assert.notNull(sleepTime, "SleepTime must not be null!");
this.connectionFactory = connectionFactory;
this.sleepTime = sleepTime;
}
public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) {
// 缓存穿透--如果缓存为空值,使用快速过期时间
if (ObjectUtils.nullSafeEquals(value, RedisSerializer.java().serialize(NullValue.INSTANCE))) {
ttl = Duration.ofSeconds(30);
}
final Duration durationTtl = ttl;
Assert.notNull(name, "Name must not be null!");
Assert.notNull(key, "Key must not be null!");
Assert.notNull(value, "Value must not be null!");
this.execute(name, (connection) -> {
if (shouldExpireWithin(durationTtl)) {
connection.set(key, value, Expiration.from(durationTtl.toMillis(), TimeUnit.MILLISECONDS), RedisStringCommands.SetOption.upsert());
} else {
connection.set(key, value);
}
return "OK";
});
}
// 其他方法省略,和DefaultRedisCacheWriter保持一致
}
生产环境建议:CustomRedisCacheWriter 其实会注册成单例bean, 可以定义nullValueTtl属性,由application.yaml传入, 或者使用nacos,可以达到动态设置效果.有兴趣可以自己试试. 另外这里ttl可以加上随机数,解决缓存雪崩的问题.
使用CustomRedisCacheWriter构造RedisCacheManager
使用RedisCacheManager.builder(cacheWriter) 传入自定义CustomRedisCacheWriter的实现
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
@Bean
public RedisCacheManager cacheManager(CustomRedisCacheWriter cacheWriter) {
// 不同类型缓存---使用不同过期策略
Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap();
// 系统缓存过期时间(s):24 * 60 * 60
cacheConfigMap.put(RedisKeyConstant.SYSTEM_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(24 * 60 * 60)));
// 业务缓存过期时间(s):20 * 60
cacheConfigMap.put(RedisKeyConstant.BUSINESS_CACHE, this.useJsonCacheConfiguration(Duration.ofSeconds(20 * 60)));
RedisCacheManager redisCacheManager = RedisCacheManager.builder(cacheWriter)
.cacheDefaults(this.useJsonCacheConfiguration())
.withInitialCacheConfigurations(cacheConfigMap)
.build();
return redisCacheManager;
}
@Bean
public CustomRedisCacheWriter cacheWriter(RedisConnectionFactory redisConnectionFactory) {
return new CustomRedisCacheWriter(redisConnectionFactory);
}
private RedisCacheConfiguration useJsonCacheConfiguration(Duration ttl) {
RedisCacheConfiguration defaultCacheConfiguration = this.useJsonCacheConfiguration();
defaultCacheConfiguration.entryTtl(ttl);
return defaultCacheConfiguration;
}
/**使用json序列化的缓存配置*/
private RedisCacheConfiguration useJsonCacheConfiguration() {
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
// 键序列化方式 redis字符串序列化
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
// 值序列化方式 简单json序列化
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));
return defaultCacheConfiguration;
}
}
3. 布隆过滤器
布隆过滤器是一种数据结构,利用极小的内存,可以判断大量的数据“一定不存在或者可能存在”。
对于缓存穿透,我们可以将查询的数据条件都哈希到一个足够大的布隆过滤器中,用户发送的请求会先被布隆过滤器拦截,一定不存在的数据就直接拦截返回了,从而避免下一步对数据库的压力
- TODO代码实现(redission布隆过滤器)
4. 加锁/限流
缓存一致性
TODO
缓存降级
缓存降级是指缓存失效或者缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或者访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对业务的影响程度。
- 场景: 一般业务系统参数因为在业务处理上会频繁读取,所以一般也会使用缓存存储一份减少数据库查询,但是由于意外情况下缓存服务不可用(比如redis挂了,连接redis的网络故障),就会因为缓存系统的不可用,导致业务系统主流程都不可用。这时候系统的业务系统系统参数表其实能够读取的, 但是因为加了缓存注解, 反而造成很大的影响。
- 分析: 因为缓存注解是写死的, 线上缓存故障时候没法改代码去关闭缓存。同时缓存恢复的时候也应该自动切回去。
可选方案:
方案 | 说明 | 评价 |
---|---|---|
方案一:异常捕获 | 获取缓存的地方增加异常捕获逻辑,查询不到缓存中的配置, 查询数据库 | 需要业务中到处硬编码很不友好。 |
方案二: 多级缓存管理器 | 检测主缓存管理器不可用(redis连接超时),自动切换成另一款管理器(读内存/读默认/甚至无缓存) | 对业务入侵较小, 可以实现自动切换 |
1. 多级缓存方案实现
- 概要设计
- 缓存降级管理器: 使用装饰器模式集成多个缓存管理,对外提供缓存降级和恢复操作接口
- 缓存降级监听器: 监听缓存redis连接事件, 连接中断通知缓存降级,连接恢复,重新上线
- 缓存降级配置器: 配置主缓存和失败缓存的具体实现
1.1 CacheDegradeManager【缓存降级管理器】
/**
* 可降级的缓存管理器
*/
public class CacheDegradeManager implements CacheManager {
private static final Logger LOGGER = LoggerFactory.getLogger(CacheDegradeManager.class);
/**主缓存管理器*/
private final LinkedHashMap<String, CacheManager> mainCacheManagers;
/**主缓存不可用后管理器*/
private final LinkedHashMap<String, CacheManager> failCacheManagers;
private final AtomicBoolean activeStatus = new AtomicBoolean(false);
protected LinkedHashMap<String, CacheManager> cacheManagers;
public CacheDegradeManager(LinkedHashMap<String, CacheManager> mainCacheManagers, LinkedHashMap<String, CacheManager> failCacheManagers) {
this.mainCacheManagers = mainCacheManagers;
this.failCacheManagers = failCacheManagers;
// 启动使用主缓存管理器
this.cacheManagers = mainCacheManagers;
}
@Override
public Cache getCache(String name) {
for (CacheManager cacheManager : this.cacheManagers.values()) {
Cache cache = cacheManager.getCache(name);
if (cache != null) {
return cache;
}
}
return null;
}
@Override
public Collection<String> getCacheNames() {
Set<String> names = new LinkedHashSet<>();
for (CacheManager manager : this.cacheManagers.values()) {
names.addAll(manager.getCacheNames());
}
return Collections.unmodifiableSet(names);
}
public void up() {
if (!this.activeStatus.get()) {
LOGGER.info("【主缓存】连接正常,启用缓存[{}]", StringUtils.join(this.mainCacheManagers.keySet(), ","));
this.cacheManagers = this.mainCacheManagers;
this.activeStatus.set(true);
}
}
public void down() {
if (this.activeStatus.get()) {
LOGGER.warn("【主缓存】连接断开,缓存降级[{}]", StringUtils.join(this.failCacheManagers.keySet(), ","));
this.cacheManagers = this.failCacheManagers;
this.activeStatus.set(false);
}
}
public boolean isActive() {
return this.activeStatus.get();
}
public LinkedHashMap<String, CacheManager> getCacheManagers() {
return cacheManagers;
}
public void setCacheManagers(LinkedHashMap<String, CacheManager> cacheManagers) {
this.cacheManagers = cacheManagers;
}
}
1.2 CacheDegradeListener【缓存降级监听器】
/**
* 降级缓存监听器
*/
public class CacheDegradeListener implements InitializingBean {
private final LettuceConnectionFactory lettuceConnectionFactory;
private final CacheManager cacheManager;
public CacheDegradeListener(LettuceConnectionFactory lettuceConnectionFactory, CacheManager cacheManager) {
this.lettuceConnectionFactory = lettuceConnectionFactory;
this.cacheManager = cacheManager;
}
@Override
public void afterPropertiesSet() throws Exception {
if (!(this.cacheManager instanceof CacheDegradeManager)) {
return;
}
CacheDegradeManager onelinkCacheManager = (CacheDegradeManager) this.cacheManager;
this.lettuceConnectionFactory.getClientResources().eventBus().get().subscribe(event -> {
if (event instanceof ConnectionDeactivatedEvent || event instanceof ConnectionActivatedEvent) {
synchronized (CacheDegradeListener.class) {
if (event instanceof ConnectionDeactivatedEvent) {
onelinkCacheManager.down();
} else {
onelinkCacheManager.up();
}
}
}
// 开启监听后立马测试连接test connection。
this.lettuceConnectionFactory.getConnection().keyCommands().keys("test".getBytes(StandardCharsets.UTF_8));
});
}
}
1.3 CacheDegradeConfig【缓存降级配置器】
/**
* 测试可降级缓存管理
*/
@Configuration
@EnableCaching
public class CacheDegradeConfig {
/**
* 可降级缓存监听
*/
@Bean
public CacheDegradeListener degradeCacheListener(LettuceConnectionFactory lettuceConnectionFactory, CacheManager cacheManager) {
return new CacheDegradeListener(lettuceConnectionFactory, cacheManager);
}
@Primary
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
//主缓存管理器
LinkedHashMap<String, CacheManager> mainCacheManagers = new LinkedHashMap<>();
mainCacheManagers.put("RedisCache", this.createRedisCacheManager(redisConnectionFactory));
//失败缓存管理器
LinkedHashMap<String, CacheManager> failCacheManager = new LinkedHashMap<>();
failCacheManager.put("NoOpCache", new NoOpCacheManager());
return new CacheDegradeManager(mainCacheManagers, failCacheManager);
}
/**
* 缓存管理器
*/
@Bean
public RedisCacheManager createRedisCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration defaultCacheConfiguration = this.useJsonCacheConfiguration();
RedisCacheConfiguration businessCacheConfiguration = this.useJsonCacheConfiguration(Duration.ofSeconds(20 * 60));
Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap();
cacheConfigMap.put("demo-app:business-cache", businessCacheConfiguration);
RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfiguration)
.withInitialCacheConfigurations(cacheConfigMap)
.build();
return redisCacheManager;
}
@Bean
public CustomRedisCacheWriter cacheWriter(RedisConnectionFactory redisConnectionFactory) {
return new CustomRedisCacheWriter(redisConnectionFactory);
}
private RedisCacheConfiguration useJsonCacheConfiguration(Duration ttl) {
return this.useJsonCacheConfiguration().entryTtl(ttl);
}
/**使用json序列化的缓存配置*/
private RedisCacheConfiguration useJsonCacheConfiguration() {
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
// 键序列化方式 redis字符串序列化
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
// 值序列化方式 简单json序列化
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer));
return defaultCacheConfiguration;
}
}