# 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 等等注解

关于这两种方式的解释:

  1. 编程式缓存:

    编程式缓存是指通过代码显式地管理缓存的存储、读取和更新。开发者需要在代码中手动编写逻辑来处理缓存操作,相当于在代码 get、set。

    特点

    1. 灵活性高
      • 开发者可以完全控制缓存的逻辑,包括缓存的加载、更新和失效。
      • 可以根据复杂的业务逻辑动态调整缓存策略。
    2. 适用场景
      • 适用于缓存逻辑复杂且需要高度定制化的场景。
      • 适用于需要在多个地方复用缓存逻辑的场景。
  2. 声明式缓存:

    声明式缓存是通过注解或其他声明性方式来管理缓存,而无需在代码中显式编写缓存逻辑。Spring Cache 是声明式缓存的典型实现。

    特点

    1. 低侵入性
      • 通过注解(如 @Cacheable@CachePut@CacheEvict )声明缓存行为,无需修改业务逻辑代码。
      • 缓存逻辑与业务逻辑分离,降低了代码耦合度。
    2. 适用场景
      • 适用于缓存逻辑相对简单且不需要高度定制化的场景。
      • 适用于需要快速实现缓存功能的场景。

# 编程式缓存

进行 scaffold-spring-boot-starter-redis 组件封装

引用依赖

<dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson-spring-boot-starter</artifactId>
</dependency>

由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。

# Spring Data Redis 配置

  1. application-local.yaml 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下所示:

    spring: 
     # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
      redis:
        host: localhost # 地址
        port: 6379 # 端口
        database: 0 # 数据库索引
    #    password: dev # 密码,建议生产环境开启
  2. 添加 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
     */
    @AutoConfiguration
    public class ScaffoldRedisAutoConfiguration {
        /**
         * 创建 RedisTemplate Bean,使用 JSON 序列化方式
         */
        @Bean
        public 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 配置

  1. application.yaml 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下所示:

    spring: 
      # Cache 配置项
      cache:
        type: REDIS
        redis:
          time-to-live: 1h # 设置过期时间为 1 小时
  2. 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})
    @EnableCaching
    public class ScaffoldCacheAutoConfiguration {
        /**
         * RedisCacheConfiguration Bean
         * <p>
         * 参考 org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 的 createConfiguration 方法
         */
        @Bean
        @Primary
        public 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;
        }
        @Bean
        public 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)最后,返回方法结果。
# 常用属性
  • valuecacheNames :指定缓存的名称。
  • key :指定缓存的键,支持 SpEL 表达式。
  • condition :指定缓存的条件,满足条件时才缓存。
  • unless :指定不缓存的条件,方法执行后判断。
  • sync :是否同步缓存,防止缓存击穿。

# @CachePut 注解

@CachePut 注解,添加在方法上,缓存方法的执行结果。不同于 @Cacheable 注解,它的执行过程如下:

  • 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。
  • 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。
  • 3)最后,返回方法结果。
# 常用属性
  • valuecacheNames :指定缓存的名称。
  • key :指定缓存的键。
  • condition :指定缓存的条件。

# @CacheEvict 注解

@CacheEvict 注解,添加在方法上,删除缓存。

# 常用属性
  • valuecacheNames :指定缓存的名称。
  • key :指定要清空的缓存键。
  • allEntries :是否清空所有缓存条目。
  • beforeInvocation :是否在方法执行前清空缓存。

# 实战案例

在 RoleServiceImpl 中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是:

  1. 获取指定用户的角色,结果应该被缓存

    @Override
    @Cacheable(value = RedisKeyConstants.ROLE, key = "#id",
               unless = "#result == null")
    public RoleDO getRoleFromCache(Long id) {
        return roleMapper.selectById(id);
    }
  2. 新建角色不需要缓存,因为新建,并不影响已经在和用户关联使用的角色

  3. 修改角色需要删除对应的缓存,因为修改的可能是关联使用的角色

    @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);
    }
  4. 删除角色需要删除对应的缓存,因为删除的可能是关联使用的角色,和修改类似

  • 【被动读】相对能够保证 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));
    }
}