Redis 缓存与 Spring Cache 开发指南

Redis 缓存与 Spring Cache 开发指南

一、Redis 缓存概述

Redis 是 C 语言开发的开源高性能 key-value 内存数据库,在系统中通常作为关系型数据库的缓存补充:Redis 承担热点数据的高速读取,MySQL 承担数据持久化落盘。

1. 适合放入缓存的数据

  • 即时性、数据一致性要求不高的数据
  • 访问量大、更新频率低(读多写少)的数据
  • 典型场景:商品信息、分类信息、资讯列表、公共配置等

2. 缓存核心实现模式(旁路缓存)

经典的缓存读写逻辑:

1. 先从缓存中查询数据
2. 缓存命中:直接返回数据
3. 缓存未命中:
   - 从数据库查询数据
   - 将查询结果写入缓存
   - 返回数据

伪代码:

data = cache.load(id);
if (data == null) {
    data = db.load(id);
    cache.put(id, data);
}
return data;

3. 本地缓存与分布式缓存

  • 本地缓存(如 JVM 中的 Map):仅能在单机/单进程内使用,分布式、集群部署下无法共享缓存数据。
  • 分布式缓存(如 Redis):集中式存储,多服务实例共享缓存数据,是分布式系统的标准方案。

二、Redis 缓存三大经典问题

1. 缓存穿透

定义:恶意或频繁查询一个不存在的数据,导致每次请求都绕过缓存直接访问数据库;数据库查询不到数据,也不会写入缓存,最终造成数据库压力激增。

  • 典型场景:请求 id 为 -1 或不存在的超大 id,攻击者通过大量非法请求打垮数据库。

解决方案:

  1. 缓存空对象(Cache Null Object) 当查询的数据不存在时,将空对象或特殊标识值写入缓存,并设置较短的过期时间(TTL)。后续相同请求直接从缓存返回,避免频繁访问数据库。
  2. 布隆过滤器:在缓存层之前提前拦截不存在的 key,从入口减少穿透请求。

2. 缓存击穿

定义:高并发场景下,某一个热点数据的缓存过期/被删除,瞬间大量并发请求同时访问该数据,全部绕过缓存直接打到数据库,造成数据库压力激增。

  • 核心原因:缓存重建需要时间,高并发下所有请求同时执行查库+写缓存逻辑。

解决方案:互斥锁 只放行一个线程去查询数据库并重建缓存,其余线程阻塞等待,缓存重建完成后直接读取。

  • 单机环境:使用 synchronized 或 Lock 本地锁
  • 分布式集群环境:使用 Redis setnx、Redisson 分布式锁
  • 特点:牺牲少量并发性能,保护数据库不被击穿。

3. 缓存雪崩

定义:同一时段大量缓存 key 同时失效,或 Redis 服务整体宕机,导致海量请求直接涌入数据库,造成数据库压力剧增。

  • 常见原因:大量 key 的过期时间设置完全相同/非常接近,集中失效;Redis 节点故障。
  • 与缓存击穿的区别:击穿是单个热点 key 失效;雪崩是大量 key 集体失效。

解决方案:

  1. 过期时间加随机偏移:给不同 key 的 TTL 添加随机值,避免大量缓存同时过期。
  2. 搭建 Redis 集群:保证缓存服务高可用,避免单点宕机。
  3. 服务降级与熔断:缓存不可用时,对非核心接口进行降级,保护核心数据库。

三大问题对比

问题 核心原因 影响范围 核心解决方案
缓存穿透 查询不存在的数据,缓存无法命中 数据库持续被非法请求访问 缓存空值、布隆过滤器
缓存击穿 单个热点 key 过期,高并发同时查库 单个热点数据对应的数据库请求 互斥锁(单机/分布式)
缓存雪崩 大量 key 同时过期 / Redis 宕机 全量/大面积请求直接打向数据库 过期时间加随机值、Redis 集群

手写 Redis 缓存实现示例

@Autowired
private RedisTemplate redisTemplate;

@Override
public Product selectById(Long id) {
    // 1. 先从缓存中查询
    Product product = (Product) redisTemplate.opsForValue().get("product:" + id);
    
    // 2. 缓存未命中
    if (ObjectUtils.isEmpty(product)) {
        // 3. 查询数据库
        product = productMapper.selectById(id);
        
        if (!ObjectUtils.isEmpty(product)) {
            // 数据存在:写入缓存,设置过期时间,避免雪崩
            redisTemplate.opsForValue().set("product:" + id, product, 1, TimeUnit.DAYS);
        } else {
            // 数据不存在:缓存空值,设置短过期时间,解决缓存穿透
            redisTemplate.opsForValue().set("product:" + id, new Product(), 1, TimeUnit.MINUTES);
        }
    }
    return product;
}

三、Spring Cache 简化缓存开发

Spring Cache 是 Spring Framework 提供的声明式缓存抽象层,通过注解的方式将方法返回值自动缓存,避免业务代码中重复编写缓存逻辑。

  • 核心优势:统一的缓存抽象,底层支持 Redis、Caffeine、EhCache 等多种缓存实现,切换缓存方案无需修改业务代码。
  • 实现原理:基于 AOP 代理,调用被注解方法时,先进入代理逻辑查询缓存;缓存命中则直接返回,不执行原方法。

1. 核心注解

注解 作用 核心使用场景
@EnableCaching 开启缓存注解功能,标注在启动类/配置类 全局开启缓存能力
@Cacheable 方法执行前先查缓存;命中直接返回,未命中执行方法并写入缓存 查询类方法,缓存方法返回值
@CachePut 强制执行方法,并将结果更新到缓存,不影响方法执行 更新数据时同步更新缓存(轻量双写模式)
@CacheEvict 删除一条或多条缓存数据;支持清空整个缓存分区 修改/删除数据时删除缓存(失效模式)
@Caching 组合注解,可同时组合多个缓存操作 一个方法需要同时执行多种缓存操作
@CacheConfig 类级注解,统一配置缓存名称、key 生成器等公共属性 类内所有缓存方法共享配置

注解核心参数说明

  • value / cacheNames:缓存分区名称,按业务模块划分(如 productCache、categoryCache)
  • key:通过 SpEL 表达式指定缓存 key,如 #id、#product.id
  • sync:是否加锁,设为 true 可解决缓存击穿问题
  • condition:方法执行前判断,满足条件才进行缓存
  • unless:方法执行后判断,满足条件则不缓存结果
  • allEntries:@CacheEvict 专属,清空整个缓存分区
  • beforeInvocation:@CacheEvict 专属,是否在方法执行前删除缓存

2. 缓存一致性方案

保证数据库与缓存数据一致性的两种主流模式:

  1. 失效模式(推荐)

:修改数据库后,删除对应缓存;后续请求重新查库并重建缓存。

  • 实现简单,并发场景下一致性更好,是最常用的方案。
  1. 双写模式

:修改数据库后,同步更新缓存。

  • 实时性更高,但存在并发更新顺序问题,可能导致缓存脏数据。

3. 配置使用步骤

① 引入 Maven 依赖

<!-- Spring Cache 启动器 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Redis 启动器 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

② 配置文件 application.yml

spring:
  cache:
    type: redis          # 指定使用 Redis 作为缓存实现
    redis:
      time-to-live: 3600000   # 缓存默认过期时间,单位毫秒
      cache-null-values: true # 开启空值缓存,解决缓存穿透
      use-key-prefix: true    # 启用缓存 key 前缀

③ 启动类开启缓存

@SpringBootApplication
@MapperScan("com.situ.mall.mapper")
@EnableCaching  // 开启 Spring Cache 注解
public class AdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(AdminApplication.class, args);
    }
}

④ Redis 序列化配置

解决默认 JDK 序列化问题,配置 key 字符串序列化、value JSON 序列化:

@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheProperties.class)
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 GenericJackson2JsonRedisSerializer())
        );

        CacheProperties.Redis redisProperties = cacheProperties.getRedis();

        // 过期时间
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        // key 前缀
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
        }
        // 是否缓存空值
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        // 是否使用前缀
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }

        return config;
    }
}

4. 业务代码示例

① 根据 ID 查询商品(@Cacheable)

/**
 * 根据ID查询商品
 * value: 缓存分区
 * key: SpEL指定缓存key
 * sync = true: 加锁,解决缓存击穿
 */
@Cacheable(value = "productCache", key = "#id", sync = true)
@Override
public Product selectById(Long id) {
    return productMapper.selectById(id);
}

② 查询分类树(固定 key)

// key 为固定字符串时,需要用单引号包裹;项目里整棵分类树缓存到 categoryCache::tree
@Cacheable(value = "categoryCache", key = "'tree'", sync = true)
public List<CategoryVO> selectCategoryTree() {
    return buildTree(categoryMapper.selectList(...));
}

③ 更新商品(更新库后淘汰缓存)

项目当前对更新统一用 @CacheEvict(删缓存,下次读时按需重建,比 @CachePut 更不易产生并发旧值覆盖):

@CacheEvict(value = "productCache", key = "#product.id")
@Override
public void updateProduct(Product product) {
    productMapper.updateById(product);
}

需要”更新后立即把新值放进缓存”时才用 @CachePut;注意 @CachePut 方法返回值必须是要缓存的对象。

④ 删除商品(@CacheEvict)

删除数据库数据后,删除对应缓存:

@CacheEvict(value = "productCache", key = "#id")
@Override
public void deleteById(Long id) {
    productMapper.deleteById(id);
}

批量删除无法逐一枚举 key 时,用 @CacheEvict(value = "productCache", allEntries = true) 清空整个分区。

5. Spring Cache 对应三大问题的解决方案

问题 Spring Cache 解决方案
缓存穿透 配置 spring.cache.redis.cache-null-values: true,自动缓存空值
缓存击穿 @Cacheable(sync = true),同 key 只放行一个线程查库重建缓存
缓存雪崩 配置 time-to-live 设置过期时间,可结合随机 TTL;搭建 Redis 集群

四、总结

  • 手写 Redis 缓存:灵活度高,可完全自定义缓存逻辑,但代码侵入性强,重复代码多。
  • Spring Cache:声明式注解开发,业务代码无侵入,适合绝大多数标准缓存场景;复杂的缓存策略(如精准控制过期时间、分布式锁细节)仍需手写 Redis 代码实现。
上一篇
下一篇