4、本地缓存实现:Guava Cache、Caffeine的原理与配置、淘汰策略(LRU/LFU/TTL)
本地缓存是距离应用最近的一层存储,其性能远高于远程缓存(如 Redis)和数据库。本章深入剖析 Guava Cache 与 Caffeine 两大主流本地缓存框架的原理、配置参数及淘汰策略,帮助你在多级缓存架构中做出正确的技术选型。
4.1 本地缓存的核心价值与适用场景
本地缓存将热点数据存储在 JVM 堆内存中,读取延迟通常在微秒级(< 1μs),而 Redis 远程缓存约 1-5ms,数据库直读约 10-50ms。其核心价值在于:
- 极低延迟:无网络开销,直接内存访问
- 高吞吐:单机可达百万级 QPS
- 降低下游压力:减少对 Redis 集群和数据库的连接数
适用场景:读多写少、数据量可控(通常 < 1GB)、允许一定时间内的数据不一致(如配置信息、商品详情、用户会话)。
4.2 Guava Cache:Google 的经典实现
Guava Cache 是 Google Guava 库提供的本地缓存组件,基于 分段锁(Segment) 和 LRU(最近最少使用) 算法实现。其核心设计借鉴了 ConcurrentHashMap,但增加了自动加载、过期和淘汰机制。
4.2.1 核心原理
- 数据结构:内部使用
LocalCache,包含多个Segment,每个 Segment 独立维护一个ReferenceEntry链表(类似 HashMap 数组+链表)。 - 并发控制:写操作加 Segment 级别的锁,读操作无锁(通过 volatile 和 Unsafe 保证可见性)。
- 淘汰触发:在
put、get操作时检查容量和过期时间,惰性删除 + 定期清理。
4.2.2 配置参数与示例
| 参数 | 说明 | 默认值 |
|---|---|---|
maximumSize |
缓存最大条目数,基于 LRU 淘汰 | 无限制 |
expireAfterWrite |
写入后过期时间(TTL) | 无 |
expireAfterAccess |
最后一次访问后过期时间 | 无 |
refreshAfterWrite |
写入后自动刷新时间(不阻塞读) | 无 |
concurrencyLevel |
并发级别,决定 Segment 数量 | 4 |
weakKeys/weakValues |
使用弱引用,允许 GC 回收 | false |
// Guava Cache 基本配置示例
Cache<String, UserProfile> userCache = CacheBuilder.newBuilder()
.maximumSize(10_000) // 最大 1 万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入 5 分钟后过期
.concurrencyLevel(8) // 8 个 Segment
.recordStats() // 开启统计
.build();
// 手动存取
userCache.put("user:1001", profile);
UserProfile result = userCache.getIfPresent("user:1001");
4.2.3 淘汰策略:基于 LRU 的近似实现
Guava Cache 的淘汰策略并非严格 LRU,而是 LRU 近似算法:每个 Segment 维护一个 AccessQueue(双向链表),按访问顺序排列。当容量超限时,从队列头部(最久未访问)开始淘汰。由于分段锁的存在,淘汰精度受 Segment 内元素分布影响。
注意:Guava Cache 的 LRU 是 per-Segment 的,如果热点数据集中在某个 Segment,可能导致该 Segment 频繁淘汰,而其他 Segment 空闲。这是分段架构的固有缺陷。
4.3 Caffeine:高性能的现代替代方案
Caffeine 是受 ConcurrentHashMap 启发、基于 W-TinyLFU 算法的高性能本地缓存库。在吞吐量和命中率上全面超越 Guava Cache,已成为 Spring Boot 5.x 的默认本地缓存实现。
4.3.1 核心原理:W-TinyLFU 算法
W-TinyLFU(Window Tiny Least Frequently Used)是 Caffeine 的核心创新,结合了 LRU 和 LFU(最不经常使用) 的优点:
- Window LRU:一个小的 LRU 队列(占 1% 空间),用于捕获突发热点流量,防止新热点被 LFU 误杀。
- Main LFU:主缓存区(占 99% 空间),使用 Count-Min Sketch 概率计数器记录访问频率,淘汰频率最低的条目。
- 自适应调整:根据访问模式动态调整 Window 和 Main 的比例。
4.3.2 配置参数与示例
| 参数 | 说明 | Guava 对应参数 |
|---|---|---|
maximumSize |
最大条目数 | maximumSize |
maximumWeight |
最大权重(需配合 weigher) |
maximumWeight |
expireAfterWrite |
写入后过期 | expireAfterWrite |
expireAfterAccess |
访问后过期 | expireAfterAccess |
refreshAfterWrite |
写入后自动刷新 | refreshAfterWrite |
initialCapacity |
初始容量 | 无 |
recordStats |
开启统计 | recordStats |
// Caffeine 基本配置示例
Cache<String, UserProfile> userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.initialCapacity(1000)
.recordStats()
.build();
// 自动加载(类似 LoadingCache)
LoadingCache<String, UserProfile> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> loadFromDB(key)); // 缓存未命中时自动加载
UserProfile result = loadingCache.get("user:1001");
4.3.3 淘汰策略对比:LRU vs LFU vs TTL
| 策略 | 原理 | 优点 | 缺点 | Caffeine 实现 |
|---|---|---|---|---|
| LRU | 淘汰最久未访问的条目 | 实现简单,对突发流量友好 | 无法识别长期高频热点(扫描场景下命中率低) | Window LRU 队列 |
| LFU | 淘汰访问频率最低的条目 | 对稳定热点命中率高 | 新热点容易被误淘汰(冷启动问题) | Count-Min Sketch + Main LFU |
| TTL | 基于时间过期(绝对/相对) | 保证数据最终一致性 | 无法控制内存大小,可能全部过期导致缓存击穿 | expireAfterWrite / expireAfterAccess |
最佳实践:Caffeine 默认同时启用 W-TinyLFU(容量淘汰)和 TTL(时间过期)。容量淘汰保证内存可控,TTL 保证数据新鲜度。两者协同工作,互不冲突。
4.4 Guava Cache vs Caffeine 性能对比
| 维度 | Guava Cache | Caffeine |
|---|---|---|
| 核心算法 | 分段 LRU(近似) | W-TinyLFU(精确) |
| 吞吐量(百万 QPS) | ~10 | ~30-50 |
| 命中率(热点场景) | 中等 | 高(比 Guava 高 10-20%) |
| 内存开销 | 较高(每个 Entry 额外 40-50 字节) | 较低(优化了引用和计数器结构) |
| 并发度 | Segment 锁(写阻塞) | 细粒度 CAS + 无锁读 |
| 淘汰精度 | per-Segment 近似 | 全局精确(通过频率 Sketch) |
| 统计功能 | 基础(命中率、加载时间) | 丰富(命中率、驱逐原因、加载时间分布) |
| Spring 集成 | 需手动配置 | Spring Boot 5.x 默认 |
4.5 实战配置建议
- 容量设置:根据 JVM 堆大小和业务数据量估算,通常设置为
最大条目数 = 堆内存 * 0.1 / 单条平均大小。 - TTL 策略:读多写少的数据(如配置)用
expireAfterWrite;对实时性要求高的数据(如库存)用expireAfterAccess或更短 TTL。 - 自动刷新:使用
refreshAfterWrite配合expireAfterWrite(刷新时间 < 过期时间),避免缓存雪崩。 - 监控:开启
recordStats(),定期采集命中率,低于 80% 时考虑扩容或优化淘汰策略。
// 生产级 Caffeine 配置示例
Cache<String, Product> productCache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.refreshAfterWrite(5, TimeUnit.MINUTES) // 5 分钟后异步刷新
.recordStats()
.removalListener((key, value, cause) -> {
log.info("Cache eviction: key={}, cause={}", key, cause);
})
.build();
// 定期打印命中率
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
CacheStats stats = productCache.stats();
double hitRate = stats.hitRate();
log.info("Cache hit rate: {:.2f}%", hitRate * 100);
if (hitRate < 0.8) {
log.warn("Cache hit rate below 80%, consider tuning!");
}
}, 0, 1, TimeUnit.MINUTES);
避坑指南:
- 不要将本地缓存作为唯一数据源,必须与 DB 或远程缓存配合使用。
- 避免缓存大对象(如 > 1MB),否则 GC 压力剧增。
- Caffeine 的
maximumSize是近似值,实际条目可能略超(通常 < 1%)。 - 多级缓存中,本地缓存 TTL 应短于远程缓存 TTL,形成“近短远长”的过期梯度。
4.6 本章小结
- Guava Cache:经典实现,适合对性能要求不高的中小型项目,基于分段 LRU。
- Caffeine:现代高性能方案,基于 W-TinyLFU 算法,在命中率和吞吐量上显著优于 Guava。
- 淘汰策略:LRU 适合突发流量,LFU 适合稳定热点,TTL 保证数据新鲜度。Caffeine 通过 Window LRU + Main LFU 实现了三者的平衡。
- 配置要点:容量 + TTL + 自动刷新 + 监控,四者缺一不可。