☰
SpringBoot五种Redis缓存策略:穿透、击穿、雪崩与一致性
2026/9/26 8:01:00 网站建设 项目流程

上周压测一个商品详情接口,QPS 到 200 上下就卡住了,平均响应 1200ms,最慢的请求接近 2 秒。慢查询日志翻出来,大部分时间耗在一次多表 join 和一次远程价格服务调用上。这种接口属于典型的“高频、重查询、结果变化不频繁”,我的第一反应就是上 Redis 缓存。但缓存不是把数据塞进去就完事,我见过很多项目加了缓存反而更慢:穿透、击穿、雪崩、脏数据、大 Key 阻塞全踩一遍。这篇把我在 SpringBoot 项目里实际验证过的五种缓存策略完整写出来,从选型到代码,再到上线后的监控与治理,读者照着能落地上线,出了故障也能按这个思路排查。

1. 加缓存前,先给接口性能对症下药

1.1 慢接口的三类常见病根

在动手引入 Redis 之前,我习惯先问三个问题:这个接口是持续慢还是偶尔慢?慢在哪一层?数据多久变一次?如果这几个问题答不上来,缓存方案很可能从一开始就是歪的。

多数慢接口都逃不出三类:

  • 数据库查询慢:SQL 关联多、数据量大、索引缺失,单个请求耗在 DB 上几百毫秒。
  • 重复计算开销大:同一个复杂统计、排序或对象组装逻辑被海量请求反复执行,但结果可能在几秒甚至几分钟内完全不变。
  • 外部依赖不可控:下游接口或第三方服务响应慢、偶发超时,接口整体耗时被拉高。

值得优先用缓存处理的是前两类,尤其是“高频、读多写少、实时性要求不苛刻”的接口,收益最大。我判断一个接口是不是该缓存时,常用的公式是:缓存收益 = 单次查询耗时 × 请求频率 × 数据不变周期。一个查询只要 10ms、每秒才 5 次,缓存意义不大;但一个查询 200ms、每秒 500 次、结果 5 分钟内都不变,缓存几乎必然是刚需。

1.2 为什么我选了 Redis 而不是本地缓存

本地内存缓存(Guava、Caffeine)读写更快,纳秒级别,部署也简单,但它的短板太明显:多实例部署时各节点各自为政,A 节点更新的数据 B 节点还是旧的,一致性很难控制;进程重启缓存全部丢失,更没法做多节点共享。Redis 是独立部署的分布式缓存,天然能把这些补上。

维度本地缓存Redis直接查数据库
读性能纳秒级微秒级毫秒级起
多实例共享不支持支持支持
数据一致性各节点独立统一写入可控制天然一致
适用场景单机热点削峰通用分布式缓存强一致与低频查询

SpringBoot 项目集成 Redis 的成本很低,spring-boot-starter-data-redis一个依赖就带上了 RedisTemplate、连接池和序列化支持。加上 Redis 本身是内存存储,读写微秒级,数据结构覆盖 String、Hash、List、Set、ZSet,既能缓存普通对象,也能做排行榜、计数器、分布式锁,属于缓存场景里的万金油。

但也有不适合用 Redis 的场景:数据实时性极强,任何缓存都会带来脏读风险;或者单体单机工具型应用,没有多实例共享诉求。这些情况就不必硬上。多数接口性能优化场景,Redis 都是性价比最高的第一步。

2. 策略一:Spring Cache 注解化缓存,去掉重复的缓存样板代码

2.1 什么场景优先用注解

如果接口逻辑简单、入参稳定、缓存 key 能从方法参数直接推导,我最推荐的方式不是手写 RedisTemplate,而是直接用 Spring Cache 注解。原因很直接:缓存查询、写入、过期全由框架接管,业务代码里看不见一行缓存操作,后续维护成本低。

适合注解的场景有三个特征:读多写少、缓存键规则简单、不需要精细控制缓存生命周期。不适合的场景是:缓存逻辑强依赖外部配置、同一方法需要多套缓存策略、结果要经过复杂加工,或者要做布隆过滤器拦截。这些我会放进策略二。

很多同学容易误解 Spring Cache 是另一套技术,其实它底层还是走 RedisTemplate,只不过把缓存访问做成了切面。搞清楚这一点很重要:注解没有魔法,只是帮你省掉了样板代码。

2.2 完整接入示例

第一步引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>

第二步声明缓存管理器和开启注解。我习惯把序列化一起配好,否则缓存里的值会以 Java 序列化格式存储,肉眼没法看,别的语言也没法读:

@Configuration @EnableCaching public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } }

然后在 Service 方法上加注解,一个普通的商品详情查询就能直接缓存:

@Service public class ProductService { @Autowired private ProductMapper productMapper; @Cacheable(value = "product", key = "#id") public Product getProductById(Long id) { System.out.println("未命中缓存,执行查询"); return productMapper.selectById(id); } @CacheEvict(value = "product", key = "#product.id") public void updateProduct(Product product) { productMapper.updateById(product); } }

这里有个点值得展开:key 用的是 SpEL 表达式,"#id"表示取方法参数 id 作为缓存 key;value 是缓存分区名,相当于命名空间,避免不同业务 Key 互相碰撞。第一次调用 getProductById 时方法真实执行,返回值写入 Redis;第二次再调用,控制台不会打印“未命中缓存”,说明数据直接来自 Redis。

2.3 注解缓存容易踩的三个坑

坑一:方法内部调用导致缓存失效。@Cacheable靠 Spring AOP 代理生效,同类里用this调用带缓存注解的方法,走的是当前对象而不是代理对象,注解直接失效。解决办法是拆到另一个 Service,或者自己注入代理对象再通过代理调用。

坑二:缓存对象结构变更后反序列化报错。Product 类加了必填字段,老缓存 JSON 反序列化时可能直接抛异常。我的习惯是发布前改缓存 key 加版本号,比如product:v2:#id,或者直接清掉相关缓存,不要侥幸兼容。

坑三:TTL 控制不灵活。一个 cache name 对应一套过期时间,同一类数据如果有的要 5 分钟、有的要 1 小时,注解方案会非常别扭。这个时候就该考虑下一策略。

3. 策略二:RedisTemplate 手动封装,接管复杂查询和动态 TTL

3.1 什么场景必须用手动方案

注解缓存适合 80% 的简单场景,剩下 20% 必须上手写 RedisTemplate。我判断的标准是这几个信号同时出现:复杂查询条件导致缓存 key 需要多参数拼接;同一数据要不同的过期时间;需要在读缓存前做布隆过滤器拦截;需要在回填缓存时加互斥锁。

手动方案听起来麻烦,其实只要封装得当,代码复用率很高。而且它的价值在于:每个环节都透明可控,出了问题能直接看到是读、是写还是过期策略的问题。

3.2 先封装通用缓存 Service,再套 Cache Aside 读写范式

直接裸用 RedisTemplate 最容易翻车的点有两个:key 序列化乱码、value 反序列化失败。我先统一配置序列化器:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

然后再封装一个 CacheService,把重复的 get、set、delete 收敛起来:

@Service public class CacheService { @Autowired private RedisTemplate<String, Object> redisTemplate; public <T> T get(String key, Class<T> clazz) { Object value = redisTemplate.opsForValue().get(key); return clazz.cast(value); } public void set(String key, Object value, Duration ttl) { redisTemplate.opsForValue().set(key, value, ttl); } public void del(String key) { redisTemplate.delete(key); } }

Read 多写少接口我基本都套 Cache Aside 模式:读请求先查缓存,miss 再查库并回填;写请求先更数据库,再删缓存。示例:

public Product getProduct(Long id) { String key = "product:detail:" + id; Product product = cacheService.get(key, Product.class); if (product != null) { return product; } // 并发保护可以加互斥锁,策略三会详细讲 product = productMapper.selectById(id); if (product != null) { cacheService.set(key, product, Duration.ofMinutes(30)); } return product; } @Transactional public void updateProduct(Product product) { productMapper.updateById(product); cacheService.del("product:detail:" + product.getId()); }

手动方案最大的优势是动态 TTL。比如商品基础信息设 30 分钟,库存数量设 10 秒,价格设 5 分钟,同一个 Service 里就能自由组合。这种能力在注解方案里写起来相当痛苦。

3.3 手动方案和注解方案同存时的两个约定

实际项目里很少有人二选一,更多是并存。为了不让风格混乱,我定过两条规矩:所有缓存 key 必须由统一 KeyBuilder 类生成,禁止散落在业务代码里拼字符串;所有缓存操作必须经过 CacheService,禁止业务代码直接调用 redisTemplate。这样做的好处是后面统一加监控、加降级、加审计都只要改一个类。

顺便提一句,MyBatis 自带的二级缓存虽然也能做缓存,但它本质是进程内缓存,多实例部署时不共享。团队里如果有人在单机上跑着没问题,一上集群就发现缓存失效,原因多半在这里。分布式环境下还是老老实实走 Redis,或者用本地缓存只做单机热点削峰。

4. 策略三:穿透、击穿、雪崩的防御设计

4.1 三个问题一张表看懂

缓存三板斧:穿透、击穿、雪崩。很多同学分不清,我直接放一张表,这是给团队做分享时用的,基本秒懂。

问题触发场景现象典型后果
缓存穿透查询一个根本不存在的数据缓存和数据库都没有每次请求都打到数据库
缓存击穿某个热点 Key 到点失效大量请求同时访问同一个 Key数据库瞬时压力陡增
缓存雪崩大量 Key 同一时刻过期Redis 大范围失效数据库被一波流量打垮

这三类问题的本质都是“Redis 挡不住流量”,但处理方式差别很大。下面分开讲。

4.2 穿透防御:空值缓存加布隆过滤器

穿透最常出现在恶意请求,拿一个不存在的 ID 来刷接口。Redis 查不到,DB 也查不到,请求全落到数据库。低频可能无所谓,脚本一刷,数据库连接池很快就撑不住。

我的第一道防线是缓存空值。查询结果为空时,也给 key 写一个 TTL 很短的占位值,比如 60 秒:

Product product = cacheService.get(key, Product.class); if (product == null) { product = productMapper.selectById(id); if (product == null) { // 缓存空值,防止同一个非法 key 反复打库 cacheService.set(key, EMPTY_VALUE, Duration.ofSeconds(60)); return null; } cacheService.set(key, product, Duration.ofMinutes(30)); }

但空值缓存只能拦截同一个 key 的重复穿透,假如攻击者随机生成大量不存在的 ID,Redis 里会堆积一堆垃圾 key。第二道防线是布隆过滤器。项目启动时把合法主键集合加载进去,请求到了先判断 key 是否存在,不存在直接返回,根本不需要碰缓存。

BloomFilter<Long> filter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01); // 启动预热时放入合法ID filter.put(product.getId()); // 请求进来先判断 if (!filter.mightContain(id)) { return null; }

注意布隆过滤器有误判率,它只能保证“不在就是不在”,“在”不一定真在,所以适合拦截明显非法的请求,不适合做最终数据源判断。

4.3 击穿防御:SETNX 互斥锁

热点 Key 失效的瞬间,比如秒杀商品详情,几万个请求同时发现缓存没有数据,一起冲进数据库。这个场景用互斥锁最有效:只有一个线程能去查库回填,其他线程阻塞等待后直接复用缓存。

我平时会直接用 Redis 的 SETNX 实现一个极简互斥锁:

String lockKey = "lock:product:detail:" + id; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { Product product = productMapper.selectById(id); cacheService.set(cacheKey, product, Duration.ofMinutes(30)); return product; } finally { redisTemplate.delete(lockKey); } } else { // 短暂 sleep 后重试读缓存 Thread.sleep(50); return cacheService.get(cacheKey, Product.class); }

锁的过期时间很关键:太短,查库回填还没完成锁就自动到期,其他线程又涌进来;太长,后续申请锁的线程等待时间会很难看。一般设成预计回填耗时的 3~5 倍比较合适。生产环境我推荐直接用 Redisson 的 lock,它把锁续期、释放边界都处理好了,比手动 SETNX 省心得多。

4.4 雪崩防御:过期时间打散加多级缓存

雪崩的经典成因是缓存预热时把所有 key 设成相同 TTL,到点之后大片 key 同时失效,数据库被一波流量打挂。解决办法很简单:给 TTL 加随机波动值。

Duration ttl = Duration.ofMinutes(30).plus(Duration.ofSeconds(new Random().nextInt(600))); cacheService.set(key, product, ttl);

遇到极高并发的核心数据,我还会叠一层 Caffeine 本地缓存。请求先找本地缓存,再找 Redis,最后才是数据库。即使 Redis 里的 key 成片过期,本地缓存也能先挡住相当一部分流量。本地缓存不要设太长,几秒到十几秒足够,只用来削峰,不承担一致性责任。

多级缓存需要克制,不是什么数据都往里塞。我的原则是:只有 QPS 特别高、单条数据量大但总量可控的接口,才适合上本地缓存层。否则内存被冷数据吃光,命中率反而更差。

5. 策略四:缓存与数据库的一致性:延迟双删与异步刷新

5.1 缓存不一致的根源

缓存和数据库不一致,大多数时候不是代码写错了,而是时序问题。最经典的一个场景:读请求先查缓存没命中,去数据库拿到旧数据,还没回填完;这时候写请求更新了数据库并删除了缓存;接着读请求把旧数据写回 Redis。从这一刻起,缓存里的就是脏数据。

还有两个常见原因:更新数据库成功了但删除缓存失败;并发更新同一个 key 时,回填顺序和提交顺序相反。搞清楚根源就知道,光“先更新数据库再删缓存”这个顺序还不够,必须在时间窗口和失败重试上做文章。

5.2 延迟双删是这样落地的

普通业务里我常用的方案是延迟双删。步骤很简单:

  1. 先更新数据库;
  2. 删除缓存;
  3. 休眠几百毫秒到一秒;
  4. 再次删除缓存。

第一次删除后,如果中间有读请求把旧数据回填进来,第二次删除就能把这个窗口期的脏数据清掉。代码也很直接:

@Transactional public void updateProduct(Product product) { productMapper.updateById(product); String key = "product:detail:" + product.getId(); redisTemplate.delete(key); // 延迟第二次删除,等读请求回填窗口过去 Thread.sleep(500); redisTemplate.delete(key); }

延迟时间怎么估?核心看“读缓存发现没有 -> 查库 -> 回填缓存”这段耗时的 P99 响应时间。如果服务本身 P99 是 300ms,延迟 500ms 基本够用。不要设太长,接口响应会被拖难看的。

实际工程里,我不会让当前请求白白等 500ms,而是把第二次删除丢到异步线程池,比如 Spring 的@Async或者自己包一个线程池。这样既保证窗口能过去,又不增加接口耗时。

5.3 追求更强一致:订阅 binlog 异步刷新

延迟双删依赖时间窗口估算,理论上做不到绝对强一致。如果业务对一致性要求高,我会走数据库变更感知方案:通过 Canal 订阅 MySQL binlog,把变更事件推给 MQ,消费者再刷新或删除缓存。为什么好用?因为缓存更新变成事件驱动,和业务代码完全解耦,删缓存失败还能靠 MQ 重试兜底。

我们做价格体系时用的就是这条链路:价格变更写库后,Canal 抓到 binlog,推给 MQ,消费端刷新价格缓存。实测下来延迟只有几百毫秒到一两秒,业务完全感知不到。相比延迟双删,它的优点是删除动作不会被业务漏调;缺点是要多维护一套 Canal 和 MQ,项目初期没必要上。

如果觉得 Canal 还是重,可以退一步:仍然用延迟双删,但把删除动作放进本地事务消息表,失败后由定时任务扫表重试。这也是一种低成本的重试保障,适合不想引入太多中间件的团队。

5.4 分布式锁在缓存回填中的使用

不管是延迟双删还是 binlog 订阅,高并发回填时都要防一下:多个实例同时查库回填同一个 key,数据库被白打不还回填顺序可能乱。让回填逻辑只允许一个线程执行,其他人等锁结束后直接读缓存。

RLock lock = redissonClient.getLock("lock:product:" + id); if (lock.tryLock(1, 5, TimeUnit.SECONDS)) { try { // 二次检查缓存,防止等待锁期间其他线程已经回填 Product product = cacheService.get(key, Product.class); if (product == null) { product = productMapper.selectById(id); cacheService.set(key, product, Duration.ofMinutes(30)); } } finally { lock.unlock(); } }

这里和 4.3 的互斥锁用的是同一个原语,但位置不同。4.3 是为了防缓存击穿,让并发读退化成单线程回填;这里更多是为了防止写后重建缓存时多实例互相踩踏。锁粒度也要注意,别精细到每个字段级别,否则锁竞争本身就是新瓶颈。

6. 策略五:缓存治理:预热、监控、降级缺一不可

6.1 预热:别让流量先打穿冷缓存

刚上线或重启后,Redis 里是空的,流量直接切过来,热点 key 会在短时间内集体回填,数据库压力非常明显。所以只要涉及缓存变更的发布,我都会先做预热。

预警方式按工作量递进有三种:

  • 启动监听器预热:ApplicationRunner 里拉取热点数据写入 Redis;
  • 定时任务预热:在服务空闲时刷新一批即将过期的 key;
  • 流量染色预热:从请求日志中统计 Top N 高频 key,提前填充。

预热注意别把大量冷数据一次性塞进去。内存被无价值数据占满,真正的热点反而容易被凌晨淘汰出局。我一般只取最近几小时请求日志里的 Top 1000 做预热,宁可少点,不要乱塞。

6.2 监控:命中率、内存与可视化客户端

缓存没有监控等于裸奔。我最常看三个指标:命中率、内存占用、大 Key 数量。命中率可以从 Redis 的 INFO stats 里拿:

redis-cli info stats | grep keyspace # keyspace_hits:123456 # keyspace_misses:2345 # 命中率 = hits / (hits + misses)

如果某个接口缓存命中率长期低于 80%,先别急着加缓存容量,回头看看 key 设计是不是出了问题。比如 key 带了无意义的随机参数,导致每条请求都 miss,这种情况加内存也只是堆垃圾数据。

可视化方面,我用 Redis Desktop Manager 和 Another Redis Desktop Manager 比较多,主要用来快速浏览 key 分布、看过期时间、排查线上疑难杂症。这类工具的优点是直观,不用记命令;但生产环境别用生产密码到处乱连,最好走只读账号或堡垒机。

更完善的方案是把 Redis exporter 接入 Prometheus,再配 Grafana 看板,把所有实例的 QPS、内存、命中率、慢查询集成在一个面板。团队协作时,这个投入很值。

6.3 大 Key、热 Key 的排查与处理

大 Key 的危害很多人都低估了:一个 Hash 里存几十万条数据,查询慢,删除更慢,删除期间 Redis 是单线程处理的,其他请求全部阻塞。官方给了排查工具:

redis-cli --bigkeys

它会扫描内存占用最大的 key。发现大 Key 后,按数据类型处理:Hash 拆成多个小 Hash,List 按分页思路只保留热点数据,大 JSON 做字段拆分或压缩。拆分后写入和读取的并发度都会好很多。

热 Key 是另一种痛:某个 key 扛了超高 QPS,单节点 CPU 飙升。我的处理方式是用本地缓存扛第一层,Redis 扛第二层,数据库只兜底。另外可以给热 key 做多副本,比如 key 后面拼随机后缀分散到不同节点,读的时候随机选一个副本,降低单点压力。多副本方案要记得所有副本同写同删,实现成本不算低,只对极端热 key 使用。

6.4 降级兜底:Redis 抖动但接口不能挂

做缓存最担心的事,是 Redis 网络抖动或慢查询导致接口整体变慢,反而比不加缓存还差。所以我有一条原则:缓存必须是接口的加速器,不能成为接口的故障点。

落地的做法很简单,所有 Redis 操作包一层 try-catch,异常时不抛出,直接走“无缓存直查数据库”的降级路径。这样即使 Redis 挂掉,接口最多是性能退化,而不是报错:

public Product getProduct(Long id) { String key = "product:detail:" + id; try { Product product = cacheService.get(key, Product.class); if (product != null) { return product; } } catch (Exception e) { log.warn("Redis 获取缓存异常,降级直查数据库", e); } return productMapper.selectById(id); }

如果担心 Redis 慢调用把业务线程池占满,最简单的方式是把 Redis 连接池的 maxTotal 和 timeout 调合理,必要时用独立的线程池执行 Redis 操作。要不要做线程池隔离,取决于业务的重要性。核心交易链路我会做,普通查询接口不做。

我印象很深的一次线上事故,就是 Redis 主从切换期间缓存调用全部超时,降级没做好,整个商品服务跟着告警。从那以后,我把降级设计当成缓存方案的一部分写进文档,和业务代码一起评审,不再把它当成上线后“有空再看”的优化项。缓存能扛性能压力,但前提是它故障时系统还站得住。


最后再分享一个小经验:五个策略不是每套系统全上,而是根据业务阶段选。缓存逻辑简单、key 由方法参数决定,优先上注解方案;出现动态 TTL、复杂查询、布隆过滤器,就切到手动封装;上线前确认穿透、击穿、雪崩都设计到位;写入频繁的数据要认真想一致性方案;最后一定要有监控和降级。缓存方案本身不难,难的是在压测和线上看到数据后,才发现自己哪一步没做到位。希望这篇能把实战路上最容易踩的坑先填掉一部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询