背景与问题
做接口优化时,很多同学第一反应是“上 Redis”。这当然没错,但项目跑起来后,往往会遇到第二层问题:
- Redis 已经扛住了数据库,但热点接口还是不够快
- Redis 网络抖动时,接口 RT 明显升高
- 本地 JVM 内其实还有大量重复查询
- 数据更新后,缓存删除了,但部分实例上还是读到旧值
- 使用
@Cacheable很方便,却不知道怎么做“本地缓存 + Redis”两级协同
我自己在业务里踩过一个很典型的坑:某个商品详情接口 QPS 不低,数据库压力是下来了,但 Redis 成了新的热点。进一步排查才发现,大量请求在短时间内反复读取同一个 key,明明应用节点内存还很富余,却没有把“最近频繁访问的数据”留在本地。
这篇文章就从这个问题出发,带你做一套Spring Boot + Spring Cache + Redis 的多级缓存方案,重点不是“能跑”,而是:
- 为什么要做两级缓存
- Spring Cache 在这里扮演什么角色
- 如何设计缓存读写链路
- 如何处理一致性、穿透、击穿、雪崩这些现实问题
- 遇到缓存不生效、序列化异常、更新不一致时怎么排查
文章示例基于:
- Spring Boot 2.x/3.x 思路通用
- Spring Cache
- Redis
- Caffeine 作为本地缓存实现
这里我选择 Caffeine + Redis,因为它是 Java 应用里非常常见的一种多级缓存组合:本地一级缓存负责极低延迟,Redis 二级缓存负责跨实例共享。
前置知识与环境准备
你需要知道什么
如果你已经用过下面这些东西,阅读会很顺:
- Spring Boot 基础开发
@Cacheable/@CachePut/@CacheEvict- Redis 基础概念
- Maven 或 Gradle 依赖管理
示例环境
- JDK 17
- Spring Boot 3.x
- Redis 6.x+
- Maven
我们要实现的目标
以“商品详情查询”为例,访问链路设计如下:
- 先查本地缓存(Caffeine)
- 本地没有,再查 Redis
- Redis 没有,再查数据库
- 查到数据库后,回填 Redis 和本地缓存
- 更新商品时,清理两级缓存,尽量保证数据一致
核心原理
什么是多级缓存
多级缓存,本质上是把不同层级、不同特性的缓存组合起来:
- 一级缓存(L1):应用本地内存缓存,速度最快,但不能跨实例共享
- 二级缓存(L2):Redis 这类分布式缓存,稍慢于本地内存,但能跨服务实例共享
它们的典型特点如下:
| 层级 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| L1 | Caffeine / ConcurrentMap | 延迟极低、无网络开销 | 多实例不共享、容量有限 |
| L2 | Redis | 共享、容量更大、支持失效策略 | 有网络开销、可成为热点 |
| DB | MySQL/PG 等 | 数据最终来源 | 最慢,压力最大 |
读请求链路
flowchart LR
A[请求进入] --> B{本地缓存命中?}
B -- 是 --> C[返回结果]
B -- 否 --> D{Redis 命中?}
D -- 是 --> E[写入本地缓存]
E --> C
D -- 否 --> F[查询数据库]
F --> G[写入 Redis]
G --> H[写入本地缓存]
H --> C
这个链路的收益非常直接:
- 热点数据大部分命中本地缓存,接口 RT 最低
- Redis 压力进一步下降
- 数据库只兜底
写请求链路与一致性
多级缓存的难点不在“查”,而在“改”。
常见做法是:
- 更新数据库
- 删除 Redis 缓存
- 删除本地缓存
注意我这里强调的是删除缓存,不是“更新缓存值”。原因很现实:
- 更新缓存容易引入复杂逻辑和并发覆盖问题
- 删缓存让后续查询自动回源重建,通常更稳
但删除缓存也不是银弹,仍然有几个一致性挑战:
- 多实例本地缓存如何同步失效
- 删除缓存和读请求并发时,是否会回填旧值
- Redis 已删,本地仍旧命中的窗口期怎么办
下面这张时序图可以帮助理解:
sequenceDiagram
participant U as User
participant S1 as App实例A
participant S2 as App实例B
participant R as Redis
participant DB as Database
U->>S1: 更新商品
S1->>DB: update product
DB-->>S1: success
S1->>R: delete product:1
S1->>S1: evict local cache
S1-->>S2: 发布失效通知
S2->>S2: evict local cache
U->>S2: 查询商品
S2->>S2: miss local cache
S2->>R: miss
S2->>DB: select product
DB-->>S2: latest data
S2->>R: set cache
S2->>S2: set local cache
Spring Cache 在方案中的角色
Spring Cache 不是缓存本身,它更像是一层统一抽象。你可以用它:
- 用注解声明缓存行为
- 通过
CacheManager管理缓存 - 切换不同缓存实现而不大量改业务代码
但是,Spring 默认并不会自动帮你做好“多级缓存一致性治理”。
也就是说:
@Cacheable很方便- 真正复杂的地方,还是在 缓存管理器设计、key 规范、失效广播、TTL 策略、异常降级
方案设计
这一版我采用一个比较实用的思路:
- 一级缓存:Caffeine
- 二级缓存:Redis
- 自定义
MultiLevelCache,实现 SpringCache接口 - 自定义
CacheManager,让业务侧继续使用@Cacheable - 数据更新后:
- 清理当前实例本地缓存
- 删除 Redis
- 通过 Redis Pub/Sub 广播其他实例清理本地缓存
这套设计兼顾了两点:
- 保留 Spring Cache 注解的开发体验
- 具备跨实例的本地缓存失效能力
实战代码(可运行)
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. 配置文件
spring:
data:
redis:
host: 127.0.0.1
port: 6379
cache:
type: none
server:
port: 8080
这里
spring.cache.type不直接交给默认实现,因为我们要自己接管CacheManager。
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. 实体与模拟仓库
Product.java
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;
}
}
ProductRepository.java
package com.example.multicache.repository;
import com.example.multicache.model.Product;
import org.springframework.stereotype.Repository;
import jakarta.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"), 1L));
storage.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00"), 1L));
}
public Product findById(Long id) {
sleep(100);
return storage.get(id);
}
public Product save(Product product) {
sleep(50);
Product old = storage.get(product.getId());
long version = old == null ? 1L : old.getVersion() + 1;
product.setVersion(version);
storage.put(product.getId(), product);
return product;
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ignored) {
}
}
}
5. 自定义多级缓存实现
CacheMessage.java
package com.example.multicache.cache;
import java.io.Serializable;
public class CacheMessage implements Serializable {
private String cacheName;
private String key;
public CacheMessage() {
}
public CacheMessage(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;
}
}
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<String, Object> redisTemplate;
private final Duration redisTtl;
public MultiLevelCache(String name,
com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache,
RedisTemplate<String, Object> redisTemplate,
Duration redisTtl) {
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.redisTtl = redisTtl;
}
private String buildRedisKey(Object key) {
return name + "::" + key;
}
@Override
public String getName() {
return 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();
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("Load value failed", 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 null;
}
return existing;
}
@Override
public void evict(Object key) {
localCache.invalidate(key);
redisTemplate.delete(buildRedisKey(key));
}
@Override
public boolean evictIfPresent(Object key) {
evict(key);
return true;
}
@Override
public void clear() {
localCache.invalidateAll();
}
@Override
public boolean invalidate() {
clear();
return true;
}
public void evictLocal(Object key) {
localCache.invalidate(key);
}
}
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.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final RedisTemplate<String, Object> redisTemplate;
private final Duration redisTtl;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisTemplate<String, Object> redisTemplate, Duration redisTtl) {
this.redisTemplate = redisTemplate;
this.redisTtl = redisTtl;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, n -> new MultiLevelCache(
n,
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.build(),
redisTemplate,
redisTtl
));
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
}
6. Redis 配置与消息监听
RedisConfig.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.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
@Configuration
public class RedisConfig {
public static final String CACHE_EVICT_TOPIC = "cache:evict:topic";
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
return new MultiLevelCacheManager(redisTemplate, Duration.ofMinutes(5));
}
@Bean
public ChannelTopic cacheEvictTopic() {
return new ChannelTopic(CACHE_EVICT_TOPIC);
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
MessageListener cacheMessageListener,
ChannelTopic cacheEvictTopic) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(cacheMessageListener, cacheEvictTopic);
return container;
}
}
CacheEvictMessageListener.java
package com.example.multicache.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.stereotype.Component;
@Component
public class CacheEvictMessageListener implements MessageListener {
private final CacheManager cacheManager;
private final GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
public CacheEvictMessageListener(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public void onMessage(Message message, byte[] pattern) {
CacheMessage cacheMessage = (CacheMessage) serializer.deserialize(message.getBody());
if (cacheMessage == null) {
return;
}
Cache cache = cacheManager.getCache(cacheMessage.getCacheName());
if (cache instanceof MultiLevelCache multiLevelCache) {
multiLevelCache.evictLocal(cacheMessage.getKey());
}
}
}
CacheSyncPublisher.java
package com.example.multicache.cache;
import com.example.multicache.config.RedisConfig;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class CacheSyncPublisher {
private final RedisTemplate<String, Object> redisTemplate;
public CacheSyncPublisher(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publishEvict(String cacheName, Object key) {
redisTemplate.convertAndSend(RedisConfig.CACHE_EVICT_TOPIC, new CacheMessage(cacheName, String.valueOf(key)));
}
}
7. 业务服务
ProductService.java
package com.example.multicache.service;
import com.example.multicache.cache.CacheSyncPublisher;
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 {
public static final String CACHE_NAME = "product";
private final ProductRepository repository;
private final CacheSyncPublisher cacheSyncPublisher;
public ProductService(ProductRepository repository, CacheSyncPublisher cacheSyncPublisher) {
this.repository = repository;
this.cacheSyncPublisher = cacheSyncPublisher;
}
@Cacheable(cacheNames = CACHE_NAME, key = "#id", unless = "#result == null")
public Product getById(Long id) {
return repository.findById(id);
}
@CacheEvict(cacheNames = CACHE_NAME, key = "#product.id")
public Product update(Product product) {
Product saved = repository.save(product);
cacheSyncPublisher.publishEvict(CACHE_NAME, product.getId());
return saved;
}
}
这里有一个细节:
@CacheEvict会删除当前实例对应 key 的缓存以及 Redis 中对应 key,
但其他实例的本地缓存不会自动删除,所以我们通过 Pub/Sub 补上这一步。
8. Controller
ProductController.java
package com.example.multicache.controller;
import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.constraints.NotNull;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public static class UpdateProductRequest {
@NotNull
private Long id;
@NotNull
private String name;
@NotNull
private BigDecimal price;
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 ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return productService.getById(id);
}
@PutMapping
public Product update(@RequestBody UpdateProductRequest request) {
Product product = new Product();
product.setId(request.getId());
product.setName(request.getName());
product.setPrice(request.getPrice());
return productService.update(product);
}
}
逐步验证清单
验证 1:本地缓存是否命中
第一次请求:
curl http://localhost:8080/products/1
第一次一般会比较慢,因为要走“本地 miss -> Redis miss -> DB”。
立刻再请求一次:
curl http://localhost:8080/products/1
第二次大概率直接命中本地缓存,响应更快。
验证 2:Redis 是否有缓存数据
redis-cli
keys product::*
get product::1
如果用了 JSON 序列化,能看到缓存对象内容。
验证 3:更新后缓存是否失效
curl -X PUT http://localhost:8080/products \
-H "Content-Type: application/json" \
-d '{"id":1,"name":"机械键盘Pro","price":399.00}'
再查询:
curl http://localhost:8080/products/1
应该能拿到最新值。
验证 4:多实例本地缓存同步失效
启动两个应用实例,分别访问同一个商品,让两个实例都把数据放进本地缓存。
然后在实例 A 上更新商品,观察实例 B 再次查询是否读取到新值。
如果 Pub/Sub 生效,实例 B 的本地缓存会被清掉,下一次会重新从 Redis 或 DB 加载。
常见坑与排查
多级缓存最好别一上来就“看起来很优雅”。它真正难的是线上行为。下面这些坑,基本都很常见。
1. @Cacheable 不生效
常见原因
- 没有加
@EnableCaching - 方法是
private - 同类内部自调用
- 方法抛异常,缓存逻辑没执行
key表达式写错
排查建议
先确认代理是否生效。最典型的情况是:
@Service
public class ProductService {
public Product query(Long id) {
return getById(id); // 自调用,缓存不会生效
}
@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
...
}
}
这类问题很多人第一次都会踩。我当时也是排查半天,最后发现根本没经过 Spring 代理。
2. Redis 有数据,但总是打到数据库
可能原因
- Redis key 不一致
- 序列化器不一致
unless = "#result == null"导致空值未缓存- L1/L2 key 生成策略不一致
排查建议
重点看三件事:
- 代码里构造的 key 是什么
redis-cli里真实存的 key 是什么- 使用的序列化器是不是统一
例如你在 @Cacheable 里用了复杂对象做 key,却在自定义缓存里直接 toString(),就容易出现逻辑 key 相同、实际 key 不同的情况。
3. 更新后短时间仍读到旧值
这是多级缓存最常见的“看起来像 bug,其实是设计窗口期”的问题。
可能原因
- 其他实例本地缓存未及时失效
- Pub/Sub 消息丢失或监听异常
- 并发下发生“删缓存后旧值回填”
- 先删缓存再更新数据库,顺序错了
推荐顺序
先更新数据库,再删缓存,再广播本地失效。
不要写成:
删缓存 -> 更新数据库
否则在并发读场景下,很容易把旧值重新写回缓存。
4. 缓存穿透
查询一个根本不存在的 ID,例如 99999999,每次都 miss,最终打到数据库。
解决办法
- 缓存空对象
- 加布隆过滤器
- 对非法参数提前拦截
示例里可以把“空值缓存”扩展一下,例如缓存一个特殊占位对象,并给较短 TTL。
5. 缓存击穿
某个热点 key 失效瞬间,大量请求同时回源数据库。
解决办法
- 热点 key 不设置过短 TTL
- 对加载逻辑加互斥控制
- 使用
sync = true
例如:
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public Product getById(Long id) {
return repository.findById(id);
}
sync = true 能降低同一实例内并发回源的问题,但不能天然解决多实例并发击穿,这一点要有边界认知。
6. 缓存雪崩
大量 key 在同一时间集中失效,导致 Redis 和数据库被瞬时流量打爆。
解决办法
- TTL 加随机值
- 热点数据预热
- 多级缓存分层兜底
- 限流降级
例如 Redis TTL 不是固定 300 秒,而是:
300 ~ 360 秒随机
这样可以明显降低同一时刻批量失效的概率。
安全/性能最佳实践
这一部分我尽量讲“能直接落地的”。
1. key 设计要稳定、可读、低冲突
推荐格式:
业务名:实体名:主键
比如:
product:detail:1
user:profile:1001
虽然本文示例用了 cacheName::key,但在复杂项目里,我更建议明确业务语义,方便排查和运维。
2. 本地缓存容量不要拍脑袋
Caffeine 很快,但它吃的是 JVM 堆内存。容量过大时:
- Full GC 风险增加
- 老年代压力变大
- 可能把应用本身拖慢
建议从这些维度评估:
- 热点 key 数量
- 单对象平均大小
- 实例堆内存大小
- GC 指标
如果你都没监控,先保守一点,比如 maximumSize=1000~5000 起步,再逐步调优。
3. Redis TTL 与本地 TTL 不要完全相同
这是个很容易忽略的细节。
如果 L1 和 L2 同时过期,大量请求会一起回源。更稳妥的做法是:
- 本地缓存 TTL 短一点
- Redis TTL 长一点
- 都加一点随机抖动
例如:
- Caffeine:30 秒
- Redis:5 分钟
这样 L1 失效后,大概率还能命中 Redis,不至于直接打数据库。
4. 广播失效要考虑“最终一致”而不是“绝对一致”
基于 Redis Pub/Sub 的本地缓存失效方案,有几个现实边界:
- 订阅端临时断连可能丢消息
- 应用重启期间错过通知
- 广播不是事务的一部分
所以它更适合:
- 商品详情
- 配置类读多写少数据
- 非强一致要求场景
如果你面对的是库存、余额、优惠券核销这类强一致业务,建议不要让本地缓存参与核心决策链路。
5. 防止缓存数据被污染
缓存中不要直接存放未经校验、体积过大的对象。建议:
- DTO 化后再缓存
- 避免缓存敏感字段
- 对 value 大小做控制
- 设置合理 TTL
尤其是用户信息、权限信息、令牌类数据,缓存前一定要审查字段。
6. 加监控,不然优化就是盲飞
至少需要监控这些指标:
- 本地缓存命中率
- Redis 命中率
- 数据库回源次数
- 平均响应时间 / P95 / P99
- Redis 连接池使用率
- 缓存失效广播数量与失败数
很多项目缓存“理论上很快”,但没有命中率监控,最后只能靠感觉调参数,这基本不靠谱。
方案边界与取舍
不是所有场景都适合多级缓存。
适合的场景
- 读多写少
- 热点明显
- 对延迟敏感
- 可以接受短暂最终一致
比如:
- 商品详情
- 字典配置
- 页面聚合接口
- 用户公开资料
不适合的场景
- 高频更新
- 强一致要求高
- 单条数据非常大
- 缓存键空间极其离散,热点不明显
比如:
- 库存扣减
- 账户余额
- 实时风控决策
- 高频变更订单状态
如果更新非常频繁,本地缓存失效广播会变得很重,收益可能反而不明显。
再补一张结构图:组件关系
classDiagram
class ProductController {
+get(id)
+update(request)
}
class ProductService {
+getById(id)
+update(product)
}
class MultiLevelCacheManager {
+getCache(name)
}
class MultiLevelCache {
+get(key)
+put(key, value)
+evict(key)
+evictLocal(key)
}
class RedisTemplate
class Caffeine
class ProductRepository
class CacheSyncPublisher
class CacheEvictMessageListener
ProductController --> ProductService
ProductService --> ProductRepository
ProductService --> CacheSyncPublisher
MultiLevelCacheManager --> MultiLevelCache
MultiLevelCache --> RedisTemplate
MultiLevelCache --> Caffeine
CacheEvictMessageListener --> MultiLevelCacheManager
常见优化方向
如果你已经把这套方案跑起来了,后面还可以继续演进:
1. 空值缓存
对不存在的数据缓存一个短 TTL 的占位对象,减少穿透。
2. 逻辑过期
对热点数据不直接“物理过期”,而是在 value 中带过期时间,后台线程异步刷新,适合极热点场景。
3. 分布式锁防击穿
对 Redis miss 且热点 key,使用分布式锁控制单线程回源数据库。
4. 缓存预热
系统启动后,提前加载热点数据进入 Redis 或本地缓存。
5. 按业务拆分缓存策略
不要所有 cacheName 都共用同一套 TTL / 最大容量。
更合理的方式是:
- 商品详情:TTL 较长
- 用户资料:TTL 中等
- 活动配置:本地缓存短、Redis 长
- 列表类接口:谨慎缓存,注意分页和组合条件爆炸
总结
如果你想在 Spring Boot 里把缓存做得既“好用”又“能上线”,一个比较实用的思路就是:
- 用 Spring Cache 保持业务代码简洁
- 用 Caffeine 承担本地一级缓存
- 用 Redis 承担分布式二级缓存
- 用 删除缓存 + 广播本地失效 处理多实例一致性
- 用 TTL 随机化、空值缓存、热点保护 处理高并发问题
可以把这篇文章里的结论浓缩成 5 条执行建议:
- 先做 L2 Redis,再考虑 L1 本地缓存,别过早复杂化
- 更新流程一定是先改 DB,再删缓存
- 本地缓存必须考虑跨实例失效
- 缓存注解能省代码,但一致性治理仍要自己设计
- 命中率、回源量、延迟监控必须补齐
最后给一个边界提醒:
多级缓存非常适合“读多写少、允许短暂最终一致”的接口优化,但不适合承担强一致核心业务。这个边界守住了,方案就能真正发挥价值,而不是成为新的隐患。