外观
从击穿到自愈:Caffeine+Redis多级缓存失效避坑实录
问题背景:亿级读为什么不能只靠 Redis
在商品详情、用户画像、配置中心等读多写少场景中,Redis 通常能扛住每秒数十万次查询,但业务继续增长后,瓶颈会从 Redis 本身转移到三个地方:网络往返、连接池排队、热点 Key 集中访问。只靠 Redis,意味着每次请求都要跨网络访问一次缓存;一旦 Redis 抖动或某个热点 Key 过期,应用线程会同时回源数据库。
多级缓存的目标不是简单“再加一层 Map”,而是把高频热点挡在应用进程内,把跨实例共享数据放在 Redis,把最终一致性交给明确的失效机制。典型读路径为:先查 Caffeine 本地缓存,未命中再查 Redis,仍未命中才访问数据库,并按策略回填两层缓存。写路径则在更新数据库后删除或刷新 Redis,再通过 Redis Pub/Sub 广播失效各节点的 Caffeine。
方案对比:四种缓存失效策略怎么选
1. TTL 被动过期
给 Caffeine 和 Redis 都设置过期时间,实现最简单,适合一致性要求不高的字典类数据。缺点是过期瞬间容易产生缓存击穿,且多层 TTL 如果配置相同,会放大同一时刻的回源压力。
2. 写后删除加消息广播
数据库更新成功后删除 Redis,再发布失效消息,各应用节点收到消息后删除本地 Caffeine。这是大多数业务的首选:一致性比单纯 TTL 更好,实现成本可控。需要注意消息丢失、乱序和重复投递。
3. 逻辑过期加异步刷新
缓存值中携带 expireAt 字段,发现逻辑过期后仍先返回旧值,同时由后台线程刷新。适合商品详情等“可用性优先、短时间旧值可接受”的场景,但不能用于库存、余额等强一致读。
4. 分布式锁防击穿
对未命中的 Key 加 Redis 锁,只有一个线程回源,其他线程短暂等待或返回旧值。热点 Key 场景建议保留该兜底,但锁粒度必须是单个 Key,避免把普通请求串行化。
实操步骤:Java 实现 Caffeine 与 Redis 两级缓存
下面示例使用 Java 17、Caffeine、Spring Data Redis 和 Redisson。代码保留核心配置,可直接改造到 Spring Boot 项目中。
Maven 依赖
xml
<dependencies>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.27.2</version>
</dependency>
</dependencies>缓存对象与服务接口
java
// 缓存值必须支持版本号,便于处理失效消息乱序
public record ProductCache(
Long id,
String name,
Long price,
long version,
long logicalExpireAt
) {}
public interface ProductLoader {
ProductCache loadFromDb(Long id);
}Caffeine 配置与读取逻辑
java
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.*;
public class LocalCacheFactory {
public static Cache<String, ProductCache> create() {
return Caffeine.newBuilder()
// maximumSize 按热点数量估算,不能无脑设置为 Integer.MAX_VALUE
.maximumSize(20_000)
// 本地 TTL 应短于 Redis TTL,避免大量节点同一时间回源
.expireAfterWrite(Duration.ofMinutes(2))
.recordStats()
.build();
}
}两级缓存读取与空值保护
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
public class ProductCacheService {
private static final String NULL_HOLDER = "{\"id\":null}";
private final Cache<String, ProductCache> local = LocalCacheFactory.create();
private final StringRedisTemplate redis;
private final RedissonClient redisson;
private final ProductLoader loader;
private final ObjectMapper mapper = new ObjectMapper();
public ProductCacheService(StringRedisTemplate redis,
RedissonClient redisson,
ProductLoader loader) {
this.redis = redis;
this.redisson = redisson;
this.loader = loader;
}
public Optional<ProductCache> get(Long id) throws Exception {
String key = "product:" + id;
ProductCache localValue = local.getIfPresent(key);
if (localValue != null) {
return Optional.of(localValue);
}
String json = redis.opsForValue().get(key);
if (NULL_HOLDER.equals(json)) {
return Optional.empty(); // 命中空值缓存,防止穿透
}
if (json != null) {
ProductCache value = mapper.readValue(json, ProductCache.class);
local.put(key, value);
return Optional.of(value);
}
return loadWithLock(id, key);
}
private Optional<ProductCache> loadWithLock(Long id, String key) throws Exception {
String lockKey = "lock:product:" + id;
RLock lock = redisson.getLock(lockKey);
boolean locked = lock.tryLock(50, 3, TimeUnit.SECONDS);
if (!locked) {
Thread.sleep(50);
String json = redis.opsForValue().get(key);
return json == null || NULL_HOLDER.equals(json)
? Optional.empty()
: Optional.of(mapper.readValue(json, ProductCache.class));
}
try {
// 双重检查:拿到锁后可能已被前一个线程回填
String json = redis.opsForValue().get(key);
if (json != null) {
return NULL_HOLDER.equals(json) ? Optional.empty()
: Optional.of(mapper.readValue(json, ProductCache.class));
}
ProductCache value = loader.loadFromDb(id);
if (value == null) {
// 空值缓存设置短 TTL,避免脏空值长期存在
redis.opsForValue().set(key, NULL_HOLDER, Duration.ofMinutes(2));
return Optional.empty();
}
String newValue = mapper.writeValueAsString(value);
redis.opsForValue().set(key, newValue, Duration.ofMinutes(10));
local.put(key, value);
return Optional.of(value);
} finally {
lock.unlock();
}
}
}写后失效与本地广播
java
public void updateProduct(ProductCache newValue) throws Exception {
String key = "product:" + newValue.id();
// 推荐先更新数据库,再删除缓存;删除失败由短 TTL 与重试任务兜底
redis.delete(key);
local.invalidate(key);
// 广播给其他应用实例,消息中带版本号
InvalidMessage msg = new InvalidMessage(key, newValue.version());
redis.convertAndSend("cache:invalidate", mapper.writeValueAsString(msg));
}
// 订阅端只处理版本更新的消息,避免旧消息覆盖新数据
public void onMessage(String body) throws Exception {
InvalidMessage msg = mapper.readValue(body, InvalidMessage.class);
ProductCache old = local.getIfPresent(msg.key());
if (old == null || old.version() < msg.version()) {
local.invalidate(msg.key());
}
}逻辑过期的使用边界
如果热点商品不能接受重建期间阻塞,可以把 TTL 改成“物理不过期、逻辑过期”:读到 logicalExpireAt < now 时立即返回旧值,并提交异步刷新任务。但必须限制同一个 Key 只提交一个刷新任务,否则过期瞬间仍可能造成数百个线程同时回源。强一致数据不要使用该模式。
监控指标:没有观测就不要上线多级缓存
至少采集 Caffeine 的 hitRate、loadTime、evictionCount、size,Redis 的命中率、慢查询、连接等待时间,以及数据库按 Key 维度的回源 QPS。命中率下降并不一定是坏事,发布后本地缓存批量失效会导致短时下降;真正需要告警的是 Redis 未命中与数据库 QPS 同时飙升。
建议按以下阈值建立基线:本地缓存命中率低于 80% 排查热点集合和 maximumSize;Redis 命中率低于 95% 检查 TTL 与删除频率;单 Key 回源 QPS 接近实例数说明锁或广播失效未生效。
避坑总结:实战中最容易踩的 5 个坑
两层缓存 TTL 完全相同,导致整点雪崩。 现象是每 10 分钟数据库出现尖刺。修正方法是 Redis TTL 加 10%~20% 随机抖动,Caffeine TTL 短于 Redis,并让不同业务 Key 使用不同过期窗口。
更新后先删缓存再更新数据库,产生旧值回填。 典型时序是删除缓存后,读线程在数据库提交前查询到旧数据并写回 Redis。修正为“先更新数据库,再删除缓存”,关键数据增加版本号或延迟双删。
只删 Redis,不通知 Caffeine,节点本地脏读。 现象是部分实例持续返回旧值直到本地 TTL 结束。修正为写后发布失效消息,订阅端按版本号失效本地缓存,并处理订阅断线后的全量兜底刷新。
缓存未命中不加锁,热点 Key 过期瞬间打满数据库。 日志常表现为同一个商品 ID 大量并发查询。修正为 Redisson 单 Key 锁、双重检查、等待线程短暂重读 Redis;不要用一个全局锁保护所有 Key。
空结果不缓存或缓存永久有效。 前者会被不存在 ID 持续穿透,后者会在数据新建后长期读不到。修正为对 null 设置短 TTL,例如 1~3 分钟;写请求创建数据成功后主动删除空值占位。
多级缓存的本质是用复杂度换读取延迟和容量弹性。Caffeine 负责进程内极低延迟,Redis 负责跨节点共享,失效策略负责两者之间的一致性契约。只有把 TTL、删除、广播、锁和监控一起设计,才能在亿级读下做到热点不穿、故障不抖、数据不过分陈旧。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。