背景与问题
很多团队一开始做缓存,都是“先把 Redis 接上再说”。这当然没错,但业务一旦上量,问题就会慢慢冒出来:
- 所有请求都直接打 Redis,网络开销开始明显;
- 热点 Key 被频繁访问,Redis 压力陡增;
- 单机内存明明很富余,却没利用本地缓存;
- 缓存一致性 处理不好,更新后读到旧值;
- Redis 短暂抖动时,整个系统响应时间明显变差。
我自己在项目里踩过一个很典型的坑:接口本身查数据库只要 20ms,接了 Redis 后平均响应反而变成了 30ms。原因不是 Redis 慢,而是**“所有请求都要走一次网络”**,对于高频、读多写少、且允许短时间弱一致的数据,这种模式并不划算。
所以,多级缓存就很自然地出现了:
- 一级缓存:本地内存缓存(Caffeine)
- 二级缓存:分布式缓存(Redis)
- 最终数据源:数据库/MySQL
这样做的目标很明确:
- 优先从本地缓存拿数据,降低延迟
- 本地未命中再访问 Redis,降低数据库压力
- Redis 兜底,保证多实例下共享缓存数据
- 更新时同时清理多级缓存,尽量控制一致性问题
这篇文章我会从 Spring Boot + Spring Cache 的角度,带你做一个可运行的多级缓存实战方案。不是只讲概念,而是从代码、流程、坑位到排查,一步步搭起来。
前置知识与环境准备
适合谁看
这篇文章更适合:
- 已经会写 Spring Boot 项目
- 用过
@Cacheable、@CacheEvict - 知道 Redis 基本用法
- 想把“单层 Redis 缓存”升级成“多级缓存”
技术栈
本文示例使用:
- JDK 8+
- Spring Boot 2.6.x
- Spring Cache
- Redis
- Caffeine
- Maven
多级缓存的目标架构
flowchart LR
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
这个流程看起来简单,但真正麻烦的地方在于:
- Spring Cache 默认并不会直接帮你做“本地 + Redis 联动”
- 你需要自己定义一个 CompositeCache 或者 自定义 CacheManager
- 删除缓存时,要保证两级一起清理
- TTL、序列化、并发加载、空值缓存都需要设计
核心原理
为什么 Spring Cache 能做这件事
Spring Cache 本质上是一个抽象层。你在业务上写:
@Cacheable@CachePut@CacheEvict
底层真正读写缓存的是 CacheManager 和 Cache 接口。
也就是说,只要我们自定义一个 Cache 实现,让它同时管理:
- 本地缓存
- Redis 缓存
那么业务层仍然可以继续优雅地使用注解,而不需要手工写一堆缓存逻辑。
多级缓存读取策略
常见读取顺序:
- 先查本地缓存
- 本地没有,再查 Redis
- Redis 命中后回填本地缓存
- Redis 也没有,再查数据库
- 查到后同时写入 Redis 和本地缓存
这是最常用也最稳妥的策略。
多级缓存写入/删除策略
对于更新操作,推荐的策略通常是:
- 更新数据库
- 删除本地缓存
- 删除 Redis 缓存
而不是“更新缓存”。原因很简单:
- 删除比更新更稳,避免覆盖错误值
- 让下一次读取自动回源重建缓存
- Spring Cache 的
@CacheEvict更容易统一管理
一致性边界
这里要说句实话:多级缓存很难做到强一致。
尤其是本地缓存存在于每个应用实例内:
- A 实例更新了数据并清理了自己的本地缓存
- B 实例的本地缓存可能还保留旧值
这就是多实例下本地缓存的天然问题。
解决思路一般有三种:
- 本地缓存 TTL 设置短一点
- 借助 Redis Pub/Sub 或 MQ 做失效通知
- 强一致场景不要上本地缓存
这也是多级缓存方案最重要的边界条件:
它适合“高频读、低频写、可接受秒级弱一致”的业务。
方案设计
本文采用的方案如下:
- 用 Caffeine 做一级缓存
- 用 Redis 做二级缓存
- 自定义
MultiLevelCache实现 SpringCache - 自定义
MultiLevelCacheManager管理缓存实例 - 业务侧继续使用
@Cacheable
类关系图
classDiagram
class Cache {
<<interface>>
+get(name)
+put(key,value)
+evict(key)
+clear()
}
class CacheManager {
<<interface>>
+getCache(name)
}
class MultiLevelCache {
-Cache localCache
-Cache redisCache
+get(key)
+put(key,value)
+evict(key)
+clear()
}
class MultiLevelCacheManager {
-CacheManager caffeineCacheManager
-CacheManager redisCacheManager
+getCache(name)
}
Cache <|.. MultiLevelCache
CacheManager <|.. MultiLevelCacheManager
实战代码(可运行)
下面给出一套可以直接落地的代码骨架。
1. 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>
2. application.yml 配置
server:
port: 8080
spring:
redis:
host: localhost
port: 6379
timeout: 3000
cache:
type: redis
logging:
level:
org.springframework.cache: debug
这里 spring.cache.type 实际上不是关键,因为我们会自己提供 CacheManager。
保留它主要是为了兼容默认行为,真正生效的是我们自定义的 Bean。
3. 启动类开启缓存
package com.example.multicache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class MultiCacheApplication {
public static void main(String[] args) {
SpringApplication.run(MultiCacheApplication.class, args);
}
}
4. 定义缓存配置
package com.example.multicache.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.example.multicache.cache.MultiLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import java.time.Duration;
@Configuration
public class CacheConfig {
@Bean
public CaffeineCacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setAllowNullValues(true);
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(2)));
return cacheManager;
}
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
)
);
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.transactionAware()
.build();
}
@Bean
public CacheManager cacheManager(CaffeineCacheManager caffeineCacheManager,
RedisCacheManager redisCacheManager) {
return new MultiLevelCacheManager(caffeineCacheManager, redisCacheManager);
}
}
这里的设计要点有两个:
- 本地缓存 TTL 短
- Redis TTL 稍长
这样可以兼顾:
- 热点请求优先走本地
- 多实例之间通过 Redis 保持相对一致
- 本地缓存即使脏了,也能较快过期
5. 自定义多级缓存实现
MultiLevelCache.java
package com.example.multicache.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import java.util.concurrent.Callable;
public class MultiLevelCache implements Cache {
private final String name;
private final Cache localCache;
private final Cache redisCache;
public MultiLevelCache(String name, Cache localCache, Cache redisCache) {
this.name = name;
this.localCache = localCache;
this.redisCache = redisCache;
}
@Override
public String getName() {
return this.name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
ValueWrapper localValue = localCache.get(key);
if (localValue != null) {
return localValue;
}
ValueWrapper redisValue = redisCache.get(key);
if (redisValue != null) {
localCache.put(key, redisValue.get());
return redisValue;
}
return null;
}
@Override
public <T> T get(Object key, Class<T> type) {
ValueWrapper valueWrapper = this.get(key);
if (valueWrapper == null) {
return null;
}
Object value = valueWrapper.get();
if (type != null && !type.isInstance(value)) {
throw new IllegalStateException(
"Cached value is not of required type [" + type.getName() + "]: " + value
);
}
return (T) value;
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper value = this.get(key);
if (value != null) {
return (T) value.get();
}
try {
T result = valueLoader.call();
this.put(key, result);
return result;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
@Override
public void put(Object key, Object value) {
localCache.put(key, value);
redisCache.put(key, value);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
ValueWrapper existing = this.get(key);
if (existing == null) {
this.put(key, value);
return null;
}
return existing;
}
@Override
public void evict(Object key) {
localCache.evict(key);
redisCache.evict(key);
}
@Override
public boolean evictIfPresent(Object key) {
localCache.evict(key);
redisCache.evict(key);
return true;
}
@Override
public void clear() {
localCache.clear();
redisCache.clear();
}
@Override
public boolean invalidate() {
localCache.clear();
redisCache.clear();
return true;
}
}
MultiLevelCacheManager.java
package com.example.multicache.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final CacheManager localCacheManager;
private final CacheManager redisCacheManager;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(CacheManager localCacheManager, CacheManager redisCacheManager) {
this.localCacheManager = localCacheManager;
this.redisCacheManager = redisCacheManager;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, key -> {
Cache localCache = localCacheManager.getCache(key);
Cache redisCache = redisCacheManager.getCache(key);
if (localCache == null || redisCache == null) {
throw new IllegalStateException("Cannot create cache with name: " + key);
}
return new MultiLevelCache(key, localCache, redisCache);
});
}
@Override
public Collection<String> getCacheNames() {
return redisCacheManager.getCacheNames();
}
}
6. 模拟业务实体
package com.example.multicache.model;
import java.io.Serializable;
public class User implements Serializable {
private Long id;
private String name;
private Integer age;
public User() {
}
public User(Long id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
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 Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
7. Service 层使用 Spring Cache 注解
package com.example.multicache.service;
import com.example.multicache.model.User;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class UserService {
private final Map<Long, User> database = new ConcurrentHashMap<>();
public UserService() {
database.put(1L, new User(1L, "Alice", 18));
database.put(2L, new User(2L, "Bob", 20));
}
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id) {
simulateSlowQuery();
System.out.println("query db, id = " + id);
return database.get(id);
}
@CacheEvict(cacheNames = "userCache", key = "#user.id")
public User update(User user) {
database.put(user.getId(), user);
System.out.println("update db, id = " + user.getId());
return user;
}
@CacheEvict(cacheNames = "userCache", allEntries = true)
public void clearAll() {
System.out.println("clear all cache");
}
private void simulateSlowQuery() {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
}
}
这里的 simulateSlowQuery() 是为了让你在本地更明显地看到缓存效果。
8. Controller 暴露测试接口
package com.example.multicache.controller;
import com.example.multicache.model.User;
import com.example.multicache.service.UserService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public User getById(@PathVariable Long id) {
return userService.getById(id);
}
@PutMapping("/{id}")
public User update(@PathVariable Long id, @RequestBody User user) {
user.setId(id);
return userService.update(user);
}
@DeleteMapping("/cache")
public String clearCache() {
userService.clearAll();
return "ok";
}
}
逐步验证清单
实际搭完之后,建议你按下面顺序验证,而不是一上来就压测。
第一步:验证本地缓存是否命中
第一次请求:
curl http://localhost:8080/users/1
你会看到日志里打印:
query db, id = 1
紧接着再次请求:
curl http://localhost:8080/users/1
如果没有再打印 query db,说明缓存生效了。
但这时候你还不能确定命中的是本地还是 Redis。
第二步:验证 Redis 是否有数据
在 Redis 里查看 Key:
redis-cli keys "*userCache*"
如果能看到对应 key,说明二级缓存也已经写入。
第三步:重启应用验证 Redis 回填本地缓存
应用重启后,本地缓存会消失,Redis 数据还在。
重启后再次请求:
curl http://localhost:8080/users/1
如果没有查询数据库,而能直接返回,说明 Redis 命中成功。
接下来再请求一次,大概率会命中本地缓存。
第四步:验证更新时缓存失效
更新用户:
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Alice-New","age":25}'
再查一次:
curl http://localhost:8080/users/1
应该重新查数据库并返回新值,随后再次查询则走缓存。
请求时序图
sequenceDiagram
participant C as Client
participant S as Spring Cache
participant L as Local Cache
participant R as Redis
participant D as DB
C->>S: getById(1)
S->>L: 查询本地缓存
alt 本地命中
L-->>S: 返回数据
S-->>C: 响应
else 本地未命中
S->>R: 查询 Redis
alt Redis 命中
R-->>S: 返回数据
S->>L: 回填本地缓存
S-->>C: 响应
else Redis 未命中
S->>D: 查询数据库
D-->>S: 返回数据
S->>R: 写入 Redis
S->>L: 写入本地缓存
S-->>C: 响应
end
end
常见坑与排查
这一部分很关键。多级缓存最烦人的地方不是“写不出来”,而是“看起来能跑,线上却总有诡异问题”。
1. 本地缓存与 Redis 数据不一致
现象
- A 节点更新后能读到新值
- B 节点偶尔还能读到旧值
原因
因为每个应用实例都有自己的本地缓存。
你在 A 节点执行了 @CacheEvict,只清掉了 A 自己的本地缓存和 Redis,并不能自动清掉 B 的本地缓存。
排查思路
- 看是否是多实例部署
- 看旧值持续多久,是否与本地 TTL 相符
- 看是否存在节点级热点缓存
解决建议
- 本地缓存 TTL 设短
- 通过 Redis Pub/Sub 广播失效消息
- 对强一致业务关闭本地缓存
2. 序列化问题导致读取报错
现象
- Redis 中有值,但反序列化失败
- 报
ClassCastException、JSON 结构不匹配
原因
常见原因包括:
- Redis 使用了 JDK 序列化,而本地是普通对象
- 对象结构变更后旧缓存未清理
- 泛型反序列化信息丢失
解决建议
- 统一使用
GenericJackson2JsonRedisSerializer - 实体类结构变更后及时清缓存
- 对复杂泛型对象单独设计缓存 DTO
3. 空值穿透数据库
现象
一个不存在的 id 被频繁请求,每次都打到数据库。
原因
查库返回 null,但缓存没有记录“这个 key 不存在”。
解决建议
- 对空值进行短 TTL 缓存
- 或者引入布隆过滤器
- 热点不存在数据场景必须单独处理
本文示例里 Redis 配置用了
disableCachingNullValues(),这是保守配置。
如果你的系统存在明显的缓存穿透风险,可以改成允许空值缓存,但 TTL 要更短,比如 30 秒。
4. 缓存雪崩
现象
某个时间点大量缓存同时过期,数据库瞬间被打满。
解决建议
- TTL 加随机值
- 热点数据提前预热
- 使用本地缓存分散 Redis 压力
- 对回源查询加限流/隔离
例如你可以把 Redis TTL 从固定 10 分钟改成随机区间:
Duration ttl = Duration.ofMinutes(10).plusSeconds((long) (Math.random() * 120));
当然,生产里不要直接在配置类写随机值给所有缓存统一处理,更推荐按业务分类配置。
5. 缓存击穿与并发回源
现象
某个热点 key 失效瞬间,大量请求同时回源数据库。
原因
虽然你用了缓存,但没有做并发加载控制。
解决建议
- 用
@Cacheable(sync = true)控制同 JVM 内并发加载 - Redis 层可配合互斥锁
- 热点 key 做逻辑过期 + 后台刷新
例如:
@Cacheable(cacheNames = "userCache", key = "#id", sync = true)
public User getById(Long id) {
simulateSlowQuery();
return database.get(id);
}
sync = true 很实用,但要注意:
它主要解决单实例内的并发问题,解决不了多实例之间的同时回源。
安全/性能最佳实践
这一部分我建议你在正式上线前过一遍,很多线上事故其实都跟这里有关。
1. 不要缓存敏感数据明文
像下面这些内容,不建议直接进缓存:
- 身份证号
- 手机号全量信息
- access token
- 密码摘要以外的敏感凭证
如果确实要缓存:
- 做脱敏
- 做字段裁剪
- Redis 开启访问控制
- 限制缓存对象内容
2. 本地缓存大小必须受控
Caffeine 虽然快,但它吃的是 JVM 堆内存。
如果你不设上限,很容易导致:
- Full GC 增多
- 老年代膨胀
- OOM
建议至少配置:
maximumSizeexpireAfterWrite或expireAfterAccess
比如:
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(2));
不要一上来就把本地缓存开得特别大,先基于热点数据量评估。
3. 分层 TTL 不要一样
如果本地缓存和 Redis 都设置相同 TTL,会出现一个问题:
它们可能在同一时间大面积过期,回源流量抖动更明显。
更合理的方式是:
- 本地缓存:1~3 分钟
- Redis:5~30 分钟
- 热点业务:加随机过期时间
4. 监控比“是否命中”更重要
很多人只关心缓存有没有生效,但线上真正要看的指标是:
- 本地缓存命中率
- Redis 命中率
- 数据库回源次数
- Key 数量增长趋势
- Redis 网络延迟
- JVM 堆使用率与 GC 次数
如果你发现:
- 本地命中率低
- Redis 命中率也不高
- 数据库回源却很高
那说明你的缓存键设计、TTL 设计或数据访问模式可能有问题。
5. Key 设计要稳定、可读、可控
虽然 Spring Cache 自动帮你生成 Key,但复杂参数对象时,默认 Key 不一定适合线上排查。
建议在业务中显式指定 key:
@Cacheable(cacheNames = "userCache", key = "#id")
如果是多维参数:
@Cacheable(cacheNames = "productCache", key = "#shopId + ':' + #productId")
这样排查 Redis 数据时会轻松很多。
6. 多实例一致性建议:失效通知机制
如果你的业务对一致性要求比“短 TTL”更高,推荐增加缓存失效广播。
基本思路:
- 更新数据库后删除 Redis
- 发送失效消息
- 各节点收到消息后删除本地缓存
状态变化可以理解为:
stateDiagram-v2
[*] --> DBUpdated
DBUpdated --> RedisEvicted: 删除 Redis Key
RedisEvicted --> PublishEvent: 发布失效事件
PublishEvent --> LocalEvictedAllNodes: 各节点删除本地缓存
LocalEvictedAllNodes --> [*]
这一步本文不展开实现,但你在生产里如果是多实例部署,我非常建议认真考虑。
进阶建议:什么时候不该用多级缓存
这个问题很重要。不是所有缓存问题都该靠“本地 + Redis”解决。
以下场景我一般不建议用本地缓存:
1. 强一致业务
例如:
- 库存扣减
- 支付状态
- 账户余额
这种数据哪怕短时间不一致,也可能带来严重后果。
这类场景建议:
- 直接查 Redis / DB
- 配合原子操作
- 明确一致性策略
2. 写多读少业务
如果一个数据刚写完很快又变,缓存命中率会很低。
这时候缓存带来的管理成本,可能比收益更大。
3. 数据体积很大
本地缓存适合热点、小对象、高频访问。
如果每条缓存对象都很大,很容易把 JVM 内存顶爆。
总结
这篇文章的核心思路其实可以归纳成一句话:
用 Spring Cache 保持业务代码简洁,用 Caffeine + Redis 组合出“低延迟 + 跨实例共享”的多级缓存能力。
我们完成了这些事情:
- 分析了单层 Redis 缓存的局限
- 解释了 Spring Cache 抽象层的工作方式
- 实现了一个可运行的
MultiLevelCache - 演示了读取、回填、失效的完整链路
- 梳理了多级缓存里最常见的坑和排查方法
- 给出了上线前值得执行的安全与性能建议
最后给几个可执行建议,方便你落地:
- 先从读多写少的接口开始改造,不要全站一口气上多级缓存。
- 本地缓存 TTL 设短,Redis TTL 设长,避免一致性问题扩大。
- 更新优先删缓存而不是改缓存,逻辑更稳。
- 多实例环境下别忽视本地缓存失效广播,否则很容易读到旧值。
- 给缓存加监控,不然你只能凭感觉判断“缓存是不是生效了”。
如果你当前项目已经在用 Spring Cache 和 Redis,那么这套方案其实非常适合作为下一步优化方向。
它不是银弹,但对很多典型的中后台读场景来说,确实是一个成本可控、收益明显的工程实践。