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,攻击者通过大量非法请求打垮数据库。
解决方案:
- 缓存空对象(Cache Null Object) 当查询的数据不存在时,将空对象或特殊标识值写入缓存,并设置较短的过期时间(TTL)。后续相同请求直接从缓存返回,避免频繁访问数据库。
- 布隆过滤器:在缓存层之前提前拦截不存在的 key,从入口减少穿透请求。
2. 缓存击穿
定义:高并发场景下,某一个热点数据的缓存过期/被删除,瞬间大量并发请求同时访问该数据,全部绕过缓存直接打到数据库,造成数据库压力激增。
- 核心原因:缓存重建需要时间,高并发下所有请求同时执行查库+写缓存逻辑。
解决方案:互斥锁 只放行一个线程去查询数据库并重建缓存,其余线程阻塞等待,缓存重建完成后直接读取。
- 单机环境:使用
synchronized或Lock本地锁 - 分布式集群环境:使用 Redis
setnx、Redisson 分布式锁 - 特点:牺牲少量并发性能,保护数据库不被击穿。
3. 缓存雪崩
定义:同一时段大量缓存 key 同时失效,或 Redis 服务整体宕机,导致海量请求直接涌入数据库,造成数据库压力剧增。
- 常见原因:大量 key 的过期时间设置完全相同/非常接近,集中失效;Redis 节点故障。
- 与缓存击穿的区别:击穿是单个热点 key 失效;雪崩是大量 key 集体失效。
解决方案:
- 过期时间加随机偏移:给不同 key 的 TTL 添加随机值,避免大量缓存同时过期。
- 搭建 Redis 集群:保证缓存服务高可用,避免单点宕机。
- 服务降级与熔断:缓存不可用时,对非核心接口进行降级,保护核心数据库。
三大问题对比
| 问题 | 核心原因 | 影响范围 | 核心解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据,缓存无法命中 | 数据库持续被非法请求访问 | 缓存空值、布隆过滤器 |
| 缓存击穿 | 单个热点 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.idsync:是否加锁,设为true可解决缓存击穿问题condition:方法执行前判断,满足条件才进行缓存unless:方法执行后判断,满足条件则不缓存结果allEntries:@CacheEvict专属,清空整个缓存分区beforeInvocation:@CacheEvict专属,是否在方法执行前删除缓存
2. 缓存一致性方案
保证数据库与缓存数据一致性的两种主流模式:
- 失效模式(推荐)
:修改数据库后,删除对应缓存;后续请求重新查库并重建缓存。
- 实现简单,并发场景下一致性更好,是最常用的方案。
- 双写模式
:修改数据库后,同步更新缓存。
- 实时性更高,但存在并发更新顺序问题,可能导致缓存脏数据。
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 代码实现。