Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:提升接口性能与一致性控制
很多业务接口一开始“跑得还行”,但一到流量上来,就会出现两个很典型的问题:
- 数据库被热点请求打穿
- 同一个数据被反复查询,接口 RT 抖动明显
如果你只用本地缓存,单机很快,但一到集群环境就容易出现节点间数据不一致;如果你只用 Redis,虽然共享没问题,但每次都要走网络,热点接口在高并发下依然会有额外开销。
这时候,多级缓存就很适合:本地缓存负责“快”,Redis 负责“共享”,数据库负责“准”。
这篇文章我会带你从零搭一个可运行的方案,基于:
- Spring Boot
- Spring Cache
- Caffeine(一级本地缓存)
- Redis(二级分布式缓存)
重点不只是“怎么配”,还包括:
- 如何设计缓存读取链路
- 如何做更新后的缓存一致性控制
- 如何避免缓存穿透、击穿、雪崩
- 如何排查“明明删了缓存却还是读到旧值”这种烦人问题
一、背景与问题
先看一个很常见的场景:商品详情接口 /products/{id}。
- 商品基础信息变化频率低
- 读取频率高
- 数据库查询涉及多表 join 或复杂组装
- 同一个商品会被频繁访问
如果没有缓存,调用链是这样的:
flowchart LR
A[客户端请求] --> B[应用服务]
B --> C[数据库]
C --> B
B --> A
问题在于:
- 热点数据会把数据库压住
- 每次都查库,接口性能上不去
- 数据库连接池容易耗尽
如果只上 Redis:
flowchart LR
A[客户端请求] --> B[应用服务]
B --> C[Redis]
C -->|未命中| D[数据库]
D --> C
C --> B
B --> A
这已经不错了,但还不够极致。因为:
- Redis 访问仍然是网络 IO
- 超高频热点数据适合优先在 JVM 内存里命中
- 同一节点上的重复请求,其实没必要每次都走 Redis
所以更理想的方案是:
flowchart LR
A[客户端请求] --> B[应用服务]
B --> C[Caffeine 本地缓存]
C -->|未命中| D[Redis]
D -->|未命中| E[数据库]
E --> D
D --> C
C --> B
B --> A
这就是典型的多级缓存。
二、前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Spring Data Redis
- Caffeine
- Maven
- Redis 6+
1. 本文目标
实现一个商品查询/更新接口:
- 查询时:先查本地缓存,再查 Redis,最后查数据库
- 更新时:更新数据库后,删除两级缓存
- 结合 Spring Cache 简化业务代码
- 支持基本的 TTL 与序列化配置
2. 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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
三、核心原理
1. Spring Cache 在这里扮演什么角色?
Spring Cache 不是缓存实现本身,它更像一个统一的缓存抽象层。你可以通过注解:
@Cacheable@CachePut@CacheEvict
把缓存逻辑从业务代码里抽出来。
但 Spring Cache 默认更适合“单一缓存后端”。要做多级缓存,我们通常会:
- 自定义
CacheManager - 或封装一个组合型
Cache
这篇文章采用第二种方式:自定义 MultiLevelCache。
2. 多级缓存的读取流程
读取顺序一般是:
- 查本地缓存 Caffeine
- 未命中再查 Redis
- Redis 命中后回填本地缓存
- Redis 也未命中,再查数据库
- 查库成功后写入 Redis 和本地缓存
用时序图更直观:
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant L1 as Caffeine
participant L2 as Redis
participant DB as MySQL/H2
Client->>App: 查询商品(id)
App->>L1: get(id)
alt L1 命中
L1-->>App: 返回数据
else L1 未命中
App->>L2: get(id)
alt L2 命中
L2-->>App: 返回数据
App->>L1: put(id, value)
else L2 未命中
App->>DB: select by id
DB-->>App: 返回数据
App->>L2: put(id, value)
App->>L1: put(id, value)
end
end
App-->>Client: 返回商品信息
3. 一致性控制的基本思路
最常见的更新策略不是“先更新缓存”,而是:
先更新数据库,再删除缓存
原因很简单:缓存是派生数据,DB 才是数据源。
推荐流程:
- 更新数据库
- 删除 Redis 缓存
- 删除本地缓存
为什么不是反过来?因为集群下本地缓存不止一个节点,通常还需要配合缓存失效通知,否则其他机器仍会读到旧值。
这篇文章先实现“单节点内两级缓存一致删除”,并在最佳实践部分讲扩展到集群的方法。
四、项目结构设计
示例结构如下:
src/main/java/com/example/cache
├── CacheDemoApplication.java
├── config
│ ├── CacheConfig.java
│ └── RedisConfig.java
├── controller
│ └── ProductController.java
├── entity
│ └── Product.java
├── repository
│ └── ProductRepository.java
├── service
│ └── ProductService.java
└── cache
├── MultiLevelCache.java
└── MultiLevelCacheManager.java
五、实战代码(可运行)
1. 实体与仓库
Product.java
package com.example.cache.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.io.Serializable;
import java.math.BigDecimal;
@Entity
public class Product implements Serializable {
@Id
private Long id;
private String name;
private BigDecimal price;
public Product() {
}
public Product(Long id, String name, BigDecimal price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
}
ProductRepository.java
package com.example.cache.repository;
import com.example.cache.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
2. Redis 配置
RedisConfig.java
这里重点是序列化,别用 JDK 默认序列化。我早期项目里吃过这个亏:Redis 里全是不可读二进制,排查很痛苦,跨服务兼容性也差。
package com.example.cache.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL
);
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;
}
}
3. 多级缓存实现
MultiLevelCache.java
package com.example.cache.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.Cache.ValueWrapper;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.concurrent.Callable;
public class MultiLevelCache implements org.springframework.cache.Cache {
private final String name;
private final Cache<Object, Object> caffeineCache;
private final RedisTemplate<Object, Object> redisTemplate;
private final Duration ttl;
public MultiLevelCache(String name,
Cache<Object, Object> caffeineCache,
RedisTemplate<Object, Object> redisTemplate,
Duration ttl) {
this.name = name;
this.caffeineCache = caffeineCache;
this.redisTemplate = redisTemplate;
this.ttl = ttl;
}
private String buildKey(Object key) {
return name + "::" + key;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
Object value = caffeineCache.getIfPresent(key);
if (value != null) {
return () -> value;
}
value = redisTemplate.opsForValue().get(buildKey(key));
if (value != null) {
caffeineCache.put(key, value);
Object finalValue = value;
return () -> finalValue;
}
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("缓存数据类型不匹配");
}
return (T) value;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Callable<T> valueLoader) {
Object value = caffeineCache.getIfPresent(key);
if (value != null) {
return (T) value;
}
value = redisTemplate.opsForValue().get(buildKey(key));
if (value != null) {
caffeineCache.put(key, value);
return (T) value;
}
try {
T loaded = valueLoader.call();
if (loaded != null) {
put(key, loaded);
}
return loaded;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
@Override
public void put(Object key, Object value) {
caffeineCache.put(key, value);
redisTemplate.opsForValue().set(buildKey(key), value, ttl);
}
@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) {
caffeineCache.invalidate(key);
redisTemplate.delete(buildKey(key));
}
@Override
public void clear() {
caffeineCache.invalidateAll();
}
}
MultiLevelCacheManager.java
package com.example.cache.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.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final RedisTemplate<Object, Object> redisTemplate;
private final Duration ttl;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisTemplate<Object, Object> redisTemplate, Duration ttl) {
this.redisTemplate = redisTemplate;
this.ttl = ttl;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, cacheName -> new MultiLevelCache(
cacheName,
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.build(),
redisTemplate,
ttl
));
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
}
4. CacheManager 注册
CacheConfig.java
package com.example.cache.config;
import com.example.cache.cache.MultiLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisTemplate<Object, Object> redisTemplate) {
return new MultiLevelCacheManager(redisTemplate, Duration.ofMinutes(5));
}
}
5. Service 层:缓存读写与更新
ProductService.java
package com.example.cache.service;
import com.example.cache.entity.Product;
import com.example.cache.repository.ProductRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@PostConstruct
public void init() {
if (productRepository.count() == 0) {
productRepository.save(new Product(1L, "iPhone", new BigDecimal("6999")));
productRepository.save(new Product(2L, "MacBook", new BigDecimal("12999")));
}
}
@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
simulateSlowQuery();
return productRepository.findById(id).orElse(null);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
return productRepository.save(product);
}
private void simulateSlowQuery() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
这里用了
@CacheEvict,意思是更新数据库成功后,把缓存删掉。下次查询再重新加载。对大多数读多写少场景,这比
@CachePut更稳,因为你不会把“半旧半新”的数据直接推入缓存链路。
6. Controller
ProductController.java
package com.example.cache.controller;
import com.example.cache.entity.Product;
import com.example.cache.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 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);
}
}
7. 启动类
CacheDemoApplication.java
package com.example.cache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class, args);
}
}
8. application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
show-sql: true
data:
redis:
host: localhost
port: 6379
logging:
level:
org.springframework.cache: debug
六、逐步验证清单
启动应用后,按下面步骤验证。
1. 第一次查询
curl http://localhost:8080/products/1
预期:
- 第一次会慢一些,大约 1 秒
- 因为要走数据库
2. 第二次查询
curl http://localhost:8080/products/1
预期:
- 基本瞬时返回
- 优先命中本地缓存
3. 更新商品
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"iPhone 15","price":7999}'
4. 再次查询
curl http://localhost:8080/products/1
预期:
- 第一次更新后的查询可能再次走数据库重建缓存
- 返回新数据而不是旧数据
七、常见坑与排查
这一部分非常关键。缓存方案最怕的不是“不快”,而是“看起来快,但偷偷返回旧数据”。
1. @Cacheable 不生效
现象
调用方法后还是每次都查库。
排查点
- 是否加了
@EnableCaching - 方法是不是
public - 是否发生了同类内部调用
比如下面这样通常不会触发缓存代理:
public Product test(Long id) {
return this.getById(id);
}
因为这是类内部直接调用,没有走 Spring AOP 代理。
解决方式
- 从外部 Bean 调用
- 或拆成两个 Service
- 或获取代理对象再调用
这是 Spring Cache 初学者最容易踩的坑之一。
2. 更新后仍读到旧值
现象
明明执行了 @CacheEvict,但查询还是老数据。
可能原因
原因一:事务提交时机
如果你在事务未提交前删缓存,另一个请求可能马上查库,查到的还是旧数据,然后又把旧值写回缓存。
建议
- 更新数据库与删缓存最好考虑事务边界
- 对强一致要求高的场景,可在事务提交后再删除缓存
- 或使用消息队列/订阅通知做二次删除
一个常见增强策略是“延迟双删”:
- 更新 DB
- 删除缓存
- 延迟几百毫秒后再删一次
虽然不完美,但对很多场景有效。
3. 集群环境下本地缓存不一致
现象
A 节点更新后,A 节点是新数据,B 节点仍读到旧数据。
原因
本地缓存天然是节点私有的。
解决思路
- Redis Pub/Sub 广播失效消息
- 使用 MQ 通知所有节点清本地缓存
- 或降低一级缓存 TTL
我自己的经验是:本地缓存不要配太长 TTL。它的职责是挡住瞬时热点,而不是长期保存真相。
4. 缓存穿透
现象
大量请求访问不存在的 ID,每次都打到 DB。
解决思路
- 缓存空值
- 参数校验
- 布隆过滤器
如果业务允许,null 结果也可以短暂缓存 30~60 秒。
不过要注意 Spring Cache 对空值支持方式要统一,不然容易出现序列化或类型判断问题。
5. 缓存雪崩
现象
一批 key 同时过期,瞬间大量请求打到下游。
解决思路
- TTL 加随机值
- 热点数据预热
- 限流与降级
- 多级缓存兜底
比如 Redis TTL 设成 300~360 秒,而不是所有 key 固定 300 秒。
6. 缓存击穿
现象
某个热点 key 失效瞬间,大量并发同时回源。
解决思路
- 热点 key 加互斥锁
- 使用逻辑过期
- 本地缓存短时保底
如果你的热点很明显,比如商品详情 Top100,可以考虑主动预热,而不是完全被动等待首次请求加载。
八、安全/性能最佳实践
1. 序列化要统一
建议:
- key 使用字符串
- value 使用 JSON
- 不要混用多种序列化方式
否则你很容易遇到:
- 老数据读不出来
- 类版本变更后反序列化失败
- 运维排查困难
2. TTL 分层设置
一级缓存和二级缓存不应该完全一样。
一个比较实用的经验值:
- Caffeine:10~30 秒
- Redis:5~10 分钟
为什么?
- 本地缓存短 TTL,降低不一致窗口
- Redis 长 TTL,降低 DB 压力
3. Key 设计要可控
推荐格式:
业务名:实体名:主键
比如:
product:detail:1
而不是随手拼接一串难以维护的字符串。
本文示例里为了对接 Spring Cache,用了 cacheName::key 的形式,实际项目中可以进一步规范。
4. 给热点接口加监控
别只看“有没有缓存”,要看:
- 本地缓存命中率
- Redis 命中率
- DB 回源比例
- 缓存重建耗时
- 热点 key 排名
如果没有这些指标,缓存调优基本靠猜。
建议至少打出:
- 查询总次数
- L1 命中次数
- L2 命中次数
- DB 查询次数
5. 防止大对象进入本地缓存
本地缓存虽然快,但它吃的是 JVM 堆内存。
如果把超大对象、长列表、复杂聚合结果直接丢进去,容易导致:
- Full GC 增加
- 老年代膨胀
- 服务抖动
建议:
- 只缓存热点、体积可控的数据
- 设置
maximumSize - 控制对象字段与层级
6. 谨慎追求“强一致”
多级缓存天生更偏向最终一致性。如果你面对的是:
- 库存扣减
- 账户余额
- 优惠券核销
这类强一致场景,不建议直接套本文方案当通用模板。应该优先考虑:
- 数据库事务
- 分布式锁
- 消息保证
- 原子操作
缓存更适合“读优化”,而不是替代核心一致性机制。
九、进阶扩展:集群下如何同步本地缓存失效
如果你的服务部署成多实例,那么更新时只删当前节点本地缓存是不够的。
可以引入 Redis Pub/Sub:
flowchart TD
A[节点A更新数据] --> B[更新数据库]
B --> C[删除 Redis 缓存]
C --> D[删除节点A本地缓存]
D --> E[发布失效消息到 Redis Channel]
E --> F[节点B订阅消息并删除本地缓存]
E --> G[节点C订阅消息并删除本地缓存]
基本思路:
- 数据更新后,删 Redis
- 删除当前节点本地缓存
- 发布一个缓存失效事件
- 其他节点收到事件后删除各自本地缓存
这样才能把“本地快”和“集群可控”结合起来。
十、方案边界与适用场景
这个方案比较适合:
- 读多写少
- 热点明显
- 可接受短暂最终一致性
- 单条查询或轻量聚合查询
不太适合:
- 高频写入
- 强一致资金类场景
- 超大对象缓存
- 多维复杂条件查询且 key 难以稳定设计的场景
一句话总结:
多级缓存不是银弹,它擅长给高频读接口提速,但不负责解决所有一致性问题。
十一、总结
这篇文章我们完成了一个基于 Spring Boot + Spring Cache + Redis + Caffeine 的多级缓存实战,核心收获有三点:
- 性能链路上:本地缓存负责极致低延迟,Redis 负责共享与削峰,数据库只在必要时回源
- 一致性上:优先采用“更新 DB,删除缓存”的策略,别急着直接写缓存
- 工程上:重点不是注解本身,而是 TTL、序列化、失效通知、监控这些细节
如果你准备在项目里落地,我建议按这个顺序推进:
- 先把单机版两级缓存跑通
- 再补上监控与命中率统计
- 最后在集群环境引入本地缓存失效广播
这样最稳,也最容易验证收益。
如果你只记住一句实践建议,那就是:
一级缓存 TTL 要短,二级缓存 TTL 要稳,更新优先删缓存,监控一定要补齐。
这套方案用在商品详情、配置查询、字典数据、用户资料页这类接口上,通常都能拿到很不错的性能收益。