Spring Boot 中基于 Spring Cache + Redis 实现多级缓存与缓存一致性的实战指南
在很多业务系统里,缓存不是“要不要上”的问题,而是“什么时候会不够用”。
我自己做 Spring Boot 项目时,最常见的演进路径是这样的:
- 一开始直接查数据库,简单直给
- 后来接口变慢,先接 Redis
- 再后来发现 Redis 也不是万能的,热点数据频繁走网络,延迟还是不够低
- 最终会走到多级缓存:应用本地缓存 + Redis 分布式缓存 + 数据库
但多级缓存真正难的地方,从来不是“把缓存加上”,而是缓存一致性怎么做,故障时怎么兜底,更新时怎么避免脏数据。
这篇文章不讲空泛概念,我会带你从一个可以运行的 Spring Boot 示例出发,搭建:
- Spring Cache 统一缓存入口
- Caffeine 作为一级本地缓存
- Redis 作为二级分布式缓存
- 基于“旁路缓存(Cache Aside)”的读写策略
- 利用消息通知实现多节点本地缓存失效
- 常见坑位与排查方式
这套方案不一定适合所有系统,但对于读多写少、对延迟敏感、允许短暂最终一致性的中大型业务,非常实用。
背景与问题
先明确一个现实问题:为什么单用 Redis 还不够?
典型场景
比如商品详情、用户画像、配置中心读取、首页推荐元数据等场景,有几个特点:
- 读多写少
- 热点明显
- 请求量大
- 接口对 RT 比较敏感
如果只有 Redis:
- 每次请求都要走网络
- Redis 自身会有连接池、序列化、反序列化开销
- 热点 key 会集中打到 Redis
- 应用实例扩容后,Redis 压力并不会线性下降
这时候引入本地缓存(如 Caffeine)就很自然了:
- 一级缓存:JVM 本地,速度极快
- 二级缓存:Redis,共享数据
- 三级存储:数据库,最终来源
但新问题也随之而来
多级缓存一上,大家最容易踩的坑就是一致性:
- 数据库更新了,本地缓存还没删
- 本地缓存删了,Redis 还在
- 一个节点更新了,其他节点本地缓存没失效
- 并发更新导致旧值回写
- 缓存击穿、穿透、雪崩同时出现时,系统直接抖动
所以,问题的本质不是“怎么缓存”,而是:
如何在 Spring Boot 中优雅地实现多级缓存,并把一致性控制在业务可接受范围内。
前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- Maven
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>
核心原理
我们先把整体设计想清楚,再写代码。
多级缓存结构
flowchart TD
A[客户端请求] --> B[Spring Cache]
B --> C{一级缓存 Caffeine}
C -- 命中 --> D[返回数据]
C -- 未命中 --> E{二级缓存 Redis}
E -- 命中 --> F[写回一级缓存]
F --> D
E -- 未命中 --> G[查询数据库]
G --> H[写入 Redis]
H --> I[写入 Caffeine]
I --> D
推荐读写策略:Cache Aside
这个策略是实际项目里最常见、也最容易掌控的。
读流程
- 先查本地缓存
- 本地未命中,查 Redis
- Redis 未命中,查数据库
- 查到后回填 Redis 和本地缓存
写流程
- 先更新数据库
- 再删除缓存,而不是更新缓存
- 删除 Redis 后,通知所有节点删除本地缓存
为什么更推荐删除缓存而不是更新缓存?
因为更新缓存的逻辑容易复杂化:
- 更新顺序难保证
- 序列化对象容易出现局部字段不一致
- 多节点情况下更新广播更难做
- 删除通常更简单,下一次读会自动回源重建
一致性边界
这里要讲一句实话:缓存一致性往往不是绝对一致,而是“业务可接受的一致性”。
常见级别:
- 强一致:非常难、成本高,缓存通常不适合强一致核心链路
- 最终一致:大多数业务可接受,尤其是读多写少场景
- 短暂不一致可接受:比如商品标题、配置、画像标签等
如果你的场景是:
- 扣库存
- 余额
- 订单状态强校验
那缓存只能辅助,不能当真相来源。
实现方案设计
为了兼顾 Spring Cache 易用性和多级缓存能力,我们做一个组合方案:
- 自定义
CacheManager - 每个缓存操作先走 Caffeine,再走 Redis
- 数据写入/删除时同步处理两级缓存
- Redis Pub/Sub 通知其他节点清理本地缓存
方案架构图
sequenceDiagram
participant Client as Client
participant App1 as App Node A
participant L1 as Caffeine
participant L2 as Redis
participant DB as Database
participant MQ as Redis PubSub
participant App2 as App Node B
Client->>App1: 查询商品
App1->>L1: get(key)
alt L1命中
L1-->>App1: value
App1-->>Client: 返回
else L1未命中
App1->>L2: get(key)
alt L2命中
L2-->>App1: value
App1->>L1: put(key, value)
App1-->>Client: 返回
else L2未命中
App1->>DB: select by id
DB-->>App1: data
App1->>L2: put
App1->>L1: put
App1-->>Client: 返回
end
end
Client->>App1: 更新商品
App1->>DB: update
App1->>L2: evict(key)
App1->>L1: evict(key)
App1->>MQ: publish evict event
MQ-->>App2: evict(key)
App2->>App2: 清理本地L1
实战代码(可运行)
下面给出一套简化但完整的实现。
1. 配置文件
application.yml
server:
port: 8080
spring:
cache:
cache-names:
- product
data:
redis:
host: localhost
port: 6379
timeout: 3000ms
logging:
level:
root: info
com.example.cache: debug
2. 实体与模拟仓库
Product.java
package com.example.cache.entity;
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
这里为了让示例能跑,我用 ConcurrentHashMap 模拟数据库。
package com.example.cache.repository;
import com.example.cache.entity.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> storage = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
storage.put(1L, new Product(1L, "机械键盘", new BigDecimal("399.00"), 1L));
storage.put(2L, new Product(2L, "无线鼠标", new BigDecimal("129.00"), 1L));
}
public Product findById(Long id) {
sleep(100);
return storage.get(id);
}
public Product update(Product product) {
sleep(100);
storage.put(product.getId(), product);
return product;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
3. Redis 序列化配置
默认 JDK 序列化可读性差、兼容性也一般,我更建议直接用 JSON。
RedisConfig.java
package com.example.cache.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
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);
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
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;
}
}
提醒一下:
activateDefaultTyping在安全上要谨慎,生产环境最好结合明确类型边界或使用更收敛的序列化方式,后面我会单独讲。
4. 自定义多级缓存实现
Spring Cache 的关键扩展点是 Cache 和 CacheManager。
我们定义一个 TwoLevelCache,把 Caffeine 和 Redis 组合起来。
TwoLevelCache.java
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 TwoLevelCache implements Cache {
private final String name;
private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
private final RedisTemplate<String, Object> redisTemplate;
private final Duration ttl;
public TwoLevelCache(String name,
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache,
RedisTemplate<String, 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 new SimpleValueWrapper(value);
}
value = redisTemplate.opsForValue().get(buildKey(key));
if (value != null) {
caffeineCache.put(key, value);
return new SimpleValueWrapper(value);
}
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("缓存值类型不匹配,期望: " + type + ", 实际: " + value.getClass());
}
return (T) value;
}
@Override
@SuppressWarnings("unchecked")
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();
put(key, value);
return value;
} 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 boolean evictIfPresent(Object key) {
evict(key);
return true;
}
@Override
public void clear() {
caffeineCache.invalidateAll();
}
@Override
public boolean invalidate() {
clear();
return true;
}
}
5. 自定义 CacheManager
TwoLevelCacheManager.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 TwoLevelCacheManager implements CacheManager {
private final RedisTemplate<String, Object> redisTemplate;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
private final Duration ttl;
public TwoLevelCacheManager(RedisTemplate<String, Object> redisTemplate, Duration ttl) {
this.redisTemplate = redisTemplate;
this.ttl = ttl;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, cacheName -> new TwoLevelCache(
cacheName,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.build(),
redisTemplate,
ttl
));
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
}
CacheConfig.java
package com.example.cache.config;
import com.example.cache.cache.TwoLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.cache.annotation.EnableCaching;
import java.time.Duration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate) {
return new TwoLevelCacheManager(redisTemplate, Duration.ofMinutes(5));
}
}
6. 业务服务:读写缓存
这里使用 Spring Cache 注解,让业务代码保持清爽。
ProductService.java
package com.example.cache.service;
import com.example.cache.entity.Product;
import com.example.cache.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;
private final CacheInvalidationPublisher invalidationPublisher;
public ProductService(ProductRepository repository,
CacheInvalidationPublisher invalidationPublisher) {
this.repository = repository;
this.invalidationPublisher = invalidationPublisher;
}
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
return repository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
Product updated = new Product(
product.getId(),
product.getName(),
product.getPrice(),
product.getVersion() == null ? 1L : product.getVersion() + 1
);
Product result = repository.update(updated);
invalidationPublisher.publish("product", product.getId().toString());
return result;
}
}
这里我故意用了“更新数据库后删缓存”的思路,而不是直接
@CachePut。
因为在多节点环境里,删除通常比更新更稳。
7. 多节点本地缓存失效通知
如果系统只有一个实例,那删本地缓存就结束了。
但线上通常是多个实例,所以某个节点更新后,其他节点的 Caffeine 也得删。
我们用 Redis Pub/Sub 做一个轻量广播。
CacheMessage.java
package com.example.cache.message;
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;
}
}
CacheInvalidationPublisher.java
package com.example.cache.service;
import com.example.cache.message.CacheMessage;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class CacheInvalidationPublisher {
public static final String CHANNEL = "cache:invalidation";
private final RedisTemplate<String, Object> redisTemplate;
public CacheInvalidationPublisher(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publish(String cacheName, String key) {
redisTemplate.convertAndSend(CHANNEL, new CacheMessage(cacheName, key));
}
}
CacheInvalidationSubscriber.java
package com.example.cache.service;
import com.example.cache.cache.TwoLevelCache;
import com.example.cache.message.CacheMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.stereotype.Component;
@Component
public class CacheInvalidationSubscriber implements MessageListener {
private static final Logger log = LoggerFactory.getLogger(CacheInvalidationSubscriber.class);
private final CacheManager cacheManager;
public CacheInvalidationSubscriber(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody());
log.info("收到缓存失效消息: {}", body);
}
}
上面这个 subscriber 只是打日志,还不能真正反序列化消息并清本地缓存。
我们再补上监听容器和实际处理。
RedisListenerConfig.java
package com.example.cache.config;
import com.example.cache.service.CacheInvalidationPublisher;
import com.example.cache.service.LocalCacheInvalidationListener;
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;
@Configuration
public class RedisListenerConfig {
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
LocalCacheInvalidationListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listener, new ChannelTopic(CacheInvalidationPublisher.CHANNEL));
return container;
}
}
LocalCacheInvalidationListener.java
package com.example.cache.service;
import com.example.cache.message.CacheMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
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.stereotype.Component;
@Component
public class LocalCacheInvalidationListener implements MessageListener {
private final CacheManager cacheManager;
private final ObjectMapper objectMapper = new ObjectMapper();
public LocalCacheInvalidationListener(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public void onMessage(Message message, byte[] pattern) {
try {
CacheMessage cacheMessage = objectMapper.readValue(message.getBody(), CacheMessage.class);
Cache cache = cacheManager.getCache(cacheMessage.getCacheName());
if (cache != null) {
cache.evict(cacheMessage.getKey());
}
} catch (Exception e) {
throw new RuntimeException("处理缓存失效消息失败", e);
}
}
}
修正发布消息格式
因为监听端用 ObjectMapper 读 JSON,所以发布端最好显式发 JSON。
package com.example.cache.service;
import com.example.cache.message.CacheMessage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class CacheInvalidationPublisher {
public static final String CHANNEL = "cache:invalidation";
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
public CacheInvalidationPublisher(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void publish(String cacheName, String key) {
try {
String msg = objectMapper.writeValueAsString(new CacheMessage(cacheName, key));
stringRedisTemplate.convertAndSend(CHANNEL, msg);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
这里你会发现:消息发布建议单独用
StringRedisTemplate,不要和业务缓存序列化混在一起。
这是我很推荐的一个小习惯,后面排查问题时省很多事。
8. 控制器
ProductController.java
package com.example.cache.controller;
import com.example.cache.entity.Product;
import com.example.cache.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return service.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody @Valid Product product) {
product.setId(id);
return service.update(product);
}
}
9. 启动类
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);
}
}
逐步验证清单
到这里,项目已经具备最小可运行能力。你可以按下面顺序验证。
1)首次查询,走数据库
curl http://localhost:8080/products/1
第一次会慢一点,因为模拟数据库有 sleep(100)。
2)再次查询,命中缓存
再请求一次:
curl http://localhost:8080/products/1
理论上会更快,优先命中本地缓存。
3)更新数据,触发缓存删除
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{
"name":"机械键盘Pro",
"price":499.00,
"version":1
}'
4)再次查询,回源并重建缓存
curl http://localhost:8080/products/1
如果你起多个应用实例,就可以观察 Redis Pub/Sub 广播失效消息的行为。
一致性实现的关键细节
上面的代码能跑,但真实项目里还要把一些细节想透。
1. 为什么更新后删除缓存,而不是先删再更新?
很多人会问:到底是“先更新库再删缓存”,还是“先删缓存再更新库”?
通常推荐:
- 更新数据库
- 删除缓存
原因是如果你先删缓存,再更新数据库,可能出现这个窗口:
- 线程 A 删除缓存
- 线程 B 读缓存未命中,查数据库读到旧值,又写回缓存
- 线程 A 更新数据库成功
- 缓存里反而是旧值
这就是经典并发脏写问题。
2. 删除两级缓存时,要注意什么?
本地缓存和 Redis 都要删。
但多个节点之间,本地缓存还需要广播。
可以理解成三步:
- 当前节点删本地缓存
- 删除 Redis 缓存
- 发布失效事件,让其他节点删本地缓存
3. 广播时为什么不要直接调用 cache.evict() 删除全部?
因为我们只想删某一个 key。
如果粗暴清空整个缓存,会带来:
- 短时间大量回源
- Redis/QPS 抖动
- 热点数据集中重建
常见坑与排查
这部分我建议你认真看。很多问题平时没感觉,一上生产就很真实。
坑 1:@Cacheable 不生效
常见原因
- 没加
@EnableCaching - 方法不是
public - 同类内部自调用
- Spring 管理的 Bean 没有被代理到
例子
@Service
public class ProductService {
@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
return repository.findById(id);
}
public Product test(Long id) {
return this.getById(id); // 自调用,缓存不会生效
}
}
排查建议
- 看启动日志里是否启用了缓存代理
- 在
getById()方法里打日志,确认是否每次都进方法体 - 遇到自调用,可拆分到另一个 Service,或从代理对象调用
坑 2:本地缓存失效消息导致 Redis 也被删了
这是一个很隐蔽但很常见的设计错误。
我们前面的 cache.evict(key) 会同时删除:
- 本地缓存
- Redis 缓存
但其他节点收到“本地失效通知”时,如果也调用这个方法,就把 Redis 重复删了一遍。
虽然多数情况下问题不大,但会有副作用:
- 无意义的 Redis 删除
- 并发重建窗口被放大
- 排查时行为不清晰
更合理的做法
给 TwoLevelCache 增加一个只删本地缓存的方法。
改进版 TwoLevelCache.java
public void evictLocal(Object key) {
caffeineCache.invalidate(key);
}
然后监听器里判断类型后,只删本地:
package com.example.cache.service;
import com.example.cache.cache.TwoLevelCache;
import com.example.cache.message.CacheMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
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.stereotype.Component;
@Component
public class LocalCacheInvalidationListener implements MessageListener {
private final CacheManager cacheManager;
private final ObjectMapper objectMapper = new ObjectMapper();
public LocalCacheInvalidationListener(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public void onMessage(Message message, byte[] pattern) {
try {
CacheMessage cacheMessage = objectMapper.readValue(message.getBody(), CacheMessage.class);
Cache cache = cacheManager.getCache(cacheMessage.getCacheName());
if (cache instanceof TwoLevelCache twoLevelCache) {
twoLevelCache.evictLocal(parseKey(cacheMessage.getKey()));
}
} catch (Exception e) {
throw new RuntimeException("处理缓存失效消息失败", e);
}
}
private Object parseKey(String key) {
try {
return Long.valueOf(key);
} catch (Exception e) {
return key;
}
}
}
这个坑我真踩过。看着只是“多删一次”,但高并发下会让回源曲线变得很难看。
坑 3:缓存穿透
当请求的 key 根本不存在时,每次都会打到数据库。
解决方案
- 缓存空值
- 布隆过滤器
- 参数校验,拦截非法 ID
比如空值缓存:
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
return repository.findById(id);
}
这个写法其实没有缓存 null。
如果你要防穿透,需要手动缓存一个空对象标记,或者使用包装结果。
坑 4:缓存击穿
某个热点 key 恰好过期,大量并发同时回源数据库。
解决方案
- 本地互斥锁 / 分布式锁
- 热点数据逻辑不过期
- 提前刷新
- 单飞(single flight)机制
简单示例:对热点 key 加锁回源。
private final ConcurrentHashMap<Object, Object> locks = new ConcurrentHashMap<>();
public <T> T getWithLock(Object key, Callable<T> loader) {
Object lock = locks.computeIfAbsent(key, k -> new Object());
synchronized (lock) {
ValueWrapper wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
try {
T value = loader.call();
put(key, value);
return value;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
坑 5:缓存雪崩
大量 key 在同一时刻过期,瞬间把数据库打穿。
解决方案
- TTL 加随机值
- 分批预热
- 热点数据永不过期 + 异步刷新
- Redis 限流降级
比如 TTL 增加抖动:
Duration ttl = Duration.ofMinutes(5).plusSeconds(ThreadLocalRandom.current().nextInt(30));
redisTemplate.opsForValue().set(buildKey(key), value, ttl);
坑 6:序列化不兼容
代码升级后,类结构变了,旧缓存反序列化失败。
排查点
- Redis 中值格式是否统一
- 是否混用了 JDK 序列化和 JSON 序列化
- 是否存在历史版本字段不兼容
建议
- 缓存对象尽量使用稳定 DTO
- 升级时允许“删缓存重建”
- 不要把复杂领域对象直接长期缓存
安全/性能最佳实践
这部分非常关键,尤其是准备上生产时。
安全实践
1. 不要缓存敏感数据明文
比如:
- 身份证号
- 手机号
- token
- 支付信息
即使必须缓存,也要考虑:
- 脱敏
- 短 TTL
- 独立命名空间
- 访问控制
2. 谨慎使用默认多态反序列化
前面示例里为了方便演示用了:
mapper.activateDefaultTyping(...)
这在生产环境里需要谨慎,因为多态反序列化如果边界没控好,会有安全风险。
更推荐:
- 按缓存 value 类型分别定义
RedisTemplate - 使用明确 DTO 类型
- 避免对任意 Object 做宽泛反序列化
3. 缓存 key 不要直接拼接用户原始输入
比如搜索词、动态表达式、超长字符串,容易带来:
- key 爆炸
- 内存异常增长
- 热点分散失控
建议统一规范:
业务名:对象类型:主键[:字段]
例如:
mall:product:1
mall:user:1001:profile
性能实践
1. 一级缓存容量要克制
本地缓存不是越大越好。
太大可能带来:
- Old 区压力增加
- Full GC 变频繁
- 热点不明显时收益有限
建议从以下维度调参:
maximumSizeexpireAfterWrite- 命中率
- GC 情况
2. Redis TTL 和本地 TTL 分层设置
一般我会让:
- 本地缓存 TTL 更短
- Redis TTL 更长
这样做的好处是:
- 本地缓存更灵活,减轻脏数据风险
- Redis 保持共享缓存能力
- 节点重启后仍可从 Redis 快速恢复
例如:
- Caffeine:30 秒
- Redis:5 分钟
3. 热点 key 做特别治理
如果某几个 key QPS 特别高,不要完全依赖通用缓存框架,最好单独处理:
- 永不过期 + 异步刷新
- 预热
- 单独指标监控
- 限流保护
4. 监控指标必须补齐
最少监控这些:
- 本地缓存命中率
- Redis 命中率
- DB 回源次数
- 缓存删除次数
- Pub/Sub 消息堆积/丢失情况
- 热点 key 排名
没有指标,缓存问题几乎全靠猜。
一个更稳妥的落地建议
如果你准备真正用于生产,我建议按下面的优先级落地,而不是一步到位全上:
第一步:先做好单级 Redis 缓存
确保你已经有:
- 清晰的 key 规范
- TTL 策略
- 读写时序
- 基础监控
第二步:再引入本地缓存
优先加在这些场景:
- 超热点读请求
- 对 RT 极敏感接口
- 数据更新频率低
第三步:补齐多节点失效机制
至少做到:
- 更新 DB 后删 Redis
- 广播通知其他节点删本地缓存
- 失败有重试或兜底日志
第四步:治理并发与异常场景
包括:
- 空值缓存
- 热点锁
- TTL 抖动
- 降级开关
这是比较现实的演进方式,不容易把系统一下搞复杂。
再看一遍完整时序
stateDiagram-v2
[*] --> ReadRequest
ReadRequest --> CheckL1
CheckL1 --> HitL1: 命中
HitL1 --> ReturnData
CheckL1 --> CheckL2: 未命中
CheckL2 --> HitL2: 命中
HitL2 --> RebuildL1
RebuildL1 --> ReturnData
CheckL2 --> QueryDB: 未命中
QueryDB --> RebuildL2
RebuildL2 --> RebuildL1FromDB
RebuildL1FromDB --> ReturnData
ReturnData --> [*]
[*] --> WriteRequest
WriteRequest --> UpdateDB
UpdateDB --> DeleteRedis
DeleteRedis --> DeleteLocal
DeleteLocal --> PublishInvalidation
PublishInvalidation --> OtherNodesDeleteLocal
OtherNodesDeleteLocal --> [*]
总结
这篇文章我们完整走了一遍 Spring Boot 中多级缓存的典型落地方案:
- 用 Spring Cache 统一业务入口
- 用 Caffeine 做一级本地缓存
- 用 Redis 做二级共享缓存
- 用 Cache Aside 实现读写分离
- 用 Redis Pub/Sub 做多节点本地缓存失效通知
- 结合常见坑位处理一致性、击穿、穿透、雪崩问题
最后给你几个可以直接执行的建议:
-
先接受“最终一致性”这个前提
多级缓存很难做到绝对强一致,别把它用到余额、库存扣减这种核心强一致链路。 -
更新时优先“更新 DB + 删除缓存”
不要急着做缓存更新,删除通常更稳,也更容易排查问题。 -
本地缓存失效要做广播,但广播只删本地
不要让其他节点收到消息后再去重复删除 Redis。 -
TTL 分层设置,避免一起过期
本地短一点,Redis 长一点,并加随机抖动。 -
先监控再调优
命中率、回源次数、热点 key、删除次数,这些指标一定要有。
如果你的系统特点是读多写少、允许短暂不一致、追求低延迟,那这套方案非常值得上手。
如果你的业务要求的是强一致、严格事务语义,那缓存只能当加速器,别当真相源。
多级缓存真正的价值,不是“更快”这两个字,而是:在复杂业务下,用可控的方式换取性能收益。这件事做对了,系统会很舒服;做错了,线上会很热闹。