Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:提升接口性能与一致性治理
做接口性能优化时,很多人第一反应就是“上 Redis”。这当然没错,但真正到了线上,往往会发现:只有 Redis 还不够。
原因很直接:
- 单机内热点数据反复查 Redis,网络开销依然存在
- 高并发下缓存击穿、雪崩、穿透会一起冒出来
- 数据更新后,本地缓存和 Redis 的一致性治理比“加个
@Cacheable”复杂得多
这篇文章我不打算只停留在“能跑”的层面,而是带你做一个Spring Boot + Spring Cache + Redis + 本地缓存(Caffeine) 的多级缓存方案。目标很明确:
- 接口更快:优先命中 JVM 本地缓存
- 后端更稳:Redis 扛共享缓存,减数据库压力
- 可治理一致性:更新时尽量减少脏数据窗口
- 可运行、可验证、可排查
如果你已经会用 Spring Cache,那这篇会帮你把它从“注解层面”推进到“线上可用层面”。
一、背景与问题
先看一个很典型的查询接口:
- 根据商品 ID 查询商品详情
- 商品详情读多写少
- 峰值流量高
- 商品更新后,要求缓存不能长期脏读
如果只有数据库,问题显而易见:
- 每次都查 DB,响应时间高
- 热点商品会把数据库打穿
- 一旦数据库抖动,接口直接雪崩
如果只加 Redis:
- 跨机器共享没问题
- 但单实例高频热点仍然要走网络 I/O
- Redis 成了所有应用实例的共同依赖点
于是,多级缓存的思路就自然出现了:
- 一级缓存(L1):本地缓存,例如 Caffeine
- 二级缓存(L2):分布式缓存,例如 Redis
- 最终数据源:MySQL 等数据库
整体目标是:先查本地,再查 Redis,最后查 DB;更新时同步清理多级缓存。
二、前置知识与环境准备
1. 技术栈
本文示例使用:
- Spring Boot 2.7.x
- Spring Cache
- Redis
- Caffeine
- Maven
- JDK 8+
2. 依赖
<!-- pom.xml -->
<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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
三、核心原理
1. 多级缓存访问路径
最常见的查询路径如下:
flowchart TD
A[客户端请求] --> B[Spring Cache]
B --> C{本地缓存 Caffeine 命中?}
C -- 是 --> D[直接返回]
C -- 否 --> E{Redis 命中?}
E -- 是 --> F[写入本地缓存]
F --> D
E -- 否 --> G[查询数据库]
G --> H[写入 Redis]
H --> I[写入本地缓存]
I --> D
这个流程带来的收益很直接:
- 本地缓存命中:最快
- Redis 命中:共享缓存,避免打 DB
- DB 回源:最后兜底
2. Spring Cache 在这里扮演什么角色
Spring Cache 的优势不是“缓存能力最强”,而是统一抽象。
你可以通过:
@Cacheable:查缓存,没有则执行方法并回填@CachePut:执行方法后更新缓存@CacheEvict:删除缓存@Caching:组合多个缓存操作
把业务代码和缓存逻辑先解耦出来。
3. 为什么默认的 Spring Cache 不够做多级缓存
Spring 默认一般只绑定一个 CacheManager。
但多级缓存的关键是:
- 一个缓存名下,内部需要有两层存储
- 查询时要按 L1 → L2 → DB 的顺序
- 删除时要同时清理 L1 和 L2
- 写入时要同时回填 L1 和 L2
这就意味着,我们通常要自定义一个 Cache 实现,而不是只靠默认配置。
4. 一致性为什么难
缓存和数据库不是一个事务资源,所以天然存在窗口期。
比如更新商品时:
- 先更新 DB
- 再删 Redis
- 再删本地缓存
如果某个读请求刚好夹在中间,就可能读到旧值。
这也是我实际项目里最常见的争议点:缓存不是数据库,不要追求绝对强一致,要做“可接受的一致性治理”。
四、方案设计:一个能落地的多级缓存结构
我们先给出一个比较务实的方案:
- L1:Caffeine
- 容量小
- 过期时间短
- 面向单机热点加速
- L2:Redis
- 容量相对大
- 多实例共享
- 设置统一 TTL
- 更新策略
- 先更新数据库
- 再清理 Redis 和本地缓存
- 防脏读增强
- TTL 不同层级错开
- 重要业务可加消息通知清理本地缓存
- 热点 key 可加互斥回源
五、实战代码(可运行)
下面我们做一个完整示例:商品详情查询。
1. 配置文件
# application.yml
server:
port: 8080
spring:
cache:
type: none
datasource:
url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: create
show-sql: true
redis:
host: localhost
port: 6379
logging:
level:
org.springframework.cache: debug
这里把
spring.cache.type设成none,是因为我们要自己接管缓存实现。
2. 启动类
// DemoApplication.java
package com.example.multicache;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
@EnableCaching
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
CommandLineRunner init(ProductRepository productRepository) {
return args -> {
productRepository.save(new Product(1L, "机械键盘", 399.00));
productRepository.save(new Product(2L, "人体工学鼠标", 259.00));
};
}
}
3. 实体与仓库
// Product.java
package com.example.multicache;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.io.Serializable;
@Entity
public class Product implements Serializable {
@Id
private Long id;
private String name;
private Double price;
public Product() {
}
public Product(Long id, String name, Double price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Double getPrice() {
return price;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(Double price) {
this.price = price;
}
}
// ProductRepository.java
package com.example.multicache;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
4. 自定义多级缓存实现
这部分是核心。
4.1 MultiLevelCache
// MultiLevelCache.java
package com.example.multicache.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.concurrent.Callable;
public class MultiLevelCache implements Cache {
private final String name;
private final com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache;
private final RedisTemplate<Object, Object> redisTemplate;
private final Duration redisTtl;
public MultiLevelCache(String name,
com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache,
RedisTemplate<Object, Object> redisTemplate,
Duration redisTtl) {
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.redisTtl = redisTtl;
}
private String buildRedisKey(Object key) {
return this.name + "::" + key;
}
@Override
public String getName() {
return this.name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return new SimpleValueWrapper(localValue);
}
Object redisValue = redisTemplate.opsForValue().get(buildRedisKey(key));
if (redisValue != null) {
localCache.put(key, redisValue);
return new SimpleValueWrapper(redisValue);
}
return null;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Class<T> type) {
ValueWrapper wrapper = get(key);
if (wrapper == null) {
return null;
}
Object value = wrapper.get();
if (type != null && !type.isInstance(value)) {
throw new IllegalStateException("缓存值类型不匹配,期望: " + type + ", 实际: " + value.getClass());
}
return (T) value;
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
try {
T value = valueLoader.call();
if (value != null) {
put(key, value);
}
return value;
} catch (Exception e) {
throw new RuntimeException("加载缓存值失败", e);
}
}
@Override
public void put(Object key, Object value) {
localCache.put(key, value);
redisTemplate.opsForValue().set(buildRedisKey(key), value, redisTtl);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
ValueWrapper existing = get(key);
if (existing == null) {
put(key, value);
}
return existing;
}
@Override
public void evict(Object key) {
localCache.invalidate(key);
redisTemplate.delete(buildRedisKey(key));
}
@Override
public void clear() {
localCache.invalidateAll();
}
}
注意:
clear()这里只清了本地缓存,没有扫描 Redis 全删。线上如果要支持整库清理,通常要配合前缀设计、专门管理接口或者版本号机制,别轻易在 Redis 里keys *。
4.2 MultiLevelCacheManager
// MultiLevelCacheManager.java
package com.example.multicache.cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final RedisTemplate<Object, Object> redisTemplate;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisTemplate<Object, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, cacheName -> new MultiLevelCache(
cacheName,
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.build(),
redisTemplate,
Duration.ofMinutes(5)
));
}
@Override
public Collection<String> getCacheNames() {
return Collections.unmodifiableSet(cacheMap.keySet());
}
}
5. Redis 配置
// CacheConfig.java
package com.example.multicache.config;
import com.example.multicache.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
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.*;
@Configuration
public class CacheConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
GenericJackson2JsonRedisSerializer jacksonSerializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jacksonSerializer);
template.setHashValueSerializer(jacksonSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public CacheManager cacheManager(RedisTemplate<Object, Object> redisTemplate) {
return new MultiLevelCacheManager(redisTemplate);
}
}
6. Service 层:用 Spring Cache 注解接入
// ProductService.java
package com.example.multicache;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
simulateSlowQuery();
return productRepository.findById(id).orElse(null);
}
@Transactional
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
Product dbProduct = productRepository.findById(product.getId())
.orElseThrow(() -> new IllegalArgumentException("商品不存在"));
dbProduct.setName(product.getName());
dbProduct.setPrice(product.getPrice());
return productRepository.save(dbProduct);
}
private void simulateSlowQuery() {
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
}
}
这里故意
sleep 500ms,方便你在本地明显看出第一次查询和后续查询的差异。
7. Controller
// ProductController.java
package com.example.multicache;
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 get(@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. 启动 Redis
redis-server
或者使用 Docker:
docker run -p 6379:6379 --name myredis -d redis:6.2
2. 启动应用
mvn spring-boot:run
3. 首次查询,预期慢
curl http://localhost:8080/products/1
第一次会有明显延迟,因为走了 DB。
4. 再查一次,预期快
curl http://localhost:8080/products/1
这次应该明显更快,优先命中本地缓存。
5. 查看 Redis 中是否有值
redis-cli
keys *
get "product::1"
如果值是 JSON 或二进制序列化后的内容,属于正常现象。
6. 更新商品后再次查询
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"机械键盘Pro","price":499.0}'
然后重新查询:
curl http://localhost:8080/products/1
预期结果:
- 更新时缓存被清理
- 下次查询重新回源 DB
- 再重新写入 Redis 和本地缓存
七、一致性治理:不是删缓存这么简单
如果你的系统只有单实例,上面的 @CacheEvict 已经够用了。
但只要变成多实例部署,就会出现一个经典问题:
- A 实例更新数据并清掉自己本地缓存和 Redis
- B 实例的本地缓存还保留旧值
- 用户请求打到 B,读到旧数据
这就是本地缓存一致性问题。
1. 多实例更新传播流程
sequenceDiagram
participant C as 客户端
participant A as 应用实例A
participant B as 应用实例B
participant R as Redis
participant DB as 数据库
C->>A: 更新商品
A->>DB: update product
A->>R: delete product::id
A->>A: clear local cache
Note over B: B 的本地缓存仍可能是旧值
C->>B: 查询商品
B->>B: 命中旧本地缓存
B-->>C: 返回旧数据
2. 解决思路
常见方法有三种:
方法一:缩短本地缓存 TTL
最简单,成本最低。
- 本地缓存 10~30 秒
- Redis 缓存 5~10 分钟
优点:
- 实现简单
- 不引入新组件
缺点:
- 存在短时间脏读窗口
这是很多中型业务实际采用的方式。
方法二:Redis Pub/Sub 广播本地缓存失效
更新数据时:
- 删 Redis
- 发布失效消息
- 所有应用实例监听消息并删除本地缓存
流程如下:
flowchart LR
A[实例A更新DB] --> B[删除Redis缓存]
B --> C[发布失效消息]
C --> D[实例A清理本地缓存]
C --> E[实例B清理本地缓存]
C --> F[实例C清理本地缓存]
这种方式在多实例下非常实用,延迟低,改造成本也不算高。
方法三:更新版本号或逻辑时间戳
适合对一致性要求更高的业务。
缓存值中带版本号,请求读取时校验版本,旧版本数据拒绝返回或强制回源。
优点:
- 一致性更强
缺点:
- 设计复杂
- 读写链路都要改
八、常见坑与排查
这部分我尽量说得“接地气”一点,因为这些坑真的太常见了。
1. @Cacheable 不生效
典型原因
- 没加
@EnableCaching - 方法是
private - 同类内部自调用
- 异常导致方法根本没执行完
排查方式
先确认:
@EnableCaching
然后注意这种代码:
public Product test(Long id) {
return this.getById(id); // 同类内部调用,可能绕过代理
}
Spring Cache 依赖 AOP 代理,同类自调用经常让人误以为“缓存失效了”。
建议:把带缓存的方法拆到独立 Bean 里。
2. Redis 里有值,但每次都走数据库
可能原因
- key 不一致
- 序列化反序列化失败
unless = "#result == null"条件导致未缓存- 查询返回对象类型对不上
排查方式
打开日志,打印缓存 key:
private String buildRedisKey(Object key) {
String redisKey = this.name + "::" + key;
System.out.println("redis key = " + redisKey);
return redisKey;
}
再去 Redis 里看是否一致。
3. 更新后读到旧值
典型原因
- 多实例本地缓存未同步失效
- 先删缓存再更新 DB,导致并发下旧值回写
- TTL 太长
这里尤其要提醒:
不要在高并发更新场景里轻易使用“先删缓存,再更新数据库”。
更稳妥的通常是:
- 更新数据库
- 删除缓存
这是缓存一致性里最经典的一条实践。
4. 缓存穿透
比如查一个根本不存在的商品 ID,每次都会打 DB。
解决办法
缓存空值,但 TTL 要短一些:
@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
return productRepository.findById(id).orElse(null);
}
但如果要缓存 null,需要你的缓存实现支持 NullValue,本文示例里为了简单,用了 unless = "#result == null",所以默认没有缓存空值。
线上如果存在大量无效 ID 探测,建议:
- 增加布隆过滤器
- 或者缓存空对象占位符
5. 缓存击穿
热点 key 过期瞬间,大量请求同时回源 DB。
解决办法
- 热点数据不过期,靠主动失效
- 使用互斥锁 / single-flight
- 给过期时间加随机值,避免同一时刻集中失效
我自己更推荐:热点 key 互斥回源 + 普通 key 随机 TTL。
6. 序列化问题
最容易出现的报错是:
- 类型转换异常
- 类结构变更后旧缓存反序列化失败
- 泛型对象反序列化成
LinkedHashMap
建议
- 缓存 DTO,不要直接缓存复杂领域对象
- 谨慎变更缓存对象结构
- 大版本升级时清理历史缓存
九、安全/性能最佳实践
这部分是上线前最好过一遍的清单。
1. TTL 分层设计
建议不要让本地缓存和 Redis 用同样的 TTL。
一个常用组合:
- 本地缓存:30 秒
- Redis:5 分钟
原因:
- 本地缓存主要解决热点访问
- Redis 主要解决共享数据和数据库减压
- 分层 TTL 能降低同时失效风险
2. TTL 加随机值,防雪崩
例如 Redis TTL 不是固定 300 秒,而是:
300 + Random(0~60)
这样不会在某一秒所有 key 一起过期。
3. 热点 key 做互斥回源
如果某个商品接口是超级热点,缓存失效时最好只允许一个线程回源数据库,其它线程等待或返回旧值。
可以基于 Redis 分布式锁,或者本机 ConcurrentHashMap + synchronized 做轻量控制。
4. 不要滥用本地缓存容量
Caffeine 很快,但 JVM 堆不是无限的。
建议:
- 只缓存高频、体积适中的对象
- 设置
maximumSize - 监控 Full GC 和老年代占用
5. 缓存 key 规范化
建议统一 key 结构:
业务名:实体名:主键
例如:
mall:product:1
本文为了和 Spring Cache 习惯保持一致,用了:
product::1
线上更推荐显式命名,便于排查。
6. 不要缓存敏感数据明文
尤其是:
- 用户令牌
- 身份信息
- 权限数据
- 支付相关信息
如果必须缓存:
- 控制 TTL
- 做脱敏
- Redis 开启认证与网络隔离
- 限制运维和开发访问权限
7. 监控比“加缓存”更重要
至少监控这些指标:
- 本地缓存命中率
- Redis 命中率
- DB 回源次数
- key 数量与内存占用
- 慢查询接口耗时
- 缓存失效次数
如果没有监控,你很难知道“缓存到底是在帮忙,还是在制造幻觉”。
十、一个更完整的演进思路
如果你的系统继续增长,建议按下面阶段演进,而不是一上来就堆复杂度。
stateDiagram-v2
[*] --> 单级Redis缓存
单级Redis缓存 --> 本地+Redis多级缓存
本地+Redis多级缓存 --> 广播失效治理
广播失效治理 --> 热点互斥回源
热点互斥回源 --> 版本号/逻辑时钟一致性
我对这条演进路径的建议是:
- 小系统:先用 Redis 单级缓存
- 中等流量:加 Caffeine 做多级缓存
- 多实例且对一致性敏感:加失效广播
- 极热点业务:再做互斥回源和热点永不过期
别反过来。
很多团队是业务还没起来,缓存方案已经复杂到没人敢维护了。
十一、边界条件:什么场景不适合多级缓存
多级缓存并不是银弹,以下场景要谨慎:
1. 写多读少
如果数据更新非常频繁,本地缓存可能刚写进去就要失效,收益很低。
2. 强一致要求极高
例如账户余额、库存强扣减这类业务,缓存通常只能做旁路辅助,不能作为核心读来源。
3. 对象非常大
超大对象缓存到 JVM 本地,容易带来 GC 压力,得不偿失。
4. key 数量巨大且长尾明显
如果访问很分散,本地缓存命中率不高,维护成本可能超过收益。
十二、总结
这篇文章我们从一个很实际的问题出发:只用 Redis 不一定够,多级缓存才更适合高频读接口的性能优化。
你可以把核心结论记成这几条:
- 多级缓存基本结构:Caffeine + Redis + DB
- 查询链路:本地缓存 → Redis → 数据库
- 更新策略:先更新 DB,再删除缓存
- 一致性治理重点:多实例下要考虑本地缓存失效同步
- 性能优化关键:TTL 分层、随机过期、热点互斥回源
- 上线前必做:监控命中率、内存占用、回源次数
如果你现在就要落地,我建议按这个顺序来:
- 第一步:先把本文的多级缓存跑起来
- 第二步:观察本地命中率和 Redis 命中率
- 第三步:如果是多实例,再补 Redis Pub/Sub 的本地失效广播
- 第四步:如果有热点 key,再做互斥回源
这样做,复杂度是可控的,收益也是一步步可验证的。
最后说一句经验之谈:
缓存优化的真正价值,不是把响应时间从 20ms 压到 5ms,而是让系统在流量上来时还能稳住。
这也是多级缓存最值得投入的地方。