先聊一个我印象挺深的线上问题。
有段时期我们某个详情页接口突然从 30ms 涨到 1.2s,数据库 CPU 直接飙上去。查下去发现那条数据并不存在,但前端因为埋点问题疯狂请求同一个不存在的 id,Redis 里又没有缓存这个“不存在”,于是几千 QPS 全部穿过缓存打到了 MySQL。
这种事故非常典型。Spring Boot 项目里引入 Redis 后,大家的第一反应通常是给 Service 方法加 @Cacheable。这没错,缓存注解确实能把“读多写少”的数据访问成本降下来。但等你在生产环境扛过一阵就会发现,@Cacheable 只解决了存取问题,没有解决风险问题:缓存穿透、缓存击穿、缓存雪崩、缓存与数据库的一致性、消息类场景的可靠性,这些才是真正拉开初级开发者和资深开发者差距的地方。
这篇文章不打算重复讲 @Cacheable 怎么用,而是把一个 Spring Boot + Redis 项目里我认为最值得抄走的 4 个实战模式拆开揉碎,每个模式都讲清楚适用场景、实现方案、为什么这样设计,以及我在实际项目中踩过的坑。适合已经会用 Redis 基础命令和 Spring Cache,但想进一步把 Redis 用到生产可观测、可治理、可长期维护的开发者。
1. 先承认一件事:@Cacheable 只解决“存取”,不解决“边界情况”
很多教程展示 @Cacheable 的代码只有三行,看起来优雅无比。但把这样一个注解铺到整个 Service 层后,问题会从几个方向冒出来。
1.1 注解帮你缓存,不帮你扛并发
@Cacheable 底层走的是 Spring Cache 抽象,CacheManager 找到对应的 Cache,然后 get/put。它默认的语义是“先查缓存,没有就执行方法,把结果放进去”。
这听起来没问题,但高并发下有几个缺口:
- 如果查询的 key 在数据库中不存在,方法返回 null,Spring Cache 默认不缓存 null,导致每次请求都穿过缓存打到数据库。
- 如果某个热点 key 刚好过期,恰好有一万个请求同时到达,Spring Cache 本身无法保证多个实例之间只有一个线程去执行回源查询。虽然本地 Caffeine 的同步机制可以保护单实例,但分布式环境下每个服务节点各查一次,数据库照样扛不住。
- 一个 cacheName 对应一种缓存策略,TTL 通常是全局配置。想对单个 key 做随机过期、空值缓存、热点续期,注解做不到。
这些不是 Spring Cache 的设计缺陷,它本来就是做“缓存读写”的,不是做“缓存防御”的。只不过很多团队把它当成万能工具,出了问题才反过来骂 Redis 不行,其实不对。
1.2 注解的自动序列化和自调用问题,看着小但坑很多
Spring Cache 的 RedisCacheManager 默认可能使用 JdkSerializationRedisSerializer。直接缓存 Java 对象没问题,但缓存里是一坨二进制,用 RedisInsight 这类工具看的时候全是乱码,排查问题非常痛苦。
而且 @Cacheable 走的是 AOP 代理。同一个类内部方法直接 this 调用时,代理不生效,缓存直接跳过。这是我见过非常多团队踩过的坑:方法拆到另一个 Service,或者注入自身代理对象才解决。
这些都是“能不能用”的问题。真正到“好不好用”的阶段,需要一套自己能控制的缓存访问层。
1.3 实战分水岭,其实是边界问题处理能力
我给团队做技术分享时经常说一句话:在低并发下,怎么用 Redis 都能跑;在高并发下,大家拼的不是命令背得多熟,而是边界问题的处理顺序是否清晰。
你会不会在缓存过期瞬间控制住回源流量?能不能保证缓存里的数据不会长期和数据库不一致?需不需要一个可靠的消息通道让消费端不丢消息?能不能通过指标看出缓存命中率在下降?这四个问题对应到本文的四个模式:
- 模式一:缓存穿透、击穿、雪崩的综合防护
- 模式二:可靠的 Redis 分布式锁
- 模式三:Redis Stream 做可靠任务队列
- 模式四:大列表分页缓存 + 多级缓存
下面一个个展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模式一:缓存穿透、击穿、雪崩,用一套“缓存兜底模板”同时治理
三个名字听着像,实际上不是一回事。
2.1 三类故障怎么区分
- 穿透:查询一个一定不存在的数据。因为缓存没有,请求每次都穿透到数据库。攻击者经常用随机生成的 id 来制造这种流量。
- 击穿:某个热点 key 在过期的一瞬间,大量请求同时到来,第一波请求全都打到数据库。
- 雪崩:大量 key 在同一段时间集中过期,比如缓存设置了同一个固定 TTL,比如 30 分钟,过了 30 分钟后一大批 key 同时失效,数据库瞬间被压垮。
一句话总结:穿透是查了不存在的东西;击穿是单个热点 key 过期;雪崩是批量 key 同时过期。三者的共同点是“缓存没有兜住流量”。
2.2 我常用的三层防护方案
第一层,空值占位。
具体做法是:数据库查不到结果时,不要把 null 直接返回,而是在 Redis 里写入一个空值标记,并设置比较短的过期时间,比如 30 到 60 秒。这样同一个不存在的 key 再次打进来时,会直接命中空缓存,不会穿透到数据库。
注意一个细节:空值的 TTL 不能太长。如果业务上后续真的创建了这条数据,而空值缓存还没过期,就会出现“明明数据有了,缓存仍旧返回不存在”的尴尬。所以我一般把空值缓存时间控制在 30 秒以内,宁可多放一次穿透流量,也不要让新数据不可见。
第二层,互斥重建。
这是解决击穿的核心手段。当缓存 miss 时,先尝试获取一个分布式锁,只有拿到锁的线程才允许查数据库、写缓存。其他线程没有拿到锁,不直接去查库,而是短暂等待后再次读取缓存。
第三层,随机 TTL。
为缓存设置过期时间时,在基础 TTL 上加一个随机值。比如基础 TTL 是 5 分钟,实际 TTL 就在 5 分钟到 8 分钟之间随机。这样即使缓存中有一大批 key,也不会在同一秒集体过期,雪崩概率大幅降低。
2.3 落地代码模板
以下代码使用 Spring Boot + Redisson + RedisTemplate 实现。思路是封装成一个通用工具方法,业务层不用每次重复写防御逻辑:
java复制public class RedisCacheGuard {
private static final String NULL_FLAG = "__NULL__";
private final StringRedisTemplate redis;
private final RedissonClient redisson;
private final ObjectMapper mapper;
public <T> T load(String key, TypeReference<T> type,
Supplier<T> loader, Duration baseTtl) throws Exception {
// 第一次读缓存
String value = redis.opsForValue().get(key);
if (StringUtils.hasText(value)) {
if (NULL_FLAG.equals(value)) {
return null;
}
return mapper.readValue(value, type);
}
// 获取分布式
