Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:从热点数据防穿透到一致性治理
在 Spring Boot 项目里,Spring Cache + Redis 是很多团队的默认组合:上手快、注解简单、接入成本低。
但真正一上生产,问题往往不是“怎么把数据放进 Redis”,而是:
- 热点数据突然被打爆,数据库扛不住
- 缓存里有,应用本地也有,但两层不一致
- 缓存击穿、穿透、雪崩一起来
- 更新数据库后,缓存什么时候删、删几层、谁先删
- 线上排查时发现:命中了缓存,但命中的不是你以为的那一层
这篇文章我不打算只讲注解怎么写,而是带你从一个可运行的 Spring Boot 示例出发,把“本地缓存 + Redis 二级缓存 + 数据库回源 + 一致性治理”的实战链路完整走一遍。
背景与问题
先看一个典型场景:商品详情页。
- 某个商品是热点商品,QPS 很高
- 读请求远大于写请求
- 商品标题、价格、库存、上下架状态经常被读取
- 运营偶尔会改价、改文案、上下架
如果你只有 Redis 这一层缓存,虽然能扛住大部分请求,但每次读取还是要走网络 IO。
当热点非常集中时,应用实例本地如果没有缓存能力,Redis 本身也会成为瓶颈点。
所以很多系统会做成多级缓存:
- L1:应用内本地缓存,例如 Caffeine
- L2:分布式缓存,例如 Redis
- L3:数据库
读取时优先命中 L1,再查 L2,最后落 DB。
写入时更新 DB,并清理或刷新多级缓存。
但多级缓存的难点恰恰在于:快是快了,一致性怎么保证?
典型问题清单
1. 缓存穿透
查一个根本不存在的商品 ID,缓存没有,Redis 没有,数据库也没有。
如果有人恶意刷大量不存在的 ID,请求会直接穿透到数据库。
2. 缓存击穿
某个热点 Key 恰好过期,瞬间大量请求同时回源数据库。
3. 缓存雪崩
大量 Key 在同一时间失效,导致数据库压力陡增。
4. 多级缓存不一致
数据库更新了,Redis 删了,但本地缓存还留着旧值。
这个坑我自己就踩过:明明 Redis 已经是新数据,接口还是返回旧值,最后发现是应用本地缓存没失效。
前置知识与环境准备
本文示例基于以下技术栈:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- 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{L1 本地缓存 Caffeine 命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{L2 Redis 命中?}
D -- 是 --> E[写入 L1 后返回]
D -- 否 --> F{获取互斥锁成功?}
F -- 否 --> G[短暂等待后重试 Redis/L1]
F -- 是 --> H[查询数据库]
H --> I{商品存在?}
I -- 否 --> J[写入空值缓存 防穿透]
I -- 是 --> K[写入 Redis]
K --> L[写入 L1]
J --> C
L --> C
这套机制里,有几个关键点:
1. L1 + L2 的职责划分
L1 本地缓存
优点:
- 访问速度最快
- 减少 Redis 网络开销
- 热点 Key 命中率高时收益明显
缺点:
- 天然是实例级别
- 多节点之间不会自动同步
- 更容易产生脏读
L2 Redis
优点:
- 所有实例共享
- 易于统一过期时间管理
- 能支撑大部分分布式缓存需求
缺点:
- 仍然有网络成本
- 失效时会产生集中回源风险
2. Spring Cache 的角色
Spring Cache 更像一层缓存抽象,它帮你统一了:
@Cacheable:查缓存,没有则执行方法并写缓存@CachePut:执行方法,并更新缓存@CacheEvict:删除缓存
但要注意:
Spring Cache 解决的是“缓存接入便利性”,不是“复杂一致性策略自动化”。
比如多级缓存同步、热点 Key 互斥重建、空值缓存策略、广播清理,这些往往还需要你自己补上。
3. 一致性治理思路
在读多写少的场景,我更推荐这种策略:
- 读路径:L1 -> L2 -> DB
- 写路径:先更新 DB,再删除 L2,再删除 L1
- 必要时延迟双删
- 跨实例通过消息或订阅广播删除 L1
看起来是“删缓存”而不是“更新缓存”,原因很简单:
- 更新缓存要考虑多个层级、多个节点
- 一旦更新中间失败,状态会更复杂
- 删除缓存更稳,后续由读请求自然重建
时序图:读取与更新
sequenceDiagram
participant C as Client
participant A as App
participant L1 as Caffeine
participant R as Redis
participant DB as Database
C->>A: GET /products/1
A->>L1: get(1)
alt L1 hit
L1-->>A: 商品数据
A-->>C: 返回
else L1 miss
A->>R: get(product:1)
alt Redis hit
R-->>A: 商品数据
A->>L1: put(1,data)
A-->>C: 返回
else Redis miss
A->>DB: select * from product where id=1
DB-->>A: 商品数据/空
A->>R: setex product:1
A->>L1: put(1,data)
A-->>C: 返回
end
end
C->>A: PUT /products/1
A->>DB: update product
A->>R: delete product:1
A->>L1: invalidate(1)
A-->>C: 更新成功
实战代码(可运行)
下面给出一个最小可运行版本。
为了突出多级缓存核心逻辑,我这里用“内存 Map 模拟数据库”。你接到真实项目里,只需要把 Repository 换成 JPA/MyBatis 即可。
1. application.yml
server:
port: 8080
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: none
这里把 Spring 默认 CacheManager 先关闭,原因是我们要自己实现一个更可控的多级缓存服务。
2. 启动类
package com.example.multicache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MultiCacheApplication {
public static void main(String[] args) {
SpringApplication.run(MultiCacheApplication.class, args);
}
}
3. 实体类 Product
package com.example.multicache.model;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
public Product() {
}
public Product(Long id, String name, BigDecimal price, Integer stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
4. 模拟数据库 Repository
package com.example.multicache.repository;
import com.example.multicache.model.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> db = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
db.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 100));
db.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00"), 50));
}
public Product findById(Long id) {
sleep(100); // 模拟数据库查询耗时
return db.get(id);
}
public Product update(Product product) {
sleep(50); // 模拟数据库更新耗时
db.put(product.getId(), product);
return product;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
}
}
}
5. Redis 配置
package com.example.multicache.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
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.*;
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
@Bean
public org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory factory) {
org.springframework.data.redis.core.RedisTemplate<String, Object> template =
new org.springframework.data.redis.core.RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(mapper, Object.class);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
实际生产里,Jackson 默认类型信息配置要更谨慎,后面我会在“安全最佳实践”里再讲。
6. Caffeine 本地缓存配置
package com.example.multicache.config;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
public class LocalCacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.recordStats()
.build();
}
}
7. 多级缓存服务
这是整篇文章最关键的部分。
package com.example.multicache.service;
import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class ProductService {
private static final String PRODUCT_CACHE_KEY = "product:";
private static final String LOCK_KEY = "lock:product:";
private static final String NULL_VALUE = "NULL";
private final Cache<String, Object> localCache;
private final RedisTemplate<String, Object> redisTemplate;
private final ProductRepository productRepository;
public ProductService(Cache<String, Object> localCache,
RedisTemplate<String, Object> redisTemplate,
ProductRepository productRepository) {
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.productRepository = productRepository;
}
public Product getProductById(Long id) {
String cacheKey = PRODUCT_CACHE_KEY + id;
// 1. 查本地缓存
Object localValue = localCache.getIfPresent(cacheKey);
if (localValue != null) {
if (NULL_VALUE.equals(localValue)) {
return null;
}
return (Product) localValue;
}
// 2. 查 Redis
Object redisValue = redisTemplate.opsForValue().get(cacheKey);
if (redisValue != null) {
localCache.put(cacheKey, redisValue);
if (NULL_VALUE.equals(redisValue)) {
return null;
}
return (Product) redisValue;
}
// 3. 互斥锁,防止缓存击穿
String lockKey = LOCK_KEY + id;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
// 双检,防止拿到锁前别人已经构建好缓存
Object again = redisTemplate.opsForValue().get(cacheKey);
if (again != null) {
localCache.put(cacheKey, again);
if (NULL_VALUE.equals(again)) {
return null;
}
return (Product) again;
}
Product product = productRepository.findById(id);
if (product == null) {
// 防穿透:缓存空值,过期时间要短
redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, 60, TimeUnit.SECONDS);
localCache.put(cacheKey, NULL_VALUE);
return null;
}
// 防雪崩:TTL 可加随机值,这里简化为固定值
redisTemplate.opsForValue().set(cacheKey, product, 10, TimeUnit.MINUTES);
localCache.put(cacheKey, product);
return product;
} finally {
Object currentLockValue = redisTemplate.opsForValue().get(lockKey);
if (Objects.equals(lockValue, currentLockValue)) {
redisTemplate.delete(lockKey);
}
}
}
// 4. 没拿到锁,短暂等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
Object retry = redisTemplate.opsForValue().get(cacheKey);
if (retry != null) {
localCache.put(cacheKey, retry);
if (NULL_VALUE.equals(retry)) {
return null;
}
return (Product) retry;
}
// 最后兜底回源,避免极端情况下请求失败
return productRepository.findById(id);
}
public Product updateProduct(Product product) {
Product updated = productRepository.update(product);
String cacheKey = PRODUCT_CACHE_KEY + product.getId();
// 先删 Redis,再删本地缓存
redisTemplate.delete(cacheKey);
localCache.invalidate(cacheKey);
// 可选:延迟双删
new Thread(() -> {
try {
Thread.sleep(200);
redisTemplate.delete(cacheKey);
localCache.invalidate(cacheKey);
} catch (InterruptedException ignored) {
}
}).start();
return updated;
}
}
8. Controller
package com.example.multicache.controller;
import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.Valid;
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.getProductById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody @Valid Product product) {
product.setId(id);
return productService.updateProduct(product);
}
}
逐步验证清单
建议你按这个顺序验证,比较容易理解整个链路。
1. 首次查询,观察 DB 回源
请求:
curl http://localhost:8080/products/1
预期:
- 本地缓存 miss
- Redis miss
- 查询 DB
- 写 Redis
- 写本地缓存
2. 再次查询,观察本地缓存命中
再次执行:
curl http://localhost:8080/products/1
预期:
- 直接命中本地缓存
- 接口响应明显更快
3. 查询不存在的 ID,验证空值缓存
curl http://localhost:8080/products/999
预期:
- 首次查询落 DB,返回空
- Redis 写入
NULL - 短时间内重复请求不再打 DB
4. 更新商品,验证缓存失效
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"机械键盘Pro","price":399.00,"stock":88}'
然后再次查询:
curl http://localhost:8080/products/1
预期:
- 更新后缓存被删除
- 再查时重新构建
- 返回新值
用 Spring Cache 注解怎么接?
上面的代码更适合你理解原理。
如果你想在项目里继续保留 Spring Cache 的开发体验,可以把它用在单层缓存场景,或者作为某一层的统一抽象。
例如最基础的 Redis 缓存写法:
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product findProduct(Long id) {
return productRepository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.update(product);
}
但要注意两件事:
- 多级缓存不是写几个
@Cacheable就完事 - 跨实例 L1 失效同步,Spring Cache 默认不帮你处理
所以我的建议是:
- 简单系统:先用 Spring Cache + Redis
- 热点明显、性能敏感系统:核心链路手写多级缓存逻辑
- 需要统一治理:对外暴露一层缓存组件,业务方只调组件接口
多级缓存一致性治理策略
这部分是实战里最容易“差一点点就出事故”的地方。
1. 为什么推荐“更新 DB,删除缓存”
先更新数据库,再删除缓存,是更稳妥的基本策略。
stateDiagram-v2
[*] --> ReadOld
ReadOld --> UpdateDB: 写请求到来
UpdateDB --> DeleteRedis
DeleteRedis --> DeleteLocal
DeleteLocal --> RebuildByRead
RebuildByRead --> [*]
如果你反过来先删缓存再更新 DB,会出现一个窗口期:
- A 线程删了缓存
- B 线程读请求进来,发现没有缓存,去 DB 读到旧值并回填缓存
- A 线程这时才更新 DB
- 缓存里就重新出现旧值了
这就是经典并发脏数据问题。
2. 延迟双删是否必须?
不是必须,但在以下场景有价值:
- 并发读写频繁
- 数据库主从延迟明显
- 删除缓存后可能马上被旧读回填
常见做法:
- 更新 DB
- 删除缓存
- 休眠几十到几百毫秒
- 再删一次缓存
但要说实话,延迟双删不是银弹。
如果你的核心诉求是强一致,那缓存本身就不适合作为权威数据源。
3. 多实例本地缓存怎么同步失效?
单机版好做,多机版才是真问题。
常见方案:
- Redis Pub/Sub 广播失效消息
- MQ 广播缓存删除事件
- 统一版本号控制
- 直接缩短 L1 TTL
我的经验是:
- 读多写少:L1 TTL 设短一点,简单有效
- 写较频繁:配合消息广播清理 L1
- 强实时性要求高:慎用本地缓存,或者只缓存极稳定字段
常见坑与排查
这一节我尽量写得接地气一点,因为这些问题,真的是上线后最常见的。
1. @Cacheable 不生效
现象
方法明明加了 @Cacheable,但每次还是会执行。
原因
很多时候是同类内部调用导致的。
Spring Cache 基于 AOP 代理,如果你在同一个类里直接 this.xxx() 调用,代理绕过去了。
处理
把缓存方法拆到另一个 Bean,或者通过代理对象调用。
2. 本地缓存已经删了,还是读到旧数据
可能原因
- 实际删的是 Redis,没删本地缓存
- 多实例场景下只删了当前节点的本地缓存
- 某个线程在删除后又把旧值回填了
排查建议
- 打印缓存 key、节点实例标识、缓存层级命中日志
- 明确区分:L1 hit / L2 hit / DB hit
- 更新链路和回填链路都加 traceId
3. Redis 里出现奇怪的序列化内容
原因
JDK 序列化、Jackson 序列化、String 序列化混用。
建议
全项目统一 RedisTemplate 的序列化策略,不要一个地方存字符串,一个地方存对象,最后自己都看不懂。
4. 热点 Key 到期瞬间,数据库被打满
原因
TTL 统一、热点 key 过期同时回源。
处理
- 热点 Key 永不过期,改为异步刷新
- 加互斥锁
- TTL 加随机值
- 对超热点数据提前预热
5. 空值缓存导致“数据后来新增了但查不到”
现象
某个 ID 之前不存在,被缓存了 NULL,后来数据库插入了这条记录,但短时间内仍查不到。
处理
- 空值缓存 TTL 设短
- 新增数据时主动删除该 Key
- 不要把空值 TTL 设得和正常数据一样长
安全/性能最佳实践
安全方面
1. 不要盲目开启 Jackson 默认类型
如果 Redis 数据来源不完全可信,activateDefaultTyping 要谨慎。
生产上更稳妥的做法是:
- 指定具体类型序列化
- 使用白名单类型
- 对外部输入参与构造缓存对象时做好校验
2. 缓存 Key 不要拼接敏感信息
例如手机号、身份证号、token,尽量不要裸拼进 key。
必要时做 hash 处理。
3. 控制缓存穿透攻击面
- 参数校验先拦截非法 ID
- 对不存在对象缓存空值
- 更严格场景可配合布隆过滤器
性能方面
1. TTL 加随机值,避免雪崩
例如:
- 正常 TTL:10 分钟
- 随机扩展:
0~120 秒
这样可以打散失效时间。
2. 本地缓存容量不要瞎配
太小,命中率低;
太大,GC 压力和内存占用又会上来。
建议你基于这些指标观察:
- hit rate
- eviction count
- average load penalty
3. 热点数据优先本地缓存
适合:
- 商品详情
- 配置字典
- 用户权限快照
- 只读元数据
不太适合:
- 强一致库存
- 高频变化余额
- 强事务依赖状态
4. 删除缓存优先于更新缓存
尤其在多级缓存、多节点场景,删缓存更容易收敛问题。
5. 给缓存链路打指标
至少要有:
- L1 命中率
- L2 命中率
- DB 回源次数
- Key 重建耗时
- 锁等待次数
- 空值缓存命中次数
没有监控的多级缓存,线上基本靠猜。
方案边界与取舍
不是所有系统都应该上多级缓存。
适合
- 读多写少
- 热点集中
- 对毫秒级性能敏感
- 允许短暂最终一致
不适合
- 强一致要求极高
- 写入非常频繁
- 数据实时变化快
- 应用节点非常多但缺少统一失效机制
如果你的业务是“库存扣减”“账户余额”“支付状态最终确认”,多级缓存一定要谨慎,很多场景甚至不该放缓存。
一个更实用的落地建议
如果你准备把本文方案搬进生产,我建议分三步走:
第一步:先做单层 Redis 缓存
目标是把缓存 Key、TTL、序列化、失效策略统一起来。
第二步:只给热点接口加 L1 本地缓存
不要全量上本地缓存,先挑收益最大的接口。
第三步:补一致性治理
至少补上:
- 更新后删除两层缓存
- 热点 Key 互斥重建
- 空值缓存
- TTL 随机化
- 命中率和回源监控
这样推进,风险最小,也最容易看见收益。
总结
Spring Boot 里做 Spring Cache + Redis 很容易,
但要把它做成真正抗打的多级缓存,重点不在“会不会加注解”,而在下面这几个点:
- 明确层次职责:L1 本地缓存负责极致性能,L2 Redis 负责共享数据
- 处理三大问题:穿透、击穿、雪崩都要有方案
- 坚持删除缓存思路:更新 DB 后删缓存,比更新缓存更稳
- 多实例要考虑 L1 失效同步
- 必须有监控和日志:否则出了问题很难定位
如果你现在正在做一个读多写少、热点明显的系统,这套方案是很值得落地的。
但请记住它的边界:多级缓存换来的是性能,不是强一致。
最后给一个实操建议,适合大多数中级开发者直接执行:
- 先用 Redis 跑通
- 再给最热点接口加 Caffeine
- 更新路径统一“更新 DB + 删除两层缓存”
- 空值缓存 TTL 设短
- 热点 Key 加锁重建
- 用监控验证命中率,而不是凭感觉优化
这样做,基本就能从“能用缓存”,走到“缓存用得稳”。