Spring Boot + Redis 缓存架构实战——从简单缓存到多级缓存设计
Spring Boot + Redis 缓存架构实战——从简单缓存到多级缓存设计
适读人群:有 Spring Boot 和 Redis 基础、想构建生产级缓存架构的 Java 工程师 | 阅读时长:约20分钟 | 核心价值:掌握单级缓存到多级缓存的演进路径,解决缓存穿透、击穿、雪崩三大问题
一、那次把 MySQL 打崩的经历
2022年双十一活动期间,某同学所在的电商公司搞了个秒杀活动,他负责商品详情服务。活动开始前他信誓旦旦说"我已经加了缓存",结果活动开始后五分钟,数据库 CPU 飙到 100%,服务开始拒绝请求。
复盘发现,他加的缓存是最朴素的写法:每次查商品详情,先查 Redis,没有就查 DB 并写入 Redis,TTL 设了 30 分钟。平时 QPS 只有两三百,这个方案够用。但活动开始瞬间 QPS 打到 8000,而所有热门商品恰好在活动开始前 10 分钟集中失效了——缓存集中过期,8000 个请求同时打到数据库,数据库扛不住,崩了。
这就是经典的缓存雪崩。
这个故事告诉我一件事:缓存不是加个 Redis 就万事大吉,它是一个架构设计问题。这篇文章我从单级缓存开始,一直讲到多级缓存设计,把缓存穿透、击穿、雪崩的解法全部给你。
二、Spring Boot 集成 Redis 基础
先把基础配置走一遍,确保大家起点一致。
pom.xml 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 使用 lettuce 连接池需要此依赖 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>application.yml 配置:
spring:
redis:
host: 127.0.0.1
port: 6379
password: your_password
database: 0
lettuce:
pool:
max-active: 20 # 最大连接数
max-idle: 10 # 最大空闲连接
min-idle: 5 # 最小空闲连接
max-wait: -1ms # 等待超时,-1 表示不超时
timeout: 3000ms # 命令超时Redis 配置类(序列化配置至关重要):
package com.example.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
/**
* 自定义 RedisTemplate,使用 JSON 序列化。
* 默认的 JDK 序列化会导致 Redis 里存的是不可读的二进制,调试困难。
* 用 Jackson2JsonRedisSerializer,存的是可读的 JSON 字符串。
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 配置 Jackson 序列化器,开启类型信息保存(反序列化时知道原始类型)
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer =
new Jackson2JsonRedisSerializer<>(om, Object.class);
// Key 用 String 序列化,Value 用 JSON 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}三、封装通用缓存工具
原生 RedisTemplate API 比较繁琐,我习惯在项目里封装一个 CacheService,统一处理序列化、异常、超时等。
package com.example.cache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
@Component
public class CacheService {
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
private final RedisTemplate<String, Object> redisTemplate;
public CacheService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 读缓存,缓存未命中时调用 loader 加载并写入缓存。
* 这是最常用的 Cache-Aside 模式。
*
* @param key 缓存键
* @param ttl 缓存过期时间
* @param loader 缓存未命中时的数据加载逻辑
* @param <T> 返回类型
* @return 缓存或数据库中的数据
*/
@SuppressWarnings("unchecked")
public <T> T getOrLoad(String key, Duration ttl, Supplier<T> loader) {
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
log.debug("[Cache] 命中缓存。key={}", key);
return (T) cached;
}
} catch (Exception e) {
// Redis 异常不影响业务,降级到 DB
log.error("[Cache] Redis 读取异常,降级到数据库。key={}", key, e);
}
log.debug("[Cache] 缓存未命中,加载数据。key={}", key);
T value = loader.get();
if (value != null) {
try {
redisTemplate.opsForValue().set(key, value, ttl);
} catch (Exception e) {
log.error("[Cache] Redis 写入异常,不影响业务继续。key={}", key, e);
}
}
return value;
}
/**
* 删除缓存(更新数据时使用)。
*/
public void delete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.error("[Cache] Redis 删除异常。key={}", key, e);
}
}
/**
* 判断缓存是否存在。
*/
public boolean exists(String key) {
try {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
return false;
}
}
}四、三大缓存问题及解法
4.1 缓存穿透——查询不存在的数据
问题:恶意请求用不存在的 ID 查数据,每次都绕过缓存打到 DB。
解法1:缓存空值
public ProductDTO getProduct(Long id) {
String key = "product:" + id;
// 先查 Redis
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
// 命中缓存空值标记,直接返回 null(表示数据不存在)
if (cached instanceof String && "NULL".equals(cached)) {
return null;
}
return (ProductDTO) cached;
}
// 查数据库
ProductDTO product = productMapper.selectById(id);
if (product == null) {
// 数据不存在,缓存一个空值标记,TTL 设短一点(5分钟),防止空值占用太多内存
redisTemplate.opsForValue().set(key, "NULL", Duration.ofMinutes(5));
} else {
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
}
return product;
}解法2:布隆过滤器(Bloom Filter)
数据量大时用布隆过滤器更优。Redisson 提供了分布式布隆过滤器实现:
// 初始化布隆过滤器(预计存放 100 万条数据,误判率 0.01%)
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product_ids");
bloomFilter.tryInit(1_000_000L, 0.0001);
// 查询前先判断布隆过滤器
public ProductDTO getProduct(Long id) {
if (!bloomFilter.contains(id)) {
// 一定不存在,直接返回
return null;
}
// 再走缓存 -> DB 流程
return cacheService.getOrLoad("product:" + id, Duration.ofMinutes(30),
() -> productMapper.selectById(id));
}4.2 缓存击穿——热点 Key 失效瞬间
问题:单个热点 Key 失效的一瞬间,大量并发请求同时打到 DB。
解法:分布式互斥锁(同一时刻只有一个线程去重建缓存)
package com.example.cache;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
@Component
public class HotKeyCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
public HotKeyCacheService(RedisTemplate<String, Object> redisTemplate,
RedissonClient redissonClient) {
this.redisTemplate = redisTemplate;
this.redissonClient = redissonClient;
}
/**
* 防击穿的缓存读取:缓存失效时,用分布式锁保证只有一个线程回源 DB。
* 其他线程等待锁期间,重新尝试读缓存(此时缓存已被重建)。
*
* @param key 缓存键
* @param ttl 缓存 TTL
* @param loader 回源加载逻辑
*/
@SuppressWarnings("unchecked")
public <T> T getWithLock(String key, Duration ttl, Supplier<T> loader) {
// 第一次检查
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (T) cached;
}
// 缓存未命中,加分布式锁
String lockKey = "lock:" + key;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待 3 秒,锁持有 10 秒后自动释放
boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (acquired) {
try {
// 获取锁之后,双重检查(另一个线程可能已经重建缓存了)
cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (T) cached;
}
// 回源加载
T value = loader.get();
if (value != null) {
redisTemplate.opsForValue().set(key, value, ttl);
}
return value;
} finally {
lock.unlock();
}
} else {
// 没拿到锁,等待一下再重试(等持有锁的线程重建完缓存)
Thread.sleep(50);
cached = redisTemplate.opsForValue().get(key);
return (T) cached;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return loader.get(); // 降级直接读 DB
}
}
}4.3 缓存雪崩——大批 Key 同时失效
解法:TTL 加随机抖动
这是最简单也最有效的方案。核心思路:不让所有 Key 在同一时刻失效。
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
/**
* 计算带随机抖动的 TTL,防止大批 Key 同时失效造成雪崩。
* 基础 TTL 为 30 分钟,加上 0~10 分钟的随机偏移。
*/
public Duration randomTtl(Duration base) {
long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 600); // 0~600秒随机
return base.plusSeconds(jitterSeconds);
}
// 使用时
redisTemplate.opsForValue().set(key, value, randomTtl(Duration.ofMinutes(30)));五、多级缓存设计
对于读 QPS 极高的场景(比如 QPS > 5000 的热点数据),单靠 Redis 也会成为瓶颈。这时候引入本地缓存 + Redis 的两级架构。
架构示意:
请求 → 本地缓存(Caffeine, ~10ms 以内)
↓ miss
Redis 缓存(~2ms 以内)
↓ miss
数据库(~20ms 以上)Caffeine 本地缓存配置:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>package com.example.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
/**
* 两级缓存:本地 Caffeine + 分布式 Redis。
* 本地缓存容量 1000 条,TTL 1 分钟(短,保证数据新鲜度)。
* Redis 缓存 TTL 30 分钟。
* 适合读多写少、允许 1 分钟内数据轻微不一致的场景。
*/
@Component
public class L2CacheService {
// 本地一级缓存(Caffeine)
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(1_000) // 最多缓存 1000 条
.expireAfterWrite(1, TimeUnit.MINUTES) // 写入 1 分钟后失效
.recordStats() // 开启统计,方便监控命中率
.build();
private final RedisTemplate<String, Object> redisTemplate;
public L2CacheService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@SuppressWarnings("unchecked")
public <T> T get(String key, Duration redisTtl, Supplier<T> dbLoader) {
// 第一级:本地缓存
Object localVal = localCache.getIfPresent(key);
if (localVal != null) {
return (T) localVal;
}
// 第二级:Redis 缓存
Object redisVal = redisTemplate.opsForValue().get(key);
if (redisVal != null) {
// 回填本地缓存
localCache.put(key, redisVal);
return (T) redisVal;
}
// 第三级:数据库
T dbVal = dbLoader.get();
if (dbVal != null) {
redisTemplate.opsForValue().set(key, dbVal, redisTtl);
localCache.put(key, dbVal);
}
return dbVal;
}
/**
* 数据更新时,先删 Redis,再删本地缓存。
* 集群环境下,本地缓存需要通过 Redis Pub/Sub 或 MQ 通知其他节点删除。
*/
public void invalidate(String key) {
redisTemplate.delete(key);
localCache.invalidate(key);
}
}多级缓存的注意事项:在多节点部署时,本地缓存删除只影响当前节点。如果你在节点 A 更新了数据,节点 B 的本地缓存不会自动失效。解决方案是在 invalidate 时发布一条 Redis 消息,所有节点订阅该消息并清除本地缓存。这个方案我在后面分布式缓存的文章里会详细讲。
六、踩坑实录
坑1:缓存 Key 冲突导致数据错乱
现象:同一套 Redis 跑了两个服务,偶尔出现业务数据错乱,A 服务读到了 B 服务的缓存。
原因:两个服务的缓存 Key 命名没有加服务前缀,product:1001 这个 Key 在两个服务里含义不同。
解法:这个坑我也踩过,后来强制规定 Key 命名格式:{服务名}:{业务模块}:{唯一标识},比如 order-service:product:1001。
坑2:大 Value 导致 Redis 慢命令
现象:监控发现 Redis 偶尔出现延迟尖刺,p99 延迟从 2ms 飙到 50ms。
原因:某个 Key 存了一个 List,包含几千条记录,序列化后达到 800KB。Redis 单线程,读写这个大 Value 会阻塞其他命令。
解法:分拆大 Value,改用 Hash 结构存储分页数据,或者只缓存 ID 列表,详情数据按需加载。单个 Value 建议不超过 10KB,超过 100KB 就要重新考虑方案。
坑3:缓存与数据库不一致
现象:数据库里已经是新数据,但 Redis 里还是旧数据,持续了几十秒。
原因:更新逻辑是"先更新 DB,再更新缓存",但更新缓存那步偶尔失败了,错误被吞掉了。
解法:改成"先更新 DB,再删缓存",删缓存失败时写入延迟消息队列,异步重试删除。同时缓存 TTL 设置合理,即使删除失败,到期后也会自然失效。
七、实战建议
我用过的最有效的缓存策略总结:
- 读多写少的配置型数据:缓存 TTL 设长(比如 1 小时),更新时主动删除
- 热点商品/用户数据:TTL 加随机抖动,配合分布式锁防击穿
- 实时性要求高的数据(如库存):不要用 Redis 缓存,直接读 DB 或用 Redis 作为主存储
- 高 QPS 热点:本地缓存 + Redis 两级,本地缓存 TTL 设 30 秒~2 分钟
缓存策略没有银弹,关键是根据你的读写比、实时性要求、数据规模做出合理的取舍。
