学完这篇你能拿到一套可直接落地的 Java 两级缓存骨架:Caffeine 扛本地热点、Redis 做跨节点共享,并附带读路径、失效广播和常见缓存故障的处理写法。
第一步:先想清楚哪一层放什么
Caffeine 是进程内缓存,读取是纳秒级,代价是每个 JVM 一份副本,多实例部署会出现数据不一致;Redis 跨进程共享,毫秒级,但有网络和序列化开销。所以定位很明确:热点、小体积、能容忍秒级不一致的数据进 L1,共享数据和全量数据放 L2。
一组常用参数:
- L1 Caffeine:
maximumSize = 10_000,expireAfterWrite = 30~60s
- L2 Redis:TTL 10~30 分钟,再叠加随机抖动
适合加二级缓存的是字典、站点配置、榜单前几名、用户基础资料;订单状态、库存这种强一致数据不要进本地缓存。
注意:L1 的 TTL 直接决定最坏情况下各节点数据不一致的时长。本地 TTL 建议 30~60 秒,设成 10 分钟等于允许别人看 10 分钟旧数据。
第二步:加依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
第三步:读路径写成 L1 → L2 → 回源
private final Cache<String, Object> local = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(60))
.recordStats()
.build();
public <T> T get(String key, Class<T> type, Duration redisTtl, Supplier<T> loader) {
Object l1 = local.getIfPresent(key);
if (l1 != null) return unwrap(l1);
String json = redis.opsForValue().get(key);
if (json != null) {
T v = JsonUtil.parse(json, type);
local.put(key, v);
return v;
}
T v = loader.get(); // 回源查数据库
if (v == null) {
local.put(key, NULL_HOLDER); // 空值也缓存,防穿透
redis.opsForValue().set(key, NULL_JSON, Duration.ofSeconds(60));
} else {
local.put(key, v);
redis.opsForValue().set(key, JsonUtil.toJson(v), withJitter(redisTtl));
}
return v;
}
Key 统一成 业务:对象:{id},例如 user:profile:10086,方便按前缀批量排查和清理。
第四步:写路径——先落库,再删缓存,最后广播失效
public void update(UserProfile p) {
userMapper.update(p);
redis.delete(key(p.getId())); // 删而不是更新
redis.convertAndSend("cache:evict", key(p.getId())); // 通知所有节点清 L1
}
每个实例订阅 cache:evict 频道,收到消息就 local.invalidate(key)。没有这一步,A 节点改了数据,B 节点的本地缓存还会继续吐旧值,直到 TTL 到期。
注意:不要用“先删缓存再写数据库”,并发下别的线程会把旧值重新读回缓存。删除失败要进重试队列,别只记一行日志。
第五步:防穿透、击穿、雪崩
- 穿透:不存在的 ID 每次都打到库。做法是空值缓存 60 秒,或前置布隆过滤器。
- 击穿:热点 key 过期瞬间大量请求同时回源。用 Redis 短锁(
setIfAbsent,超时 3 秒)保证只有一个线程重建,其余线程短暂等待后重读;也可以给 value 存“逻辑过期时间”,过期后由异步线程刷新,旧值先扛着。
- 雪崩:大批 key 同时失效。TTL 加 0~300 秒随机抖动即可。
第六步:序列化和容量别踩坑
Redis 里存 JSON 或 Protobuf,不要用 JDK 原生序列化,跨语言不通、体积还大一截。Caffeine 的 maximumSize 按条数计,不按字节,如果缓存的是几百 KB 的大列表,一万条就是几个 G,要单独压小上限。
第七步:看效果,而不是凭感觉
Caffeine 打开 recordStats(),定期打日志看 hitRate();Redis 侧看 INFO stats 的命中率与网络往返延迟。命中率低于 60% 说明 key 粒度太粗或 TTL 太短,先调这两个,别急着加机器。
小结
- 分层定位:Caffeine 放热点小对象,Redis 放共享数据,强一致数据两者都不放。
- 读路径 L1 → L2 → 回源,空值也要缓存。
- 写路径:先更新数据库,再删 Redis,再通过 Pub/Sub 广播清各节点 L1。
- L1 的 TTL 就是不一致窗口,控制在 30~60 秒。
- 穿透靠空值和布隆过滤器,击穿靠分布式锁或逻辑过期,雪崩靠 TTL 抖动。