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 的比例。
Caffeine W-TinyLFU 淘汰架构 新条目 Window LRU (1%) 捕获突发热点 Main LFU (99%) Count-Min Sketch 频率统计 + 淘汰 淘汰 自适应调整比例

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 实战配置建议

  1. 容量设置:根据 JVM 堆大小和业务数据量估算,通常设置为 最大条目数 = 堆内存 * 0.1 / 单条平均大小。
  2. TTL 策略:读多写少的数据(如配置)用 expireAfterWrite;对实时性要求高的数据(如库存)用 expireAfterAccess 或更短 TTL。
  3. 自动刷新:使用 refreshAfterWrite 配合 expireAfterWrite(刷新时间 < 过期时间),避免缓存雪崩。
  4. 监控:开启 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 + 自动刷新 + 监控,四者缺一不可。