Spring Boot 中基于 Spring Cache 与 Redis 实现多级缓存的实战方案与性能调优
在做接口性能优化时,很多同学第一反应是“上 Redis”。这当然没错,但如果你的热点数据访问非常频繁,只靠 Redis 这一层,网络开销、序列化开销、Redis 自身压力都会逐渐显现。
更现实一点的场景是这样的:
- 本地 JVM 内访问极快
- Redis 适合跨实例共享缓存
- 数据库适合做最终数据源,但最慢
所以,多级缓存的典型目标就是:
- 优先命中本地缓存
- 本地未命中,再查 Redis
- Redis 未命中,再查数据库
- 回填 Redis 和本地缓存
这篇文章我会带你用 Spring Boot + Spring Cache + Redis + Caffeine 做一个可运行的多级缓存方案,并把常见的坑和调优点一并讲清楚。文章偏实战,我会尽量按“能落地”的方式来写。
背景与问题
先看单级缓存常见的问题。
只有 Redis 的问题
如果系统只有 Redis 缓存,虽然已经比直接查数据库快很多,但仍然会遇到:
- 高并发下大量请求都要走网络访问 Redis
- 热点 Key 会集中打到 Redis
- 序列化/反序列化消耗不小
- 多服务实例场景下,Redis 压力会越来越高
只有本地缓存的问题
如果只做 JVM 本地缓存,比如 Caffeine:
- 单机性能很好
- 但是缓存无法跨实例共享
- 多个应用节点的数据容易不一致
- 服务重启后缓存全失效
多级缓存为什么适合 Spring Boot 场景
Spring Boot 项目里,大多数接口的访问特征都很像:
- 某些配置、字典、商品详情、用户资料是热点数据
- 更新频率相对低,读取频率高
- 服务通常是多实例部署
这类数据特别适合:
- 一级缓存:Caffeine(本地内存)
- 二级缓存:Redis(分布式共享)
这样做能兼顾:
- 极致读性能
- 多实例共享
- 适当的一致性控制
- 对数据库的强保护
前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Spring Data Redis
- Caffeine
- Redis 7.x
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-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
核心原理
先把整体链路想清楚,再写代码会顺很多。
多级缓存访问流程
flowchart TD
A[请求进入] --> B{本地缓存 Caffeine 命中?}
B -- 是 --> C[返回数据]
B -- 否 --> D{Redis 命中?}
D -- 是 --> E[回填本地缓存]
E --> C
D -- 否 --> F[查询数据库]
F --> G[写入 Redis]
G --> H[写入本地缓存]
H --> C
更新流程的关键点
读链路不难,真正难的是写链路。因为只要数据更新,就涉及缓存一致性问题。
比较常见的策略:
- 先更新数据库,再删除缓存
- 删除时同时删本地缓存和 Redis
- 如果是多实例,本地缓存删除还需要广播通知
sequenceDiagram
participant Client as 客户端
participant App as 应用实例A
participant DB as 数据库
participant Redis as Redis
participant App2 as 应用实例B
Client->>App: 更新商品信息
App->>DB: update product
DB-->>App: success
App->>Redis: delete redis key
App->>App: clear local cache
App->>Redis: publish invalidation message
Redis-->>App2: 订阅到失效通知
App2->>App2: clear local cache
Spring Cache 在这里扮演什么角色
Spring Cache 负责的是统一缓存编程模型,例如:
@Cacheable@CachePut@CacheEvict
但 Spring Cache 默认并不知道“多级缓存”该怎么协同,所以我们通常有两种做法:
- 自定义 CacheManager / Cache 实现
- 业务里手工编排本地缓存 + Redis
如果你希望继续用 @Cacheable 这套注解,推荐第一种:自定义一个 MultiLevelCache,让 Spring Cache 帮你接管方法缓存。
方案设计:Caffeine + Redis 双层缓存
这里我们采用下面这个设计:
- 一级缓存:Caffeine
- 优点:极快、无网络开销
- 用途:拦截热点请求
- 二级缓存:Redis
- 优点:多实例共享
- 用途:跨节点缓存、容量更大
- 缓存失效通知:Redis Pub/Sub
- 用途:某个实例删缓存后,通知其他实例清理本地缓存
适用场景
推荐用于:
- 商品详情
- 分类树
- 地区字典
- 用户基础资料
- 配置项
- 报表维度数据
不太推荐直接用于:
- 强一致金融余额
- 高频写入且必须实时一致的数据
- 超大对象缓存
实战代码(可运行)
下面给一个可运行的简化版本。为了聚焦主题,我用“商品查询”做示例。
1. application.yml
server:
port: 8080
spring:
cache:
type: none
data:
redis:
host: localhost
port: 6379
timeout: 3s
management:
endpoints:
web:
exposure:
include: health,info,metrics
这里把
spring.cache.type设为none,是因为我们要自己注册CacheManager。
2. 启动类
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);
}
}
3. 商品实体
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 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 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 Long getVersion() {
return version;
}
public void setVersion(Long version) {
this.version = version;
}
}
4. 模拟 Repository
这里先用内存 Map 模拟数据库,方便你本地直接跑通。
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, "Mechanical Keyboard", new BigDecimal("399.00"), 1L));
db.put(2L, new Product(2L, "Wireless Mouse", new BigDecimal("129.00"), 1L));
}
public Product findById(Long id) {
sleep(100);
return db.get(id);
}
public Product save(Product product) {
db.put(product.getId(), product);
return product;
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
5. Redis 序列化与监听配置
package com.example.multicache.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
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.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public ChannelTopic cacheEvictTopic() {
return new ChannelTopic("cache:evict:topic");
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory factory,
MessageListenerAdapter listenerAdapter,
ChannelTopic topic) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(listenerAdapter, topic);
return container;
}
@Bean
public MessageListenerAdapter messageListenerAdapter(CacheInvalidationListener listener) {
return new MessageListenerAdapter(listener, "onMessage");
}
}
6. 本地缓存失效监听器
package com.example.multicache.config;
import org.springframework.stereotype.Component;
@Component
public class CacheInvalidationListener {
private final MultiLevelCacheManager cacheManager;
public CacheInvalidationListener(MultiLevelCacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public void onMessage(String message) {
String[] parts = message.split("::", 2);
if (parts.length != 2) {
return;
}
String cacheName = parts[0];
String key = parts[1];
cacheManager.clearLocal(cacheName, key);
}
}
7. 自定义 MultiLevelCache
package com.example.multicache.config;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ChannelTopic;
import java.time.Duration;
import java.util.concurrent.Callable;
public class MultiLevelCache extends AbstractValueAdaptingCache {
private final String name;
private final Cache<Object, Object> localCache;
private final RedisTemplate<String, Object> redisTemplate;
private final ChannelTopic topic;
private final Duration redisTtl;
public MultiLevelCache(
String name,
Cache<Object, Object> localCache,
RedisTemplate<String, Object> redisTemplate,
ChannelTopic topic,
Duration redisTtl) {
super(true);
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.topic = topic;
this.redisTtl = redisTtl;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
private String buildRedisKey(Object key) {
return name + "::" + key;
}
@Override
protected Object lookup(Object key) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return localValue;
}
Object redisValue = redisTemplate.opsForValue().get(buildRedisKey(key));
if (redisValue != null) {
localCache.put(key, redisValue);
}
return redisValue;
}
@Override
public void put(Object key, Object value) {
Object storeValue = toStoreValue(value);
localCache.put(key, storeValue);
redisTemplate.opsForValue().set(buildRedisKey(key), storeValue, redisTtl);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
Object existing = lookup(key);
if (existing != null) {
return () -> fromStoreValue(existing);
}
put(key, value);
return null;
}
@Override
public void evict(Object key) {
localCache.invalidate(key);
redisTemplate.delete(buildRedisKey(key));
redisTemplate.convertAndSend(topic.getTopic(), name + "::" + key);
}
@Override
public void clear() {
localCache.invalidateAll();
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
Object value = lookup(key);
if (value != null) {
return (T) fromStoreValue(value);
}
try {
T loaded = valueLoader.call();
if (loaded != null) {
put(key, loaded);
}
return loaded;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void clearLocal(Object key) {
localCache.invalidate(key);
}
}
8. 自定义 CacheManager
package com.example.multicache.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.ChannelTopic;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class MultiLevelCacheManager implements CacheManager {
private final RedisTemplate<String, Object> redisTemplate;
private final ChannelTopic topic;
private final Map<String, MultiLevelCache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisTemplate<String, Object> redisTemplate, ChannelTopic topic) {
this.redisTemplate = redisTemplate;
this.topic = topic;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, this::createCache);
}
private MultiLevelCache createCache(String name) {
return new MultiLevelCache(
name,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build(),
redisTemplate,
topic,
Duration.ofMinutes(5)
);
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
public void clearLocal(String cacheName, Object key) {
MultiLevelCache cache = cacheMap.get(cacheName);
if (cache != null) {
cache.clearLocal(key);
}
}
}
9. 业务 Service
package com.example.multicache.service;
import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public Product getById(Long id) {
return repository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
Product old = repository.findById(product.getId());
long version = old == null ? 1L : old.getVersion() + 1;
product.setVersion(version);
return repository.save(product);
}
}
这里的
sync = true很关键。它可以降低同一实例内缓存击穿时的并发加载问题。这个点我后面还会专门说。
10. Controller
package com.example.multicache.controller;
import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public Product getById(@PathVariable Long id) {
return service.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestParam String name, @RequestParam BigDecimal price) {
Product product = new Product();
product.setId(id);
product.setName(name);
product.setPrice(price);
return service.update(product);
}
}
逐步验证清单
项目启动后,你可以按这个顺序验证。
第一次查询:走“数据库”
curl http://localhost:8080/products/1
预期:
- 本地缓存未命中
- Redis 未命中
- Repository 睡眠 100ms 后返回数据
- 数据写入 Redis 和 Caffeine
第二次查询:走“本地缓存”
再次请求:
curl http://localhost:8080/products/1
预期:
- 直接命中 Caffeine
- 响应明显更快
更新数据:触发双层失效
curl -X PUT "http://localhost:8080/products/1?name=Mechanical%20Keyboard%20V2&price=499"
然后再查询:
curl http://localhost:8080/products/1
预期:
- 本地缓存和 Redis 对应 Key 已失效
- 重新回源加载最新数据
核心原理再拆开一点:为什么这个实现能工作
有同学看完代码会问:@Cacheable 是怎么自动走到你自定义的多级缓存里的?
答案是:
- Spring 在执行
@Cacheable方法时,会从容器里找CacheManager - 我们注册了
MultiLevelCacheManager getCache("product")返回的是MultiLevelCache- 所以 Spring Cache 注解最终操作的是我们的双层缓存逻辑
换句话说,Spring Cache 负责切面拦截,自定义 Cache 负责缓存策略。
常见坑与排查
这一段非常重要。很多多级缓存方案不是不会写,而是上线后问题不断。
1. 本地缓存不一致
现象
你在实例 A 更新了数据,但实例 B 仍然返回旧值。
原因
因为 B 的 Caffeine 本地缓存并不知道数据已经被更新了。
解决
- 删除 Redis 缓存还不够
- 必须增加本地缓存失效广播
- 本文示例用的是 Redis Pub/Sub
排查方法
- 确认更新接口是否真的触发了
@CacheEvict - 确认消息频道是否发出通知
- 确认其他实例是否成功订阅
- 检查 key 格式是否一致,例如
product::1
2. @Cacheable 不生效
常见原因
- 方法是
private - 同类内部调用,绕过了 Spring AOP 代理
- 没有加
@EnableCaching CacheManager没注册成功
典型错误示例
@Service
public class ProductService {
@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
return findInner(id);
}
public Product test(Long id) {
return getById(id); // 同类内部调用可能导致缓存失效
}
private Product findInner(Long id) {
return null;
}
}
建议
- 被缓存的方法用
public - 跨 Bean 调用
- 启动时打印
CacheManager类型确认注入正确
3. 缓存穿透
现象
请求大量不存在的 ID,比如 99999999,每次都打到数据库。
解决思路
- 缓存空值
- 做布隆过滤器
- 接口层做参数校验
本文代码继承 AbstractValueAdaptingCache(true),允许缓存 null,这就是在为“空值缓存”打基础。
不过要注意:空值 TTL 通常要更短,否则业务刚新增数据时,旧的空缓存会影响可见性。
4. 缓存击穿
现象
某个热点 Key 刚过期,大量并发同时回源数据库。
解决思路
@Cacheable(sync = true):单实例内有效- Redis 分布式锁:跨实例保护
- 热点数据永不过期 + 异步刷新
- TTL 加随机值,避免集中过期
边界条件
sync = true 只能解决同一个应用实例上的并发回源问题,解决不了多实例同时击穿。这一点特别容易被误解。
5. 缓存雪崩
现象
大量缓存同一时间过期,瞬间把数据库打崩。
解决
- TTL 加随机抖动
- 不同缓存分类设置不同 TTL
- 热点缓存预热
- 做限流和降级
比如 Redis TTL 可以从固定 5 分钟改成:
Duration base = Duration.ofMinutes(5);
long randomSeconds = ThreadLocalRandom.current().nextLong(30, 120);
Duration ttl = base.plusSeconds(randomSeconds);
6. 序列化问题
现象
- Redis 里 value 结构奇怪
- 反序列化报错
- 升级类结构后旧缓存读不出来
建议
- 优先使用 JSON 序列化,避免 JDK 原生序列化
- 缓存对象保持简单稳定
- DTO 和缓存对象尽量解耦
- 大版本升级时考虑缓存统一清理
我自己踩过的一个坑是:缓存里直接塞复杂领域对象,后来对象层次一变,线上旧缓存全反序列化失败。稳妥做法是:缓存专用对象尽量扁平化。
安全/性能最佳实践
这一部分我会把“好用”和“能上线”分开讲。
1. TTL 要分层,不要一刀切
不同数据的更新频率不同:
- 商品详情:5~15 分钟
- 字典配置:30~60 分钟
- 用户资料:1~10 分钟
- 空值缓存:30 秒 ~ 2 分钟
不要所有缓存统一 5 分钟,这通常只是“看起来简单”。
2. 本地缓存容量要有边界
Caffeine 很快,但不是无限的。容量配置要结合 JVM 堆大小。
建议至少关注:
initialCapacitymaximumSizeexpireAfterWriterecordStats
如果机器内存紧张,还要结合:
-Xms-Xmx- Full GC 次数
- 对象大小估算
3. Redis Key 设计要规范
推荐统一格式:
业务前缀:缓存名:业务主键
例如:
mall:product:1
如果直接用 product::1 也不是不能用,但在大型系统里,带业务前缀更利于排查、隔离和迁移。
4. 不要缓存超大对象
如果一个对象动辄几百 KB,甚至几 MB:
- Redis 网络传输成本高
- 本地缓存占内存快
- 反序列化慢
- GC 压力大
这种场景更适合:
- 拆分字段
- 只缓存摘要/热点字段
- 缓存分页结果时加限制
5. 监控命中率,而不是“感觉很快”
多级缓存如果没有监控,后面基本靠猜。
建议至少观察:
- Caffeine 命中率
- Redis 命中率
- 数据库 QPS
- 缓存加载耗时
- 缓存失效次数
- Redis 网络延迟
你可以结合 Micrometer 暴露指标,或者定期打印 Caffeine stats。
6. 热点 Key 建议做逻辑隔离
对于极热点数据,比如首页爆款商品、系统配置:
- 可以单独放一个 cacheName
- 设置更长 TTL
- 做主动预热
- 必要时异步刷新
不要把热点数据和普通长尾数据全部塞进同一个缓存策略里。
7. 更新策略优先“删缓存”,少做“强覆盖”
缓存更新有两种常见思路:
- 更新数据库后,删除缓存
- 更新数据库后,直接覆盖缓存
在实际业务里,我更推荐优先删除缓存,因为:
- 简单
- 出错面小
- 避免把脏数据写回缓存
除非你对更新链路控制很强,否则“更新 DB + 删缓存”通常更稳。
8. 给 Redis 做最小权限与网络隔离
安全上容易被忽视的点:
- Redis 不暴露公网
- 开启认证
- 做 VPC/安全组隔离
- 限制危险命令
- 配置合理超时
- 生产环境启用 TLS 时评估额外延迟
缓存也是数据资产,不能因为“只是缓存”就放松防护。
一个更完整的生产级增强方向
如果你准备把本文方案继续往生产级推进,可以按这个路线逐步升级:
flowchart LR
A[基础版<br/>Caffeine + Redis + Spring Cache] --> B[增强一致性<br/>Redis Pub/Sub 广播失效]
B --> C[抗击穿<br/>分布式锁 + 热点永不过期]
C --> D[可观测性<br/>命中率 负载 延迟 指标]
D --> E[生产增强<br/>随机TTL 预热 限流 降级]
进一步增强点
- 空值缓存单独 TTL
- 热点 Key 单独锁
- 异步刷新缓存
- 本地缓存按业务分组
- 统一缓存 KeyBuilder
- 缓存异常兜底,绝不影响主流程
- 引入布隆过滤器防穿透
一个现实的取舍:一致性不是免费的
多级缓存方案的价值很大,但也别神化它。
你要接受几个事实:
- 本地缓存越 aggressive,一致性越弱
- TTL 越长,命中率越高,但旧数据窗口越长
- 更新通知链路越复杂,运维成本越高
- 强一致场景通常不适合多级缓存
所以,我的建议是:
适合多级缓存的边界
- 读多写少
- 可容忍秒级以内短暂不一致
- 热点明显
- 查询链路相对昂贵
不适合的边界
- 每次读取都必须拿最新值
- 更新极其频繁
- 对账、余额、库存强一致核心链路
总结
我们这篇文章完成了一个 Spring Boot 下的多级缓存实战方案,核心点可以归纳成 6 句话:
- 一级用 Caffeine,二级用 Redis,是很实用的组合
- Spring Cache 负责注解与切面,自定义 Cache 负责多级策略
- 读链路是本地 -> Redis -> DB -> 回填
- 写链路建议更新 DB 后删除缓存,并广播本地失效
sync = true能缓解单实例击穿,但不是分布式银弹- 上线前一定要补齐 TTL、监控、失效通知、容量边界和异常兜底
如果你现在要把这个方案真正用到项目里,我建议优先做这三件事:
- 先跑通本文示例,确认注解 + 双层缓存链路没有问题
- 再补 Pub/Sub 广播和命中率监控
- 最后按业务热点程度拆分 TTL 与缓存策略
这样推进,风险最小,也最容易看见效果。
如果只想记住一句话,那就是:
多级缓存的重点不在“加两层”,而在“如何失效、如何兜底、如何观测”。
只要这三个问题想明白了,这套方案就不只是“能跑”,而是真能扛线上流量。