3. 本地缓存实现:Caffeine 缓存原理与配置、Guava Cache 对比、本地缓存的优缺点、内存淘汰策略(LRU/LFU/TTL)
3.1 本地缓存在微服务架构中的定位
在 Spring Boot 应用中,本地缓存(也称进程内缓存)是距离应用最近的一层存储。它直接运行在 JVM 堆内存中,与业务线程共享同一进程空间,因此具备零网络开销和纳秒级访问延迟的优势。但本地缓存的数据无法跨进程共享,每个微服务实例都维护一份独立的副本,这给缓存一致性带来了天然挑战。
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 的访问频次,同时引入一个窗口缓存区来应对突发流量。
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) |
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):从最后一次读取开始计时,到期后失效。适合访问频率决定数据生命周期的场景,如用户会话。
expireAfterWrite=5m, expireAfterAccess=2m 表示无论写入后多久,只要 2 分钟未被访问就过期,但最长不超过 5 分钟。
3.5.3 基于引用的淘汰:软引用与弱引用
Caffeine 支持通过 .softValues() 和 .weakKeys() / .weakValues() 让缓存条目跟随 GC 回收:
- 软引用(SoftReference):当 JVM 堆内存不足时,GC 会回收软引用对象。适合作为缓存容量的最后一道防线。
- 弱引用(WeakReference):每次 GC 都会回收弱引用对象。适合与外部生命周期绑定的场景。
3.6 本地缓存一致性保障策略
虽然本地缓存天然存在多实例不一致问题,但可以通过以下手段将不一致窗口缩小到可接受范围: