前言:为什么我们需要本地缓存?
在构建高性能、高可用的Web应用时,缓存是绕不开的关键技术。它能够将热点数据存储在距离计算更近的地方,极大地减少数据访问延迟,提高系统吞吐量。
Redis、Memcached等分布式缓存因其强大的功能和共享特性而广受欢迎。然而,在面对极高并发请求时,即使是Redis也可能成为性能瓶颈。每一次网络往返带来的毫秒级延迟,在海量请求下累积起来,足以拖慢整个系统的响应速度。此外,热点Key的集中访问也可能瞬间压垮Redis实例。

此时,Java 世界中,Caffeine 已经是事实上的本地缓存首选,它不仅性能优秀,还在内存管理和淘汰策略方面设计得极为精巧。
- 性能层面:官方基准测试表明,Caffeine 的命中率在大多数真实业务场景中已经接近理论最优。
- 生态层面 :Spring Boot 2.x 之后,
spring-boot-starter-cache默认就集成了 Caffeine,你可以几乎零成本启用它。
一个现实例子:京东开源的 JD-HotKey 中间件在探测到 Redis 热 Key 时,可以将其直接推入客户端的 Caffeine 缓存,避免 Redis 热点访问引发的网络 IO 风暴。
而在众多Java本地缓存框架中,Caffeine 凭借其出色的性能表现和先进的缓存淘汰算法(W-TinyLFU),脱颖而出,被誉为"新一代高性能本地缓存之王"。本文将深入探讨Caffeine的各项特性和最佳实践,帮助你掌握这一利器,为你的Java应用性能优化提供强大助力。
一、快速上手
1. 依赖引入
xml
<!-- pom.xml -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
2. 配置Bean(声明式配置)
java
@Configuration
public class CacheConfig {
@Value("${cache.caffeine.spec:maximumSize=100000,expireAfterWrite=1h,recordStats}")
private String cacheSpec;
@Bean("localUrlCache")
public Cache<String, String> localUrlCache() {
return Caffeine.from(cacheSpec).build();
}
}
配置参数拆解:
maximumSize=100000:最大容量10万条目expireAfterWrite=1h:写入后1小时过期recordStats:开启统计功能(命中率、加载时间)
3. 业务使用(注入 + API调用)
java
@Service
public class ShortUrlService {
@Autowired
@Qualifier("localUrlCache")
private Cache<String, String> localCache;
public String getLongUrl(String shortCode) {
// 查询缓存
String cached = localCache.getIfPresent(shortCode);
if (cached != null) return cached;
// 未命中,查DB后回填
String longUrl = db.query(shortCode);
localCache.put(shortCode, longUrl);
return longUrl;
}
}
二、解决的核心痛点
痛点1:Redis网络IO成为瓶颈
场景:QPS 5000时,每次查Redis需要1-2ms网络延迟
plaintext
┌─────────┐ 1-2ms ┌──────┐
│ Service │ ────────▶│ Redis│ ← 网络开销
└─────────┘ └──────┘
解决 :本地缓存命中率90%,延迟降至微秒级
java
// 统计数据:90%请求在L1本地缓存命中
localCache.getIfPresent(code); // < 1μs
痛点2:热点Key打爆Redis
场景:爆款短链1秒被点击1000次,Redis连接池耗尽
plaintext
高并发 ────┬──▶ Redis连接1
├──▶ Redis连接2 ← 连接池耗尽
└──▶ Redis连接N
解决:L1本地缓存承载热点流量
ini
java
// 热点短链直接从JVM堆内存读取,不占用Redis连接
String url = localCache.getIfPresent("hot-code");
痛点3:冷启动缓存穿透
场景:应用重启后缓存为空,大量请求打到DB
plaintext
重启 ──▶ 缓存空 ──▶ 1000并发 ──▶ MySQL崩溃
解决:启动时预热Top热点数据
java
@PostConstruct
public void warmupCache() {
// 启动时加载Top 1000热点链接
List<ShortUrl> hotUrls = repo.findAll(PageRequest.of(0, 1000));
hotUrls.forEach(url ->
localCache.put(url.getShortCode(), url.getLongUrl())
);
log.info("预热完成:{} 条热点数据", hotUrls.size());
}
三、缓存淘汰原理(W-TinyLFU算法)
1. 为什么不用LRU?
LRU问题:扫描攻击会淘汰真正的热点数据
plaintext
正常访问:A(100次) B(90次) C(80次)
攻击场景:D E F G ... Z(各1次)
LRU结果:A B C被淘汰 ← 灾难!
正确结果:应保留A B C,淘汰D-Z
2. W-TinyLFU核心机制
Window Cache(窗口缓存)
新数据先进入窗口区(1%容量),防止扫描攻击污染主缓存
java
// 新访问的shortCode先进Window
Window[1000] ──过滤──▶ Main[99000]
↑ 新数据 ↑ 热点数据
** Frequency Sketch(频率统计)**
使用Count-Min Sketch算法,4字节记录百万级访问频率
java
// 空间复杂度:O(1),时间复杂度:O(1)
hash1(key) ──▶ counter[1234] += 1
hash2(key) ──▶ counter[5678] += 1
hash3(key) ──▶ counter[9012] += 1
// 查询时取最小值:min(3个counter)
淘汰决策(Admission Policy)
java
// 伪代码
if (cache.isFull()) {
victim = findVictim(); // 找到频率最低的旧数据
newFreq = sketch.estimate(newKey);
victimFreq = sketch.estimate(victim);
if (newFreq > victimFreq) {
cache.remove(victim);
cache.put(newKey, newValue);
} else {
// 拒绝新数据入缓存
}
}
四、过期策略详解
1. 三种过期模式
java
// 方式1:写入后过期(适合读多写少)
Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS) // 写入1小时后过期
// 方式2:访问后过期(适合会话数据)
Caffeine.newBuilder()
.expireAfterAccess(30, TimeUnit.MINUTES) // 30分钟不访问就过期
// 方式3:自定义过期策略
Caffeine.newBuilder()
.expireAfter(new Expiry<String, String>() {
public long expireAfterCreate(String key, String value, long currentTime) {
// VIP链接缓存24小时
return isVip(key) ? TimeUnit.HOURS.toNanos(24)
: TimeUnit.HOURS.toNanos(1);
}
})
2. 懒过期机制
java
// 不是定时扫描删除(节省CPU),而是:
localCache.getIfPresent(key);
// ↓ 触发检查
if (isExpired(entry)) {
remove(entry); // 懒删除
return null;
}
3. 定时清理(后台线程)
java
// Caffeine内部Scheduler每隔一段时间清理过期数据
Caffeine.newBuilder()
.scheduler(Scheduler.systemScheduler()) // 默认使用ForkJoinPool
五、监控与调优
1. 开启统计
java
Cache<String, String> cache = Caffeine.newBuilder()
.recordStats() // 开启统计
.build();
// 查看缓存指标
CacheStats stats = cache.stats();
log.info("命中率: {}", stats.hitRate());
log.info("加载次数: {}", stats.loadCount());
log.info("驱逐次数: {}", stats.evictionCount());
2. 集成Micrometer(暴露给Prometheus)
java
@Bean
public Cache<String, String> monitoredCache(MeterRegistry registry) {
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(100000)
.recordStats()
.build();
// 绑定到Prometheus
CaffeineCacheMetrics.monitor(registry, cache, "url_cache");
return cache;
}
Grafana监控面板:
plaintext
caffeine_cache_hit_total / (hit + miss) → 命中率
caffeine_cache_eviction_total → 驱逐速率
caffeine_cache_load_duration_seconds → 加载耗时
六、多级缓存架构
完整查询链路
java
public String getLongUrl(String shortCode) {
// L1: 本地Caffeine(微秒级)
String url = localCache.getIfPresent(shortCode);
if (url != null) {
log.debug("L1命中: {}", shortCode);
return url;
}
// L2: Redis(毫秒级)
url = redisTemplate.opsForValue().get("url:" + shortCode);
if (url != null) {
localCache.put(shortCode, url); // 回填L1
log.debug("L2命中,回填L1");
return url;
}
// L3: MySQL(十毫秒级)
ShortUrl entity = repository.findByShortCode(shortCode);
if (entity != null) {
url = entity.getLongUrl();
redisTemplate.set("url:" + shortCode, url, 24, HOURS); // 回填L2
localCache.put(shortCode, url); // 回填L1
log.debug("DB命中,回填L2+L1");
return url;
}
return null;
}
缓存更新策略(Cache Aside模式)
java
public void updateShortUrl(String shortCode, String newAlias) {
// 1. 更新数据库
repository.updateShortCode(shortCode, newAlias);
// 2. 删除缓存(而非更新,避免并发问题)
localCache.invalidate(shortCode);
redisTemplate.delete("url:" + shortCode);
// 下次查询会触发缓存回填
}
八、最佳实践清单
java
Cache<String, String> cache = Caffeine.newBuilder()
// 1. 设置合理容量(根据JVM堆内存)
.maximumSize(100000) // 每条约100字节,共10MB
// 2. 过期时间要比Redis短(避免数据不一致)
.expireAfterWrite(1, TimeUnit.HOURS) // Redis是24小时
// 3. 开启统计监控
.recordStats()
// 4. 弱引用Value(内存紧张时GC可回收)
.softValues() // 可选
// 5. 异步加载(避免阻塞)
.buildAsync(key -> loadFromRedis(key));
总结 :Caffeine = W-TinyLFU算法 (94%命中率) + 微秒级延迟 + 零网络开销 + 自动淘汰过期 ,是Java生态最强本地缓存方案,配合Redis形成黄金二级缓存架构。