# scaffold 项目之 redis 缓存
# 简介
- 类型:NoSQL 数据库,主要存储数据在内存中。
- 数据结构:支持多种数据结构,包括字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)和范围查询、位图(bitmaps)、超日志(hyperloglogs)以及地理空间索引(geospatial indexes)。
- 性能:由于数据存储在内存中,访问速度极快,通常能达到每秒数十万次的读写速度。
- 持久化:虽然 Redis 是基于内存的系统,但它也提供了多种持久化机制,如 RDB(快照)和 AOF(追加文件)。
如果想更详细了解请看这里 Redis
# 开始使用
本项目封装了 scaffold-spring-boot-starter-redis 技术组件,使用 Redis 实现缓存的功能,它有 2 种使用方式:
- 编程式缓存:基于 Spring Data Redis 框架的 RedisTemplate 操作模板
- 声明式缓存:基于 Spring Cache 框架的
@Cacheable等等注解
关于这两种方式的解释:
编程式缓存:
编程式缓存是指通过代码显式地管理缓存的存储、读取和更新。开发者需要在代码中手动编写逻辑来处理缓存操作,相当于在代码 get、set。
特点
- 灵活性高:
- 开发者可以完全控制缓存的逻辑,包括缓存的加载、更新和失效。
- 可以根据复杂的业务逻辑动态调整缓存策略。
- 适用场景:
- 适用于缓存逻辑复杂且需要高度定制化的场景。
- 适用于需要在多个地方复用缓存逻辑的场景。
声明式缓存:
声明式缓存是通过注解或其他声明性方式来管理缓存,而无需在代码中显式编写缓存逻辑。Spring Cache 是声明式缓存的典型实现。
特点
- 低侵入性:
- 通过注解(如
@Cacheable、@CachePut、@CacheEvict)声明缓存行为,无需修改业务逻辑代码。- 缓存逻辑与业务逻辑分离,降低了代码耦合度。
- 适用场景:
- 适用于缓存逻辑相对简单且不需要高度定制化的场景。
- 适用于需要快速实现缓存功能的场景。
# 编程式缓存
进行 scaffold-spring-boot-starter-redis 组件封装
引用依赖
<dependency> | |
<groupId>org.redisson</groupId> | |
<artifactId>redisson-spring-boot-starter</artifactId> | |
</dependency> |
由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。
# Spring Data Redis 配置
在
application-local.yaml配置文件中,通过spring.redis配置项,设置 Redis 的配置。如下所示:spring:# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
redis:host: localhost # 地址port: 6379 # 端口
database: 0 # 数据库索引
# password: dev # 密码,建议生产环境开启添加
ScaffoldRedisAutoConfiguration配置类,设置使用 JSON 序列化 value 值。如下代码所示:/*** <p> Project: scaffold - ScaffoldRedisAutoConfiguration </p>
*
* Redis 配置类
* @author Tz
* @date 2024/01/09 23:45
* @version 1.0.0
* @since 1.0.0
*/
@AutoConfigurationpublic class ScaffoldRedisAutoConfiguration {
/*** 创建 RedisTemplate Bean,使用 JSON 序列化方式
*/
@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
// 创建 RedisTemplate 对象RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置 RedisConnection 工厂。它就是实现多种 Java Redis 客户端接入的秘密工厂。template.setConnectionFactory(factory);
// 使用 String 序列化方式,序列化 KEY 。template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 使用 JSON 序列化方式(库是 Jackson ),序列化 VALUE 。template.setValueSerializer(buildRedisSerializer());
template.setHashValueSerializer(buildRedisSerializer());
return template;
}public static RedisSerializer<?> buildRedisSerializer() {
RedisSerializer<Object> json = RedisSerializer.json();
// 解决 LocalDateTime 的序列化ObjectMapper objectMapper = (ObjectMapper) ReflectUtil.getFieldValue(json, "mapper");
objectMapper.registerModules(new JavaTimeModule());
return json;
}}
# 使用
在其他模块引入组件
<dependency> | |
<groupId>com.tz.boot</groupId> | |
<artifactId>scaffold-spring-boot-starter-redis</artifactId> | |
</dependency> |
然后后在代码中注入:
@Resource | |
private StringRedisTemplate stringRedisTemplate; |
# RedisKeyConstants
关于 RedisKeyConstants 作为 redis 的常量 key 值, 在各个模块中用到缓存的地方,推荐都要有一个这个类。列如:
package com.tz.scaffold.module.system.dal.redis; | |
/** | |
* <p> Project: scaffold - RedisKeyConstants </p> | |
* | |
* System Redis Key 枚举类 | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
public interface RedisKeyConstants { | |
/** | |
* 指定部门的所有子部门编号数组的缓存 | |
* <p> | |
* KEY 格式:dept_children_ids:{id} | |
* VALUE 数据类型:String 子部门编号集合 | |
*/ | |
String DEPT_CHILDREN_ID_LIST = "dept_children_ids"; | |
/** | |
* 角色的缓存 | |
* <p> | |
* KEY 格式:role:{id} | |
* VALUE 数据类型:String 角色信息 | |
*/ | |
String ROLE = "role"; | |
/** | |
* 用户拥有的角色编号的缓存 | |
* <p> | |
* KEY 格式:user_role_ids:{userId} | |
* VALUE 数据类型:String 角色编号集合 | |
*/ | |
String USER_ROLE_ID_LIST = "user_role_ids"; | |
} |
为什么要定义 Redis Key 常量?
每个
scaffold-module-xxx模块,都有一个 RedisKeyConstants 类,定义该模块的 Redis Key 的信息。目的是,避免 Redis Key 散落在 Service 业务代码中,像对待数据库的表一样,对待每个 Redis Key。通过这样的方式,如果我们想要了解一个模块的 Redis 的使用情况,只需要查看 RedisKeyConstants 类即可。相当于每个模块的
RedisKeyConstants类就是一张表, 对应的属性(key 值) 就是表的字段。
# 声明式缓存
引用依赖
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-cache</artifactId> | |
</dependency> |
相比来说 Spring Data Redis 编程式缓存,Spring Cache 声明式缓存的使用更加便利,一个 @Cacheable 注解即可实现缓存的功能。示例如下:
@Cacheable(value = "users", key = "#id") | |
UserDO getUserById(Integer id); |
# Spring Cache 配置
在
application.yaml配置文件中,通过spring.redis配置项,设置 Redis 的配置。如下所示:spring:
# Cache 配置项cache:
type: REDIS
redis:
time-to-live: 1h # 设置过期时间为 1 小时
在
ScaffoldCacheAutoConfiguration配置类,设置使用 JSON 序列化 value 值。如下所示:/*** <p> Project: scaffold - ScaffoldCacheAutoConfiguration </p>
*
* Cache 配置类,基于 Redis 实现
* @author Tz
* @date 2024/01/09 23:45
* @version 1.0.0
* @since 1.0.0
*/
@AutoConfiguration@EnableConfigurationProperties({CacheProperties.class, ScaffoldCacheProperties.class})
@EnableCachingpublic class ScaffoldCacheAutoConfiguration {
/*** RedisCacheConfiguration Bean
* <p>
* 参考 org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 的 createConfiguration 方法
*/
@Bean@Primarypublic RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 设置使用:单冒号,而不是双::冒号,避免 Redis Desktop Manager 多余空格// 详细可见 https://blog.csdn.net/chuixue24/article/details/103928965 博客config = config.computePrefixWith(cacheName -> cacheName + StrUtil.COLON);
// 设置使用 JSON 序列化方式config = config.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(buildRedisSerializer()));
// 设置 CacheProperties.Redis 的属性CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}if (redisProperties.getKeyPrefix() != null) {
config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
}if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}return config;
}@Beanpublic RedisCacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate,
RedisCacheConfiguration redisCacheConfiguration,
ScaffoldCacheProperties scaffoldCacheProperties) {
// 创建 RedisCacheWriter 对象RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
BatchStrategies.scan(scaffoldCacheProperties.getRedisScanBatchSize()));
// 创建 TenantRedisCacheManager 对象return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);
}}
# 常见注解
# @Cacheable 注解
@Cacheable 注解:添加在方法上,缓存方法的执行结果。执行过程如下:
- 1)首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。
- 2)然后,执行方法,获得方法结果。
- 3)之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
- 4)最后,返回方法结果。
# 常用属性:
value或cacheNames:指定缓存的名称。key:指定缓存的键,支持 SpEL 表达式。condition:指定缓存的条件,满足条件时才缓存。unless:指定不缓存的条件,方法执行后判断。sync:是否同步缓存,防止缓存击穿。
# @CachePut 注解
@CachePut 注解,添加在方法上,缓存方法的执行结果。不同于 @Cacheable 注解,它的执行过程如下:
- 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。
- 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
- 3)最后,返回方法结果。
# 常用属性:
value或cacheNames:指定缓存的名称。key:指定缓存的键。condition:指定缓存的条件。
# @CacheEvict 注解
@CacheEvict 注解,添加在方法上,删除缓存。
# 常用属性:
value或cacheNames:指定缓存的名称。key:指定要清空的缓存键。allEntries:是否清空所有缓存条目。beforeInvocation:是否在方法执行前清空缓存。
# 实战案例
在 RoleServiceImpl 中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是:
获取指定用户的角色,结果应该被缓存
@Override@Cacheable(value = RedisKeyConstants.ROLE, key = "#id",
unless = "#result == null")
public RoleDO getRoleFromCache(Long id) {
return roleMapper.selectById(id);
}新建角色不需要缓存,因为新建,并不影响已经在和用户关联使用的角色
修改角色需要删除对应的缓存,因为修改的可能是关联使用的角色
@Override@CacheEvict(value = RedisKeyConstants.ROLE, key = "#id")
public void updateRoleStatus(Long id, Integer status) {
// 校验是否可以更新validateRoleForUpdate(id);
// 更新状态RoleDO updateObj = new RoleDO().setId(id).setStatus(status);
roleMapper.updateById(updateObj);
}删除角色需要删除对应的缓存,因为删除的可能是关联使用的角色,和修改类似
【被动读】相对能够保证 Redis 与 MySQL 的一致性
绝大数数据不需要放到 Redis 缓存中,采用【主动写】会将非必要的数据进行缓存
# 过期时间
Spring Cache 默认使用 spring.cache.redis.time-to-live 配置项,设置缓存的过期时间,项目默认为 1 小时。
如果你想自定义过期时间,可以在 @Cacheable 注解中的 cacheNames 属性中,添加 #{过期时间} 后缀,单位是秒。如下所示:
@Override | |
@CacheEvict(value = RedisKeyConstants.ROLE + "#100", key = "#id") | |
public void updateRoleStatus(Long id, Integer status) { | |
// 校验是否可以更新 | |
validateRoleForUpdate(id); | |
// 更新状态 | |
RoleDO updateObj = new RoleDO().setId(id).setStatus(status); | |
roleMapper.updateById(updateObj); | |
} |
** 注意!** 这里这么加并不是 cache 所支持的,而是扩展 RedisCacheManager 实现的。具体实现代码:
/** | |
* <p> Project: scaffold - TimeoutRedisCacheManager </p> | |
* | |
* 支持自定义过期时间的 {@link RedisCacheManager} 实现类 | |
* <p> | |
* 在 {@link Cacheable#cacheNames ()} 格式为 "key#ttl" 时,# 后面的 ttl 为过期时间。 | |
* <p> | |
* 单位为最后一个字母(支持的单位有:d 天,h 小时,m 分钟,s 秒),默认单位为 s 秒 | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
public class TimeoutRedisCacheManager extends RedisCacheManager { | |
private static final String SPLIT = "#"; | |
public TimeoutRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) { | |
super(cacheWriter, defaultCacheConfiguration); | |
} | |
@Override | |
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) { | |
if (StrUtil.isEmpty(name)) { | |
return super.createRedisCache(name, cacheConfig); | |
} | |
// 如果使用 # 分隔,大小不为 2,则说明不使用自定义过期时间 | |
String[] names = StrUtil.splitToArray(name, SPLIT); | |
if (names.length != 2) { | |
return super.createRedisCache(name, cacheConfig); | |
} | |
// 核心:通过修改 cacheConfig 的过期时间,实现自定义过期时间 | |
if (cacheConfig != null) { | |
// 移除 # 后面的:以及后面的内容,避免影响解析 | |
names[1] = StrUtil.subBefore(names[1], StrUtil.COLON, false); | |
// 解析时间 | |
Duration duration = parseDuration(names[1]); | |
cacheConfig = cacheConfig.entryTtl(duration); | |
} | |
return super.createRedisCache(name, cacheConfig); | |
} | |
/** | |
* 解析过期时间 Duration | |
* | |
* @param ttlStr 过期时间字符串 | |
* @return 过期时间 Duration | |
*/ | |
private Duration parseDuration(String ttlStr) { | |
String timeUnit = StrUtil.subSuf(ttlStr, -1); | |
switch (timeUnit) { | |
case "d": | |
return Duration.ofDays(removeDurationSuffix(ttlStr)); | |
case "h": | |
return Duration.ofHours(removeDurationSuffix(ttlStr)); | |
case "m": | |
return Duration.ofMinutes(removeDurationSuffix(ttlStr)); | |
case "s": | |
return Duration.ofSeconds(removeDurationSuffix(ttlStr)); | |
default: | |
return Duration.ofSeconds(Long.parseLong(ttlStr)); | |
} | |
} | |
/** | |
* 移除多余的后缀,返回具体的时间 | |
* | |
* @param ttlStr 过期时间字符串 | |
* @return 时间 | |
*/ | |
private Long removeDurationSuffix(String ttlStr) { | |
return NumberUtil.parseLong(StrUtil.sub(ttlStr, 0, ttlStr.length() - 1)); | |
} | |
} |