缓存04:Caffeine
从本地缓存优势、W-TinyLFU 淘汰算法到与 Redis 多级缓存架构,拆解 Caffeine 在高性能本地缓存场景下的核心机制。
Caffeine 不是”分布式缓存”,而是”高性能本地缓存”。它的核心场景是热点数据本地缓存、高频读场景、降低远程缓存压力,支持内存级读写、W-TinyLFU 淘汰算法和异步刷新,适合单机热点数据、配置缓存、计算结果缓存。
这篇文章围绕三个核心点:本地缓存优势、W-TinyLFU 淘汰算法、与 Redis 多级缓存架构,把 Caffeine 的设计逻辑和适用场景讲清楚。
为什么需要本地缓存
分布式缓存(Redis、Memcached)虽然解决了多实例共享数据的问题,但存在以下瓶颈:
- 网络开销:每次读取都需要网络请求,延迟增加
- 热点压力:热点 Key 集中访问,Redis 单节点压力大
- 单点故障:Redis 故障时缓存不可用,数据库压力激增
本地缓存的优势:
| 维度 | 本地缓存(Caffeine) | 分布式缓存(Redis) |
|---|---|---|
| 访问延迟 | 微秒级(内存访问) | 毫秒级(网络请求) |
| 吞吐量 | 百万级 QPS | 十万级 QPS |
| 热点压力 | 无(本地内存) | 热点 Key 压力大 |
| 数据一致性 | 弱(多实例不一致) | 强(集中存储) |
| 数据共享 | 不支持(单机) | 支持(分布式) |
| 内存容量 | 受限于 JVM 堆内存 | 受限于服务器内存 |
简单判断:热点数据、高频读、一致性要求不高 → 本地缓存合适。数据共享、一致性要求高、数据量大 → 分布式缓存合适。
Caffeine 核心机制:W-TinyLFU 淘汰算法
本地缓存的核心问题:内存有限,如何选择淘汰策略,保证热点数据不丢失。
传统淘汰策略的局限
| 淘汰策略 | 说明 | 局限 |
|---|---|---|
| LRU(Least Recently Used) | 淘汰最近最少使用的数据 | 无法处理扫描访问(大量一次性数据挤掉热点) |
| LFU(Least Frequently Used) | 淘汰访问频率最低的数据 | 无法处理热点变化(历史热点一直占用内存) |
| FIFO | 先进先出 | 不考虑访问频率和时效 |
传统 LRU/LFU 在面对复杂访问模式时,命中率下降。
W-TinyLFU 算法
Caffeine 使用 W-TinyLFU(Window TinyLFU) 算法,结合 LRU 和 LFU 的优势:
- Window(窗口区):新数据进入窗口区,采用 LRU 策略
- Main(主区):热点数据进入主区,采用 LFU 策略
- Probation(试用期):窗口区淘汰的数据进入试用期,与主区淘汰的数据竞争,胜者留下
W-TinyLFU 的优势:
| 优势 | 说明 |
|---|---|
| 抗扫描访问 | 一次性数据只在窗口区,不会挤掉主区热点 |
| 适应热点变化 | 主区采用衰减的频率计数,历史热点会被新热点替换 |
| 高命中率 | 结合 LRU 和 LFU,命中率高于传统算法 |
Caffeine 的命中率接近理论最优,在高频读场景下性能优异。
Caffeine 配置示例
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10000) // 最大缓存数量
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后 5 分钟过期
.refreshAfterWrite(1, TimeUnit.MINUTES) // 写入后 1 分钟异步刷新
.recordStats() // 开启统计
.build(key -> loadUserFromDb(key)); // 缓存未命中时加载数据
配置说明:
| 配置项 | 说明 |
|---|---|
maximumSize | 最大缓存数量,超过时淘汰 |
expireAfterWrite | 写入后过期时间,适合数据不变的场景 |
expireAfterAccess | 访问后过期时间,适合热点数据 |
refreshAfterWrite | 写入后异步刷新,避免读时阻塞 |
recordStats | 开启命中率统计,监控缓存效果 |
多级缓存架构:Caffeine + Redis
本地缓存和分布式缓存不是互斥的,而是互补的。生产环境常采用多级缓存架构:
请求 → Caffeine(L1 本地缓存)→ Redis(L2 分布式缓存)→ 数据库
多级缓存流程
- 请求先查 Caffeine:命中则返回,未命中则查 Redis
- Redis 未命中则查数据库:数据库返回后写入 Redis 和 Caffeine
- 数据更新时:先更新数据库,再删除 Redis,最后删除 Caffeine
一致性问题
多级缓存的一致性比单级缓存更复杂:
| 一致性问题 | 说明 | 解决方式 |
|---|---|---|
| Caffeine 与 Redis 不一致 | Caffeine 未及时更新,读到旧数据 | 设置较短过期时间、监听 Redis 删除事件 |
| 多实例 Caffeine 不一致 | 不同实例的 Caffeine 数据不一致 | 使用消息广播(Redis Pub/Sub)通知其他实例删除 |
多级缓存配置示例
使用 Spring Cache + Caffeine + Redis:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// L1 本地缓存(Caffeine)
CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES));
// L2 分布式缓存(Redis)
RedisCacheManager redisCacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)))
.build();
// 组合缓存管理器
CompositeCacheManager compositeCacheManager = new CompositeCacheManager(
caffeineCacheManager, redisCacheManager);
return compositeCacheManager;
}
}
Caffeine 使用场景
| 场景 | 说明 |
|---|---|
| 配置缓存:系统配置、业务配置本地缓存,减少 Redis 压力 | |
| 计算结果缓存:复杂计算结果缓存,避免重复计算 | |
| 热点数据缓存:高频读数据本地缓存,降低延迟 | |
| 限流计数:本地限流计数器,减少远程存储压力 |
不适合场景:
- 数据共享:多实例需要共享数据
- 数据一致性要求高:多实例数据必须一致
- 数据量大:超过 JVM 堆内存容量
常见误区
| 误区 | 真实情况 |
|---|---|
| 本地缓存不需要考虑一致性 | 多实例本地缓存不一致,需要广播删除或短过期时间 |
| Caffeine 可以替代 Redis | 本地缓存不能共享,适合作为 Redis 的补充 |
| W-TinyLFU 是完美的 | 算法复杂,内存开销比 LRU 大,但命中率更高 |
| 本地缓存容量可以很大 | 受限于 JVM 堆内存,过大影响 GC 性能 |
项目判断
引入 Caffeine 时,先回答三个问题:
- 访问频率:是否是热点数据,高频读?
- 一致性要求:是否允许短暂数据不一致?
- 内存容量:数据量是否在 JVM 堆内存容量内?
如果是热点数据、高频读、允许短暂不一致、数据量小 → Caffeine 合适。如果数据需要共享、一致性要求高、数据量大 → Redis 合适。两者可以组合使用:Caffeine 做 L1 本地缓存,Redis 做 L2 分布式缓存。
线上排查 Caffeine 问题:
- 命中率低:检查缓存策略,是否过期时间过短、淘汰过快
- 内存占用大:检查缓存数量,控制
maximumSize,避免影响 GC - 缓存未命中阻塞:使用
refreshAfterWrite异步刷新,避免读时阻塞