Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:提升接口性能与一致性保障
在做接口优化时,很多人第一反应是“上 Redis”。这当然没错,但如果你的服务本身 QPS 不低、接口读多写少,而且应用实例和数据库之间的延迟已经比较明显,那么只靠单层 Redis 往往还不够。
我自己在项目里踩过一个典型场景:某商品详情接口,数据库查询并不复杂,但流量高峰时,大量请求仍然会穿透到 Redis,导致 Redis CPU 抬升、网络开销增加,接口 RT 波动明显。后来我们把“本地缓存 + Redis”做成两级缓存后,热点数据的访问延迟明显下降,同时结合失效通知处理一致性,整体效果就稳定很多。
这篇文章就带你从 Spring Boot + Spring Cache + Redis 出发,做一个可运行的多级缓存方案,重点解决两个问题:
- 性能怎么提起来
- 缓存一致性怎么尽量稳住
背景与问题
先看一个典型链路:
- Controller 接口接收请求
- Service 查询商品信息
- 先查 Redis,没有再查数据库
- 查到数据库后回填 Redis
这已经是常见的缓存旁路(Cache Aside)模式了。但它依然有几个痛点:
1. 单层 Redis 仍有网络开销
即使 Redis 很快,它也是远程调用:
- 序列化/反序列化
- 网络 IO
- Redis 本身的并发压力
对于高频热点 key,每次都打到 Redis,其实还是浪费。
2. 热点数据容易集中打爆下游
例如某个商品详情、配置项、首页聚合数据:
- 高并发同时访问
- 本地没有缓存时全去 Redis
- Redis miss 时又一起压数据库
这就是典型的缓存击穿问题。
3. 多实例下本地缓存天然不一致
如果你加了 JVM 本地缓存,就会遇到:
- A 实例更新了缓存
- B 实例本地缓存还是旧值
- 用户访问不同实例时看到的数据不一样
所以多级缓存并不只是“再加一层缓存”,而是要把一致性传播机制一起考虑进去。
核心原理
本文采用的方案是:
- 一级缓存(L1):本地缓存,使用 Caffeine
- 二级缓存(L2):Redis
- 缓存注解入口:Spring Cache
- 一致性传播:更新时删除 Redis,并通过 Redis Pub/Sub 广播清理各实例本地缓存
这是一个偏实战、落地成本低的方案。对中级读者来说,比较适合先把它跑起来。
整体流程
flowchart TD
A[客户端请求] --> B[Controller]
B --> C[Service @Cacheable]
C --> D{L1 本地缓存命中?}
D -- 是 --> E[直接返回]
D -- 否 --> F{L2 Redis 命中?}
F -- 是 --> G[写回 L1]
G --> E
F -- 否 --> H[查询数据库]
H --> I[回填 Redis]
I --> J[回填 L1]
J --> E
更新流程
sequenceDiagram
participant Client
participant AppA as 应用实例A
participant DB as MySQL
participant Redis
participant AppB as 应用实例B
Client->>AppA: 更新商品信息
AppA->>DB: update
AppA->>Redis: 删除 L2 缓存
AppA->>Redis: 发布失效消息
Redis-->>AppA: 清理本地 L1
Redis-->>AppB: 清理本地 L1
AppA-->>Client: 返回成功
设计要点
1. 为什么不用“只本地缓存”?
只本地缓存的问题是:
- 多实例数据不一致严重
- 重启就丢
- 容量受单机内存限制
所以本地缓存更适合做热点加速层,而 Redis 作为共享层更稳妥。
2. 为什么不用“只 Redis”?
只 Redis 当然简单,但热点请求都得过网络,不够极致。而 L1 本地缓存能把超热点请求直接拦在 JVM 内部,延迟和吞吐都更漂亮。
3. 一致性怎么理解?
先说结论:缓存和数据库很难做到绝对强一致,业务里通常追求最终一致。
在这套方案里,我们做到的是:
- 更新数据库后,删除 Redis 缓存
- 再通知各实例删除本地缓存
- 下一次读请求重新加载新数据
这已经是大多数读多写少接口的合理平衡。
前置知识与环境准备
技术栈
- JDK 8+
- Spring Boot 2.x
- Spring Cache
- Spring Data Redis
- Caffeine
- Maven
- Redis 5.x+
示例场景
我们以“商品详情接口”为例:
GET /products/{id}:查询商品PUT /products/{id}:更新商品
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>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
核心原理落地:Spring Cache 如何接入多级缓存
Spring Cache 的关键点在于:它本质上只关心 CacheManager 和 Cache 接口。
也就是说,我们完全可以自己封装一个 Cache,让它内部变成:
- 先查 Caffeine
- 再查 Redis
- 回填 Caffeine
- 更新/删除时同时操作两层
这就是本文的实现思路。
类关系
classDiagram
class CacheManager {
<<interface>>
+getCache(String name)
}
class Cache {
<<interface>>
+get(Object key)
+put(Object key, Object value)
+evict(Object key)
+clear()
}
class MultiLevelCacheManager {
+getCache(String name)
}
class MultiLevelCache {
-CaffeineCache localCache
-RedisTemplate redisTemplate
+get(Object key)
+put(Object key, Object value)
+evict(Object key)
+clear()
}
CacheManager <|.. MultiLevelCacheManager
Cache <|.. MultiLevelCache
实战代码(可运行)
下面这套代码是一个简化但完整的多级缓存实现,适合作为 tutorial 起点。
1. 启动类开启缓存
package com.example.cache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class, args);
}
}
2. 商品实体
package com.example.cache.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 updateTime;
public Product() {
}
public Product(Long id, String name, BigDecimal price, Long updateTime) {
this.id = id;
this.name = name;
this.price = price;
this.updateTime = updateTime;
}
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 getUpdateTime() {
return updateTime;
}
public void setUpdateTime(Long updateTime) {
this.updateTime = updateTime;
}
}
3. 模拟 Repository
为了方便运行,这里先不用真实数据库,用 ConcurrentHashMap 模拟。你接入 MyBatis/JPA 时,替换掉这一层即可。
package com.example.cache.repository;
import com.example.cache.model.Product;
import org.springframework.stereotype.Repository;
import javax.annotation.PostConstruct;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> storage = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
storage.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), System.currentTimeMillis()));
storage.put(2L, new Product(2L, "电竞鼠标", new BigDecimal("159.00"), System.currentTimeMillis()));
}
public Product findById(Long id) {
sleep(100); // 模拟数据库耗时
return storage.get(id);
}
public Product update(Product product) {
product.setUpdateTime(System.currentTimeMillis());
storage.put(product.getId(), product);
return product;
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
4. Redis 配置
package com.example.cache.config;
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 RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
5. 多级缓存实现
package com.example.cache.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 Cache localCache;
private final RedisTemplate<String, Object> redisTemplate;
private final Duration ttl;
public MultiLevelCache(String name, Cache localCache, RedisTemplate<String, Object> redisTemplate, Duration ttl) {
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.ttl = ttl;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
private String buildKey(Object key) {
return name + "::" + key;
}
@Override
public ValueWrapper get(Object key) {
Cache.ValueWrapper localValue = localCache.get(key);
if (localValue != null) {
return localValue;
}
Object redisValue = redisTemplate.opsForValue().get(buildKey(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();
return (value != null && type.isInstance(value)) ? (T) value : null;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
synchronized (this.internKey(buildKey(key))) {
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 ValueRetrievalException(key, valueLoader, e);
}
}
}
@Override
public void put(Object key, Object value) {
localCache.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) {
localCache.evict(key);
redisTemplate.delete(buildKey(key));
}
@Override
public void clear() {
localCache.clear();
}
private String internKey(String key) {
return key.intern();
}
}
这里我特意加了一个简单的
synchronized + valueLoader,用于减少同一 key 并发 miss 时的击穿。但请注意,大量使用intern()在超大 key 空间下并不完美,后文会说更稳妥的做法。
6. 多级 CacheManager
package com.example.cache.cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.AbstractCacheManager;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class MultiLevelCacheManager extends AbstractCacheManager {
private final RedisTemplate<String, Object> redisTemplate;
public MultiLevelCacheManager(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
protected Collection<? extends Cache> loadCaches() {
return Collections.emptyList();
}
@Override
protected Cache getMissingCache(String name) {
CaffeineCache localCache = new CaffeineCache(
name,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.recordStats()
.build()
);
return new MultiLevelCache(name, localCache, redisTemplate, Duration.ofMinutes(5));
}
}
7. 缓存配置注册
package com.example.cache.config;
import com.example.cache.cache.MultiLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
return new MultiLevelCacheManager(redisTemplate);
}
}
8. 缓存失效消息体
package com.example.cache.message;
import java.io.Serializable;
public class CacheEvictMessage implements Serializable {
private String cacheName;
private String key;
public CacheEvictMessage() {
}
public CacheEvictMessage(String cacheName, String key) {
this.cacheName = cacheName;
this.key = key;
}
public String getCacheName() {
return cacheName;
}
public void setCacheName(String cacheName) {
this.cacheName = cacheName;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
}
9. Pub/Sub 配置
package com.example.cache.config;
import com.example.cache.listener.CacheMessageSubscriber;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@Configuration
public class RedisPubSubConfig {
public static final String CACHE_EVICT_TOPIC = "cache:evict:topic";
@Bean
public ChannelTopic cacheEvictTopic() {
return new ChannelTopic(CACHE_EVICT_TOPIC);
}
@Bean
public MessageListenerAdapter listenerAdapter(CacheMessageSubscriber subscriber) {
return new MessageListenerAdapter(subscriber, "onMessage");
}
@Bean
public RedisMessageListenerContainer redisContainer(RedisConnectionFactory factory,
MessageListenerAdapter listenerAdapter,
ChannelTopic cacheEvictTopic) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(listenerAdapter, cacheEvictTopic);
return container;
}
}
10. 监听并清理本地缓存
package com.example.cache.listener;
import com.example.cache.cache.MultiLevelCache;
import com.example.cache.message.CacheEvictMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
@Component
public class CacheMessageSubscriber {
private final CacheManager cacheManager;
private final ObjectMapper objectMapper;
public CacheMessageSubscriber(CacheManager cacheManager, ObjectMapper objectMapper) {
this.cacheManager = cacheManager;
this.objectMapper = objectMapper;
}
public void onMessage(String message) {
try {
CacheEvictMessage evictMessage = objectMapper.readValue(message, CacheEvictMessage.class);
Cache cache = cacheManager.getCache(evictMessage.getCacheName());
if (cache != null) {
cache.evict(evictMessage.getKey());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
这里有个细节:如果直接调用
cache.evict(key),会同时删本地和 Redis。虽然问题不大,但会重复删除 Redis。更严谨的做法是给MultiLevelCache单独暴露一个“只删本地”的方法。为了示例简洁先这么写,后面会讲优化版。
11. Service 层
package com.example.cache.service;
import com.example.cache.config.RedisPubSubConfig;
import com.example.cache.message.CacheEvictMessage;
import com.example.cache.model.Product;
import com.example.cache.repository.ProductRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private static final String CACHE_NAME = "product";
private final ProductRepository productRepository;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public ProductService(ProductRepository productRepository,
StringRedisTemplate stringRedisTemplate,
ObjectMapper objectMapper) {
this.productRepository = productRepository;
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
}
@Cacheable(cacheNames = CACHE_NAME, key = "#id", unless = "#result == null")
public Product getById(Long id) {
return productRepository.findById(id);
}
public Product update(Product product) {
Product updated = productRepository.update(product);
// 先删 Redis/L1(通过后续查询重建)
stringRedisTemplate.delete(CACHE_NAME + "::" + product.getId());
// 广播所有实例清理本地缓存
try {
String msg = objectMapper.writeValueAsString(new CacheEvictMessage(CACHE_NAME, String.valueOf(product.getId())));
stringRedisTemplate.convertAndSend(RedisPubSubConfig.CACHE_EVICT_TOPIC, msg);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return updated;
}
}
12. Controller 层
package com.example.cache.controller;
import com.example.cache.model.Product;
import com.example.cache.service.ProductService;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@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.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 productService.update(product);
}
}
13. application.yml
spring:
redis:
host: localhost
port: 6379
cache:
type: simple
server:
port: 8080
logging:
level:
org.springframework.cache: debug
逐步验证清单
建议你按下面顺序验证,而不是一上来就压测。
1. 启动 Redis
redis-server
或者 Docker:
docker run -d --name redis -p 6379:6379 redis:6.2
2. 启动应用后访问接口
第一次访问:
curl http://localhost:8080/products/1
第一次会慢一些,因为会查“数据库”。
第二次访问同一个 key:
curl http://localhost:8080/products/1
这时应该明显更快,优先命中本地缓存。
3. 查看 Redis 中缓存
redis-cli
keys *
get "product::1"
4. 更新数据
curl -X PUT "http://localhost:8080/products/1?name=新机械键盘&price=399.00"
然后再次查询:
curl http://localhost:8080/products/1
应该能看到新值,并且缓存被重新构建。
5. 多实例验证
你可以启动两个实例:
80808081
更新其中一个实例的数据后,另一个实例查询时,本地缓存也应该被清理。
常见坑与排查
多级缓存好用,但坑也不少。下面这些问题,我基本都见过。
坑 1:@Cacheable 方法内部调用不生效
现象
同一个 Service 里:
public Product test(Long id) {
return this.getById(id);
}
结果发现缓存注解没生效。
原因
Spring Cache 基于 AOP 代理,类内部自调用不会走代理。
解决方式
- 把缓存方法放到独立 Bean
- 或注入代理对象再调用
- 或用
AopContext.currentProxy(),但不建议滥用
坑 2:本地缓存和 Redis TTL 不一致
现象
Redis 已过期,但本地缓存还活着,导致短时间返回旧数据。
原因
L1/L2 的过期时间设置不同步。
建议
- 本地缓存 TTL 通常应 小于等于 Redis TTL
- 热点 key 可让 L1 更短,减少脏数据窗口
- 写敏感数据尽量主动失效,而不是只靠 TTL
坑 3:更新后仍然读到旧值
现象
刚更新完商品,立刻查询,有时还是旧值。
可能原因
- 本地缓存未及时清理
- Redis Pub/Sub 消息丢失或消费者异常
- 先更新缓存再更新数据库,顺序不当
- 序列化后的 key 类型不一致,如
1和"1"
排查路径
- 看数据库记录是否已更新
- 看 Redis 对应 key 是否删除
- 看应用日志中是否收到失效消息
- 检查缓存 key 生成规则是否完全一致
坑 4:缓存穿透
现象
大量请求访问不存在的 ID,例如 99999999,缓存一直 miss,数据库被打满。
解决思路
- 缓存空值,设置较短 TTL
- 接口参数合法性校验
- 对恶意请求做限流
如果你要缓存空值,unless = "#result == null" 就不能用了,需要改策略。
坑 5:缓存雪崩
现象
大量 key 同一时间过期,请求瞬间压向 Redis/数据库。
解决思路
- TTL 加随机值
- 热点数据预热
- 多级缓存分担流量
- 限流、降级、熔断一起上
坑 6:序列化兼容问题
现象
对象结构调整后,旧缓存反序列化失败。
建议
- 尽量缓存 DTO,而不是复杂领域对象
- 控制缓存对象字段稳定性
- 重大结构变更时升级 key 前缀,例如
product:v2::1
安全/性能最佳实践
这部分我尽量说一些“能直接落地”的建议。
1. key 设计要规范
建议统一格式:
业务名:实体名:版本:主键
比如:
product:detail:v1:1
本文示例中用了 cacheName::key,够演示,但线上最好再细化,不然跨业务排查会痛苦。
2. 不要缓存超大对象
缓存不是对象仓库。超大 JSON 会带来:
- Redis 内存膨胀
- 网络传输变慢
- 反序列化耗时上升
建议只缓存接口真正需要的数据视图。
3. 给 TTL 加随机抖动
例如 Redis TTL 不是固定 300 秒,而是:
- 300 ~ 360 秒随机
这样能有效降低同一时刻批量过期。
示意代码:
int base = 300;
int random = ThreadLocalRandom.current().nextInt(60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(base + random));
4. 热点 key 做单飞保护
示例里用了 synchronized,但线上更推荐:
- Caffeine 自带加载能力
- 分布式锁
- 基于 key 的细粒度锁容器
因为 intern() 有额外风险,尤其在大量不同 key 的场景下并不优雅。
5. 失效通知不要只依赖 Pub/Sub
Redis Pub/Sub 的问题是:它不是可靠消息。
如果实例在短暂重启期间错过消息,就可能保留旧的本地缓存。
更稳妥的方案有两个:
- 使用 Redis Stream / MQ 做可靠失效消息
- 给本地缓存设置更短 TTL,当作兜底
我的建议是:
- 对一致性要求一般的场景:Pub/Sub + 短 TTL
- 对一致性要求更高的场景:MQ + 版本号校验
6. 更新顺序要谨慎
比较推荐的写路径是:
- 更新数据库
- 删除 Redis 缓存
- 广播删除本地缓存
- 后续读请求重建缓存
不要轻易采用“先更新缓存再更新数据库”,失败时更难收拾。
7. 监控指标要补齐
如果没有监控,多级缓存出了问题很难定位。
至少要看这些指标:
- 本地缓存命中率
- Redis 命中率
- 数据库查询 QPS
- 缓存重建耗时
- 缓存失效消息发送/消费失败数
- 热点 key TOP N
8. 敏感数据不要直接缓存
例如:
- 用户隐私信息
- 令牌、密钥
- 高敏感业务状态
即使要缓存,也应:
- 做字段脱敏
- 缩短 TTL
- 严格控制 key 命名和访问边界
一个更稳妥的优化方向
如果你准备在生产环境继续增强,我建议重点补三件事:
1. 本地缓存只删本地
现在的订阅器调用 cache.evict(key),会把 Redis 也删一次。可以在 MultiLevelCache 增加方法:
public void evictLocal(Object key) {
localCache.evict(key);
}
然后在订阅器里判断类型后调用 evictLocal(),避免重复操作 Redis。
2. 缓存空值
对恶意或高频不存在数据,建议缓存空对象占位,例如:
public class NullValue implements Serializable {
}
然后设置短 TTL,比如 30 秒,能显著降低穿透。
3. 加版本号避免旧值覆盖新值
对于更新频繁的数据,可以在 value 中带 version/updateTime:
- 查询时比较版本
- 本地缓存写入时校验版本单调递增
这样可以减少极端并发下旧数据回填覆盖新数据的问题。
方案边界与适用场景
这套方案最适合:
- 读多写少
- 热点明显
- 接口延迟敏感
- 可接受短暂最终一致
不太适合:
- 强一致要求极高的资金类核心写场景
- 更新极其频繁的数据
- 单条缓存对象特别大的场景
- 本地内存非常紧张的应用
换句话说,不是所有接口都值得上多级缓存。不要为了“看起来高级”而过度设计。
总结
回到文章标题,多级缓存真正带来的价值,不只是“快”,而是:
- 用 Caffeine 拦住热点请求,降低接口 RT
- 用 Redis 做共享缓存,减轻数据库压力
- 用 Spring Cache 把接入成本压低
- 用 Pub/Sub 失效通知 尽量控制多实例一致性问题
如果你现在正准备在 Spring Boot 项目里做缓存升级,我建议按这个顺序推进:
- 先把单层 Redis 缓存打稳
- 再为热点接口加本地缓存
- 补上缓存失效广播
- 最后完善监控、空值缓存、随机 TTL、热点保护
一句实话:缓存方案没有银弹,只有取舍。
但对于大多数中后台和内容型接口来说,Spring Cache + Caffeine + Redis 这个组合,已经是一个很实用、性价比很高的答案。
如果你要上线生产版,记住两个底线:
- 一致性靠机制兜底,不要只靠 TTL
- 性能优化要靠监控验证,不要凭感觉
做到这两点,多级缓存就不只是“能跑”,而是真正“敢用”。