Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透防护与性能调优
在中大型 Spring Boot 项目里,单纯“把 Redis 接上”通常还不够。
我自己在做商品、配置、用户画像这类读多写少的场景时,最先遇到的问题不是“怎么缓存”,而是:
- 本地缓存和 Redis 怎么配合才不打架?
- 更新数据库后,为什么接口偶尔还能读到旧值?
- 热点 key 突然失效时,为什么数据库被打穿?
- Spring Cache 用起来很省事,但它默认并不会帮你自动处理多级缓存一致性
这篇文章我不打算只讲概念,而是带你从 Spring Cache + Caffeine(一级缓存)+ Redis(二级缓存) 搭出一个可运行方案,再把一致性、缓存穿透、热点问题和性能调优一起收掉。
背景与问题
先明确一个目标:我们要的不是“有缓存”,而是“稳定、可控、可排查的多级缓存体系”。
典型读路径通常是:
- 先查本地缓存(JVM 内,速度最快)
- 未命中再查 Redis
- Redis 未命中再查数据库
- 查到后回填 Redis 和本地缓存
这样做的价值很直接:
- 本地缓存:减少网络开销,极低延迟
- Redis:多实例共享,避免每个节点都打数据库
- 数据库:最终数据源
但问题也随之而来:
1. 多级缓存一致性
最常见的坑是:
- A 节点更新数据库并删除 Redis
- B 节点本地缓存还没失效
- 一段时间内 B 节点仍然返回旧数据
这就是典型的 分布式本地缓存一致性问题。
2. 缓存穿透
用户查一个根本不存在的数据,如果每次都直接落到数据库,攻击或者脏流量一来,DB 压力就很大。
3. 缓存击穿
某个热点 key 恰好过期,瞬间大量请求同时穿透到数据库。
4. 缓存雪崩
大量 key 在同一时间段失效,导致 Redis 命中率骤降,数据库扛不住。
前置知识与环境准备
本文示例基于以下技术栈:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- Maven
Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
核心原理
先看整体结构。
flowchart TD
A[请求进入] --> B{本地缓存 Caffeine 命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{Redis 命中?}
D -- 是 --> E[回填本地缓存并返回]
D -- 否 --> F[查询数据库]
F --> G{数据存在?}
G -- 是 --> H[写入 Redis]
H --> I[写入本地缓存]
I --> J[返回结果]
G -- 否 --> K[写入空值/短 TTL 防穿透]
K --> L[返回空]
一级缓存与二级缓存的职责
建议分工很明确:
- Caffeine
- 保存热点、超高频、短 TTL 数据
- 适合单机内快速命中
- Redis
- 共享跨实例数据
- 保存业务缓存主副本
- 数据库
- 最终一致的数据源
为什么 Spring Cache 不够直接
Spring Cache 的优点是注解简单:
@Cacheable@CachePut@CacheEvict
但它更像一个抽象层,并没有天然替你搞定:
- 多级缓存协调
- 本地缓存的跨节点失效
- 热点 key 的互斥重建
- 空值缓存策略
- TTL 随机抖动
所以实际项目里,Spring Cache 适合做统一入口,底层需要自定义 CacheManager 或组合策略。
方案设计:读写路径怎么定
我推荐一个比较稳妥、落地成本也不高的策略:
读路径
- 本地缓存 -> Redis -> DB
- DB 查到后回填两级缓存
- 不存在的数据写入空值缓存,TTL 要短
写路径
- 先更新数据库
- 再删除 Redis
- 再删除本地缓存
- 最好结合消息通知清理其他节点本地缓存
这是经典的 Cache Aside Pattern。
为什么不是“先删缓存再更新数据库”?
因为如果先删缓存,再更新数据库,在数据库提交前这段时间有并发读进来,就可能读到旧值并重新写回缓存,造成脏数据回流。
相对更稳妥的是:
- 更新数据库
- 删除缓存
如果对一致性要求更高,再叠加:
- 延迟双删
- MQ 通知
- binlog 订阅刷新缓存
下面用图看一下。
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant DB as 数据库
participant Redis as Redis
participant Local as 本地缓存
Client->>App: 更新请求
App->>DB: 更新数据
DB-->>App: 提交成功
App->>Redis: 删除缓存
App->>Local: 删除本地缓存
App-->>Client: 返回成功
Note over App,Local: 多实例场景下,还需广播清理其他节点本地缓存
实战代码(可运行)
下面我们做一个简化但可跑的商品查询示例。
目录思路如下:
ProductControllerProductServiceProductRepositoryMultiLevelCacheServiceCacheConfig
第一步:基础配置
application.yml
server:
port: 8080
spring:
cache:
type: simple
data:
redis:
host: localhost
port: 6379
timeout: 3000ms
logging:
level:
org.springframework.data.redis: info
这里 spring.cache.type 不直接依赖默认实现,我们会自己控制多级缓存逻辑。
第二步:定义实体与模拟仓储
Product.java
package com.example.cachedemo.model;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
private Long version;
public Product() {
}
public Product(Long id, String name, BigDecimal price, Long version) {
this.id = id;
this.name = name;
this.price = price;
this.version = version;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
public Long getVersion() {
return version;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public void setVersion(Long version) {
this.version = version;
}
}
ProductRepository.java
这里先用内存 Map 模拟数据库,方便本地跑通。
package com.example.cachedemo.repository;
import com.example.cachedemo.model.Product;
import org.springframework.stereotype.Repository;
import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> store = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
store.put(1L, new Product(1L, "iPhone", new BigDecimal("6999.00"), 1L));
store.put(2L, new Product(2L, "MacBook", new BigDecimal("12999.00"), 1L));
}
public Optional<Product> findById(Long id) {
simulateDbCost();
return Optional.ofNullable(store.get(id));
}
public Product save(Product product) {
simulateDbCost();
long newVersion = product.getVersion() == null ? 1L : product.getVersion() + 1;
product.setVersion(newVersion);
store.put(product.getId(), product);
return product;
}
private void simulateDbCost() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
第三步:配置 Caffeine 与 RedisTemplate
CacheConfig.java
package com.example.cachedemo.config;
import com.github.benmanes.caffeine.cache.Caffeine;
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.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.util.concurrent.TimeUnit;
@Configuration
public class CacheConfig {
@Bean
public com.github.benmanes.caffeine.cache.Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.recordStats()
.build();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
第四步:实现多级缓存服务
这里是核心逻辑:
- 本地缓存查不到,再查 Redis
- Redis 查不到,再查 DB
- 使用短期空值缓存防穿透
- 用分布式锁减少热点 key 击穿
- 更新后删除两级缓存
MultiLevelCacheService.java
package com.example.cachedemo.service;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
@Service
public class MultiLevelCacheService {
private static final String CACHE_PREFIX = "product:";
private static final String LOCK_PREFIX = "lock:product:";
private static final String NULL_VALUE = "__NULL__";
private final Cache<String, Object> caffeineCache;
private final RedisTemplate<String, Object> redisTemplate;
private final StringRedisTemplate stringRedisTemplate;
private final ProductRepository productRepository;
public MultiLevelCacheService(Cache<String, Object> caffeineCache,
RedisTemplate<String, Object> redisTemplate,
StringRedisTemplate stringRedisTemplate,
ProductRepository productRepository) {
this.caffeineCache = caffeineCache;
this.redisTemplate = redisTemplate;
this.stringRedisTemplate = stringRedisTemplate;
this.productRepository = productRepository;
}
public Product getProduct(Long id) {
String key = CACHE_PREFIX + id;
Object localValue = caffeineCache.getIfPresent(key);
if (localValue != null) {
if (NULL_VALUE.equals(localValue)) {
return null;
}
return (Product) localValue;
}
Object redisValue = redisTemplate.opsForValue().get(key);
if (redisValue != null) {
caffeineCache.put(key, redisValue);
if (NULL_VALUE.equals(redisValue)) {
return null;
}
return (Product) redisValue;
}
String lockKey = LOCK_PREFIX + id;
String lockValue = UUID.randomUUID().toString();
boolean locked = Boolean.TRUE.equals(
stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5))
);
try {
if (locked) {
Optional<Product> dbResult = productRepository.findById(id);
if (dbResult.isPresent()) {
Product product = dbResult.get();
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(5));
caffeineCache.put(key, product);
return product;
} else {
redisTemplate.opsForValue().set(key, NULL_VALUE, Duration.ofSeconds(60));
caffeineCache.put(key, NULL_VALUE);
return null;
}
} else {
Thread.sleep(50);
Object retryRedisValue = redisTemplate.opsForValue().get(key);
if (retryRedisValue != null) {
caffeineCache.put(key, retryRedisValue);
if (NULL_VALUE.equals(retryRedisValue)) {
return null;
}
return (Product) retryRedisValue;
}
return productRepository.findById(id).orElse(null);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return productRepository.findById(id).orElse(null);
} finally {
if (locked) {
String current = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(current)) {
stringRedisTemplate.delete(lockKey);
}
}
}
}
public Product updateProduct(Product product) {
Product saved = productRepository.save(product);
String key = CACHE_PREFIX + product.getId();
redisTemplate.delete(key);
caffeineCache.invalidate(key);
return saved;
}
public void evictProduct(Long id) {
String key = CACHE_PREFIX + id;
redisTemplate.delete(key);
caffeineCache.invalidate(key);
}
}
第五步:暴露接口
ProductService.java
package com.example.cachedemo.service;
import com.example.cachedemo.model.Product;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final MultiLevelCacheService multiLevelCacheService;
public ProductService(MultiLevelCacheService multiLevelCacheService) {
this.multiLevelCacheService = multiLevelCacheService;
}
public Product getById(Long id) {
return multiLevelCacheService.getProduct(id);
}
public Product update(Product product) {
return multiLevelCacheService.updateProduct(product);
}
}
ProductController.java
package com.example.cachedemo.controller;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.service.ProductService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public Product getById(@PathVariable Long id) {
return productService.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
return productService.update(product);
}
}
第六步:如何验证它真的生效
你可以按下面顺序测试。
1. 首次查询
curl http://localhost:8080/products/1
第一次会走“DB -> Redis -> 本地缓存”。
2. 第二次查询
再次执行:
curl http://localhost:8080/products/1
此时应优先命中本地缓存,响应会更快。
3. 查询不存在的数据
curl http://localhost:8080/products/999
会写入空值缓存,避免每次都访问 DB。
4. 更新商品
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"iPhone 15","price":7999.00,"version":1}'
更新后会删除 Redis 和本地缓存。下次查询重新加载新值。
用 Spring Cache 注解怎么接入
如果你希望业务层继续享受 Spring Cache 注解带来的简洁写法,一个常见做法是:
- 查询接口继续用
@Cacheable - 更新接口用
@CacheEvict - 多级缓存能力下沉到自定义
CacheManager或统一缓存服务
例如:
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product queryProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
但这里要提醒一句:只加注解,不代表你已经拥有了完整的多级缓存能力。
尤其在这些场景里,你还是得补自定义逻辑:
- 空值缓存
- 逻辑过期
- 分布式锁
- 跨节点本地缓存失效广播
多实例下一致性怎么做
单机演示里,删除本地缓存已经够了;但生产通常是多实例部署。
这时一个节点更新缓存,只能清掉自己的 JVM 本地缓存,其他节点并不知道。解决思路一般有 3 类:
方案 1:Redis Pub/Sub 广播失效
- 更新成功后发布失效消息
- 所有应用实例订阅频道
- 收到消息后清理各自本地缓存
这是实现成本较低、效果也不错的方案。
flowchart LR
A[节点A更新数据库] --> B[删除Redis缓存]
B --> C[节点A删除本地缓存]
C --> D[发布失效消息]
D --> E[节点B订阅消息并删除本地缓存]
D --> F[节点C订阅消息并删除本地缓存]
方案 2:MQ 异步通知
比 Pub/Sub 更可靠,适合对消息送达要求更高的场景。
方案 3:订阅 binlog
由数据变更驱动缓存刷新,一致性更强,但复杂度也高。
我的建议:
- 一般中型系统:
DB 更新 + 删除 Redis + 广播本地缓存失效 - 强一致要求更高:考虑延迟双删或 MQ
- 极高复杂度场景:再评估 Canal / binlog 路线
常见坑与排查
这一部分最值得细看,因为缓存问题通常不是“不会写”,而是“出问题时不好查”。
坑 1:序列化方式不一致
症状:
- Redis 里明明有值,程序反序列化报错
- 不同服务版本读不到旧缓存
原因通常是:
- JDK 序列化与 JSON 序列化混用
- 类结构变更但缓存未清
建议:
- 统一使用 JSON 序列化
- 给关键缓存对象保留兼容字段
- 发布前评估是否需要清理历史缓存
坑 2:本地缓存没有跨节点失效
症状:
- 某台机器读到旧数据,另一台是新的
- 重启应用后问题消失
排查思路:
- 确认 Redis 已删
- 确认当前实例本地缓存是否已删
- 看其他实例是否收到失效通知
坑 3:@Cacheable 方法内部调用失效
症状:
- 明明加了
@Cacheable,但缓存不生效
原因:
Spring AOP 代理机制导致 类内部自调用 绕过代理。
错误示例:
public Product outer(Long id) {
return this.query(id);
}
@Cacheable(cacheNames = "product", key = "#id")
public Product query(Long id) {
return productRepository.findById(id).orElse(null);
}
建议:
- 拆到独立 Service
- 或通过代理对象调用
坑 4:空值缓存时间过长
症状:
- 某数据刚创建,接口却持续查不到
原因:
之前缓存了空值,TTL 过长,导致新数据不能及时被读取。
建议:
- 空值 TTL 一般设短一些,比如 30~120 秒
- 对新增操作主动清理对应空值缓存
坑 5:热点 key 失效引发数据库毛刺
症状:
- 某个时间点数据库 QPS 突然飙升
- Redis 命中率瞬时下降
建议:
- 热点 key 加互斥锁或 single flight
- 做逻辑过期而非物理同时过期
- 对 TTL 加随机抖动
安全/性能最佳实践
1. 给 TTL 加随机值,避免雪崩
不要所有 key 都固定 5 分钟过期。
更好的做法:
long ttlSeconds = 300 + ThreadLocalRandom.current().nextInt(60);
这样可以把同一批 key 的失效时间打散。
2. 对不存在数据做缓存,但 TTL 要短
推荐策略:
- 空值缓存 TTL:30~120 秒
- 正常数据 TTL:几分钟到几十分钟
- 热点本地缓存 TTL:10~60 秒更常见
3. 不要把大对象整个塞进本地缓存
本地缓存虽然快,但它吃的是 JVM 堆内存。
如果对象很大或数量很多,会带来:
- Full GC 风险
- 内存占用不可控
- 实例间热点分布不均
建议:
- 只缓存高频小对象
- 控制
maximumSize - 监控命中率与淘汰率
4. 更新缓存优先删,不要轻易强写
很多人喜欢“更新 DB 后顺手 set 缓存”,但在并发场景下反而可能覆盖新值。
对于大部分业务,推荐:
- 更新 DB
- 删除缓存
- 由下次读请求懒加载重建
5. 热点数据考虑逻辑过期
对于极热 key,与其在失效时让用户请求一起去重建,不如:
- 缓存中同时保存业务值和逻辑过期时间
- 请求线程先返回旧值
- 后台线程异步刷新
这样牺牲一点实时性,换更稳定的吞吐。
6. Redis 不是越多 key 越好
缓存也有成本:
- 网络 IO
- 内存成本
- 序列化开销
- 运维复杂度
适合缓存的数据一般有这些特征:
- 读多写少
- 可容忍短暂不一致
- 查询代价高
- 热点明显
7. 补监控,不然优化很容易凭感觉
至少监控这些指标:
- 本地缓存命中率
- Redis 命中率
- DB 回源率
- 平均查询耗时
- 热点 key 排名
- 缓存删除失败次数
- 锁竞争次数
我自己排查缓存问题时,最怕的就是“没有命中率,没有回源率,只能猜”。
逐步验证清单
如果你准备把这套方案迁到项目里,可以按这个顺序推进:
阶段 1:先跑通功能
- 查询能命中本地缓存
- 本地未命中能命中 Redis
- Redis 未命中能回源 DB
- 更新后两级缓存能删除
阶段 2:补防护
- 空值缓存防穿透
- 热点 key 锁防击穿
- TTL 随机抖动防雪崩
阶段 3:补一致性
- 多实例本地缓存失效广播
- 关键更新链路做日志埋点
- 排查缓存删除失败重试策略
阶段 4:补可观测性
- Caffeine stats 指标输出
- Redis 命中率统计
- DB 回源量监控
- 慢查询和热点 key 排查面板
什么时候不建议上多级缓存
虽然本文在讲“怎么做”,但我也想说下“什么时候别急着做”。
以下情况,可以先别上复杂多级缓存:
- 数据实时一致性要求极高,不能容忍秒级旧数据
- 数据写入频繁,本地缓存失效成本很高
- 系统规模还小,单层 Redis 足够
- 团队缺少监控与排障能力,先上只会增加复杂度
简单永远比复杂更珍贵。
如果你当前只是单实例服务、读压力也不高,先用 Spring Cache + Redis 单层缓存就行,别一开始就堆满方案。
总结
这篇文章我们从实战角度搭了一套 Spring Boot + Spring Cache 思路 + Caffeine + Redis 的多级缓存方案,核心点可以收敛成下面几条:
- 读路径:本地缓存 -> Redis -> DB
- 写路径:先更新 DB,再删除缓存
- 防穿透:缓存空值,TTL 要短
- 防击穿:热点 key 加锁或逻辑过期
- 防雪崩:TTL 加随机抖动
- 一致性重点:多实例下要广播本地缓存失效
- 性能优化前提:一定要有命中率、回源率、耗时监控
如果你现在正在做一个中级复杂度的 Spring Boot 系统,我的建议很务实:
- 第一步:先落地单层 Redis + Cache Aside
- 第二步:热点场景再补本地缓存
- 第三步:多实例后补广播失效
- 第四步:再根据压测结果决定是否加逻辑过期、MQ、延迟双删
不要一上来就追求“最完整方案”,而是先做出 可运行、可解释、可排查 的缓存体系。这样真出问题时,你能知道问题在哪,而不是只能靠重启服务“碰运气”。