3. 本地缓存实现:Caffeine 缓存原理与配置、Guava Cache 对比、本地缓存的优缺点、内存淘汰策略(LRU/LFU/TTL)

3.1 本地缓存在微服务架构中的定位

在 Spring Boot 应用中,本地缓存(也称进程内缓存)是距离应用最近的一层存储。它直接运行在 JVM 堆内存中,与业务线程共享同一进程空间,因此具备零网络开销和纳秒级访问延迟的优势。但本地缓存的数据无法跨进程共享,每个微服务实例都维护一份独立的副本,这给缓存一致性带来了天然挑战。

核心矛盾:本地缓存追求极致性能(无序列化、无网络 I/O),但必须接受数据在分布式环境下的弱一致性。在一致性要求不高的场景(如配置信息、静态字典、用户会话 Token),本地缓存是性价比最高的选择。

3.2 Caffeine 缓存原理与配置

Caffeine 是当前 Java 生态中性能最高的本地缓存库,被 Spring Boot 2.x+ 选为默认的本地缓存提供者。其核心设计理念是基于频率的准入策略与近最优的淘汰算法。

3.2.1 核心原理:TinyLFU + W-TinyLFU

Caffeine 没有使用传统的 LRU 或 LFU,而是实现了 TinyLFU(Tiny Least Frequently Used) 算法,并在此基础上扩展为 W-TinyLFU(Window Tiny LFU)。该算法通过维护一个频率布隆过滤器来记录每个 key 的访问频次,同时引入一个窗口缓存区来应对突发流量。

W-TinyLFU 淘汰决策流程 窗口缓存区 Window Cache 默认 1% 总容量 主缓存区 Main Cache 默认 99% 总容量 淘汰引擎 Eviction Policy 频率布隆过滤器 Frequency Sketch (4-bit CountMin Sketch) 说明:新写入的 key 先进入窗口缓存区;当窗口满时,候选 key 与主缓存区中被淘汰的 victim key 进行频率对比, 频率更高者保留,频率更低者被淘汰。频率数据由 CountMin Sketch 近似统计,内存开销极低。

3.2.2 Spring Boot 集成配置

在 Spring Boot 中启用 Caffeine 只需添加依赖并配置缓存管理器。以下是一个生产级配置示例:

// pom.xml 依赖(Spring Boot 2.x 默认已包含)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Caffeine 由 spring-boot-starter-cache 传递引入 -->

// application.yml 配置
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=300s,recordStats

// Java 配置类(更精细的控制)
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)              // 最大条目数
            .expireAfterWrite(5, TimeUnit.MINUTES)  // 写入后过期
            .expireAfterAccess(2, TimeUnit.MINUTES) // 访问后过期(可选)
            .refreshAfterWrite(1, TimeUnit.MINUTES) // 写入后自动刷新
            .recordStats()                     // 开启统计
            .softValues()                      // 使用软引用(可选)
        );
        return manager;
    }
}
最佳实践:生产环境建议同时设置 maximumSize 和 expireAfterWrite,前者防止内存溢出,后者保证数据新鲜度。refreshAfterWrite 配合异步加载可避免缓存雪崩。

3.3 Guava Cache 与 Caffeine 对比

Guava Cache 是 Google Guava 库提供的本地缓存实现,曾是 Java 本地缓存的事实标准。Caffeine 在 Guava Cache 的基础上进行了大量优化,两者在 API 设计上高度兼容,但底层实现差异显著。

对比维度 Guava Cache Caffeine
淘汰算法 基于 LRU(最近最少使用) 基于 W-TinyLFU(窗口 + 频率)
并发性能 分段锁(Segment Lock) 无锁读 + 细粒度写锁(Striped)
缓存命中率 中等(LRU 对扫描型负载不友好) 高(TinyLFU 可抵抗扫描型访问)
内存开销 较高(每个条目维护访问链表) 较低(使用频率布隆过滤器)
异步加载 不支持原生异步 支持 CompletableFuture 异步加载
统计能力 基础命中/未命中统计 完整统计 + 压力指标(hitRate, evictionCount 等)
Spring Boot 支持 需手动集成 默认集成(spring-boot-starter-cache)
迁移建议:如果现有项目使用 Guava Cache,可直接替换为 Caffeine,因为 Caffeine 提供了 Guava 兼容的 API 桥接(Caffeine.newBuilder() 与 GuavaCache 接口兼容)。替换后通常可提升 15%-30% 的命中率,并降低内存占用。

3.4 本地缓存的优缺点分析

在分布式缓存(如 Redis)盛行的今天,本地缓存依然有不可替代的价值,但也存在明显的局限性。以下从架构角度进行客观分析:

3.4.1 优点

  • 极低延迟:无网络 I/O、无序列化/反序列化,访问延迟通常在微秒级,比 Redis 快 10-100 倍。
  • 高吞吐:单机每秒可处理数百万次读写操作,不受网络带宽限制。
  • 无单点故障:每个实例独立运行,不依赖外部中间件,可用性高。
  • 简化架构:减少对 Redis 等外部组件的依赖,降低运维复杂度。

3.4.2 缺点

  • 数据不一致:多实例间缓存各自独立,更新操作无法实时同步,只能通过 TTL 或消息通知最终一致。
  • 内存受限:受限于 JVM 堆大小,无法存储海量数据,通常建议单实例缓存不超过 1GB。
  • 资源竞争:缓存数据占用堆内存,可能影响 GC 行为和业务对象的内存分配。
  • 重启丢失:进程重启后缓存全部清空,冷启动阶段可能引发数据库压力激增。
架构决策:本地缓存适合存储变化频率低、数据量小、一致性要求不严格的数据(如配置项、字典表、用户基础信息)。对于订单、库存等强一致场景,应使用分布式缓存或直接查询数据库。

3.5 内存淘汰策略详解

本地缓存必须设置淘汰策略,否则内存会被无限增长的缓存条目耗尽。Caffeine 支持三种核心淘汰策略,以及它们的组合使用。

3.5.1 基于容量的淘汰:LRU 与 LFU

Caffeine 的 maximumSize 和 maximumWeight 基于 W-TinyLFU 算法实现,但为了便于理解,我们对比传统 LRU 和 LFU 的行为差异:

策略 全称 淘汰依据 适用场景 缺点
LRU Least Recently Used 最近一次访问时间 热点数据集中、访问模式稳定 扫描型负载会污染缓存
LFU Least Frequently Used 历史访问频率 访问模式长期稳定 频率衰减慢,突发热点响应迟钝
TinyLFU Tiny Least Frequently Used 近似频率 + 时间衰减 通用场景(Caffeine 默认) 实现复杂,但效果最优

3.5.2 基于时间的淘汰:TTL 与 TTI

Caffeine 提供两种时间维度的淘汰策略:

  • expireAfterWrite(TTL):从写入/更新开始计时,到期后自动失效。适合数据有明确有效期的场景,如验证码、Session。
  • expireAfterAccess(TTI):从最后一次读取开始计时,到期后失效。适合访问频率决定数据生命周期的场景,如用户会话。
组合使用:可以同时设置 TTL 和 TTI,Caffeine 会取两者中更早的过期时间。例如:expireAfterWrite=5m, expireAfterAccess=2m 表示无论写入后多久,只要 2 分钟未被访问就过期,但最长不超过 5 分钟。

3.5.3 基于引用的淘汰:软引用与弱引用

Caffeine 支持通过 .softValues() 和 .weakKeys() / .weakValues() 让缓存条目跟随 GC 回收:

  • 软引用(SoftReference):当 JVM 堆内存不足时,GC 会回收软引用对象。适合作为缓存容量的最后一道防线。
  • 弱引用(WeakReference):每次 GC 都会回收弱引用对象。适合与外部生命周期绑定的场景。

3.6 本地缓存一致性保障策略

虽然本地缓存天然存在多实例不一致问题,但可以通过以下手段将不一致窗口缩小到可接受范围:

本地缓存一致性保障手段 短 TTL 策略 expireAfterWrite=30s 最大不一致窗口 30s 消息通知失效 Redis Pub/Sub 或 MQ 秒级失效广播 版本号校验 数据版本号 + 乐观锁 读取时校验版本 推荐组合:短 TTL(兜底)+ 消息通知(主动失效)+ 版本号校验(强一致兜底)。 对于配置类数据,短 TTL 即可满足需求;对于业务数据,建议使用消息通知主动清除。

3.7 本章小结