针对缓存雪崩的终极防御,引入 Caffeine(本地进程缓存) + Redis(分布式缓存) 组成多级缓存是目前互联网大厂最主流的落地方案。
当 Redis 不幸宕机,或者发生大面积 Key 集中过期时,请求会先在第一级缓存(Caffeine,在 JVM 内存中)被拦截并直接返回,根本不需要走到 Redis 层,更不会冲垮底层的 MySQL,从而实现完美的高可用兜底。
要深入后端开发和高并发架构,Caffeine 是一个你绝对绕不开的组件。在 Java 领域,它被称为**"缓存之王"**,也是 Spring Boot 官方默认支持和强烈推荐的本地缓存实现(用来全面替代老旧的 Guava Cache 和 Ehcache)。
Caffine 的三大"驱逐策略"(内存满了怎么办?)
因为 JVM 内存有限,Caffeine 不可能无限存储。当缓存达到设定的上限时,它必须把旧的数据踢出去。Caffeine 提供了三种被动淘汰机制:
- 基于大小
- 设置最大容纳多少个 Key(
maximumSize)。超过这个量,自动淘汰。
- 设置最大容纳多少个 Key(
- 基于时间
expireAfterWrite:从写入开始算起,过一段时间过期(最常用)。expireAfterAccess:从最后一次访问开始算起,过一段时间过期(适合留住热点数据)。
- 基于引用
- 利用 Java 的弱引用(
WeakReference)或软引用(SoftReference),在 JVM 内存告急、发生 GC 时,自动配合回收空间(较少主动配置)。
在 Spring Boot 生态中,最优雅、最常见的整合方案是重写 Spring Cache 的 CacheManager。下面是全套工业级可运行的代码实现:
- 利用 Java 的弱引用(
1. 核心设计架构
- 一级缓存(L1): Caffeine(本地内存,速度快到飞起,但各节点不共享,容量有限)。
- 二级缓存(L2): Redis(分布式缓存,容量大,多节点共享,但有网络 I/O 损耗)。
2. 引入依赖
在 pom.xml 中引入 Spring Cache、Redis 和 Caffeine 的相关 Starter:
xml
<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>
3. 核心:自定义多级缓存实现
我们需要自定义一个 Cache 类,让它在执行 get 时,先查 Caffeine,没有再查 Redis,并回写到 Caffeine。
3.1 编写自定义多级缓存类 DoubleCache
java
import com.github.ben-manes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
public class DoubleCache extends AbstractValueAdaptingCache {
private final String name;
private final Cache<Object, Object> caffeineCache;
private final RedisTemplate<Object, Object> redisTemplate;
private final long redisExpiry; // 二级缓存过期时间(秒)
public DoubleCache(String name, Cache<Object, Object> caffeineCache,
RedisTemplate<Object, Object> redisTemplate, long redisExpiry) {
super(true); // allowInvocationsWithNullComponents
this.name = name;
this.caffeineCache = caffeineCache;
this.redisTemplate = redisTemplate;
this.redisExpiry = redisExpiry;
}
@Override
public String getName() {
return this.name;
}
@Override
public Object getNativeCache() {
return this;
}
// 多级查找的核心
// 当 Spring 需要拿缓存时,首先会暗中调用这个方法:
@Override
protected Object lookup(Object key) {
String realKey = this.name + ":" + key;
// 1. 先查一级缓存
Object value = caffeineCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 一级缓存没中,查二级缓存
value = redisTemplate.opsForValue().get(realKey);
if (value != null) {
// 3. 二级缓存中了,回写到一级缓存,方便下次直接用
caffeineCache.put(key, value);
}
return value;
}
// 如果 lookup 找了一圈,发现本地没有,Redis 也没有(人称"双漏"),
// Spring 就会调用这个 get 方法:
@SuppressWarnings("unchecked")
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
// Double-Check(双重检查):拿到锁之后,不急着查库,
// 先再次调用 lookup(key) 看看别人是不是已经查完并写好缓存了。
Object value = lookup(key);
if (value != null) {
return (T) fromStoreValue(value);
}
// 4. 二级缓存也没中(双漏),触发锁,去查数据库(Spring Cache 自带同步机制)
synchronized (key) {
value = lookup(key);
if (value != null) return (T) fromStoreValue(value);
try {
T loadValue = valueLoader.call();
put(key, loadValue); // 写入多级缓存
return loadValue;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
}
//双写与"防雪崩"噪点
@Override
public void put(Object key, Object value) {
if (value == null) return;
// 同时写入一级和二级缓存
caffeineCache.put(key, value);
// 防雪崩核心:给 Redis 的过期时间加一个随机数(比如 1~5分钟随机),防止集体撞车过期
long randomExpiry = redisExpiry + (long) (Math.random() * 300);
redisTemplate.opsForValue().set(this.name + ":" + key, toStoreValue(value), randomExpiry, TimeUnit.SECONDS);
}
//双删(数据一致性)
@Override
public void evict(Object key) {
// 清除缓存
caffeineCache.invalidate(key);
redisTemplate.delete(this.name + ":" + key);
}
@Override
public void clear() {
caffeineCache.invalidateAll();
}
}
3.2 配置多级缓存管理器 CacheManager
将自定义的缓存注入到 Spring 容器中,让 @Cacheable 注解可以识别它。
java
import com.github.ben-manes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableCaching // 开启 Spring Cache 缓存注解支持
public class DoubleCacheConfig {
@Bean
public CacheManager cacheManager(RedisTemplate<Object, Object> redisTemplate) {
SimpleCacheManager cacheManager = new SimpleCacheManager();
List<DoubleCache> caches = new ArrayList<>();
// 定义一个名为 "product" 的缓存空间
caches.add(new DoubleCache(
"product",
Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 一级缓存本地生存 10 分钟
.maximumSize(1000) // 本地最多存 1000 条
.build(),
redisTemplate,
3600 // 二级缓存基准失效时间 1 小时
));
cacheManager.setCaches(caches);
return cacheManager;
}
}
4. 业务层无侵入使用(极度丝滑)
配置完多级缓存后,你在 Service 层甚至不需要写一行关于 Redis 或 Caffeine 的 API,直接用 Spring 内置的 @Cacheable 注解即可:
java
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
/**
* 获取商品详情
* 当该方法被调用时:
* 1. 自动去 Caffeine 查有无缓存,有则直接返回。
* 2. Caffeine 没有,自动去 Redis 查,有则回写 Caffeine 并返回。
* 3. Redis 也没有(或者 Redis 挂了),才会执行下面方法里的代码(查数据库 MySQL)。
* 4. 查出结果后,自动写入 Caffeine 和 Redis。
*/
@Cacheable(value = "product", key = "#productId", sync = true)
public String getProductDetail(Long productId) {
System.out.println("====== 缓存未命中,开始极其沉重地查询数据库 MySQL ======");
return "Product_Detail_Of_" + productId;
}
/**
* 当商品被修改或下架时,清除多级缓存
*/
@CacheEvict(value = "product", key = "#productId")
public void updateProduct(Long productId) {
System.out.println("====== 数据库商品已更新,正在同步清除多级缓存 ======");
}
}
5. 面试官进阶连环追问:多级缓存的分布式一致性问题
如果你在面试中聊到了这里,面试官一定会丢出一个高级问题:
"你把数据缓存在本地服务器的 Caffeine 内存里,如果后台管理员修改了商品,你用 @CacheEvict 清除了当前 A 节点的内存,但是分布式部署的 B 节点、C 节点的 JVM 内存里还是旧数据,这不就导致数据不一致了吗? 怎么解决?"
大厂正宗解法:Redis Pub/Sub(发布订阅机制)当某一台机器触发了 evict(清除缓存)时,我们不仅要删掉本地内存和 Redis,还要通过 Redis 的 Pub/Sub 功能发布一条广播消息(比如频道叫
cache:clear:channel,内容是:{"cacheName":"product", "key": 1001})。其他分布式部署的 Spring Boot 节点全部订阅这个频道,一旦收到广播消息,各自的本地服务立刻执行
caffeineCache.invalidate(key)。这样就能瞬间做到全集群本地缓存的同步清理。
利用 Redis 的 Pub/Sub(发布/订阅) 来同步清理各节点的 Caffeine 缓存,在 Spring Boot 中的核心落地分为三步:
-
定义消息体(DTO):用来传递要清理的缓存名字和 Key。
-
改造 DoubleCache 的 evict 方法:在清除本地和 Redis 后,发送广播。
-
编写 Redis 订阅监听器(Listener):让所有节点都监听这个频道,收到消息就擦除本地 Caffeine。
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CacheMessage implements Serializable {
private String cacheName; // 缓存名称,如 "product"
private Object key; // 缓存的 Key,如 1001
}
java
// 注意:这里只贴出修改后的 evict 方法,其他 lookup、get 保持不变
@Override
public void evict(Object key) {
String realKey = this.name + ":" + key;
// 1. 清除当前机器本地的一级缓存
caffeineCache.invalidate(key);
// 2. 清除共享的二级缓存 Redis
redisTemplate.delete(realKey);
// 3. 【核心增强】:发布一条订阅消息,通知其他节点清理本地缓存
// 频道名固定为:cache:clear:channel
CacheMessage message = new CacheMessage(this.name, key);
redisTemplate.convertAndSend("cache:clear:channel", message);
}
java
@Configuration
public class CacheSyncConfig {
/**
* 配置 Redis 消息监听容器
*/
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 让监听器订阅指定频道 "cache:clear:channel"
container.addMessageListener(listenerAdapter, new ChannelTopic("cache:clear:channel"));
return container;
}
/**
* 配置消息监听适配器:指定收到消息后,由哪个类的哪个方法来处理
*/
@Bean
public MessageListenerAdapter listenerAdapter(CacheMessageReceiver receiver) {
// 让 receiver 对象的 "receiveMessage" 方法来接收并处理消息
return new MessageListenerAdapter(receiver, "receiveMessage");
}
}