核心技术点:
- W-TinyLFU算法揭秘:为何它能吊打传统LRU
- 异步与权重:应对突发流量的两种武器
- 监控与调优:从黑盒到白盒的缓存治理
一、W-TinyLFU:缓存的"智能淘汰算法"
很多人选缓存框架就看个API友好度,但真正的差距在淘汰算法上。传统的LRU(最近最少使用)在实际生产中经常表现不佳,因为它有个致命弱点:无法应对突发稀疏流量。
举个例子:你的缓存容量是100。突然来了1000个只访问一次的热点数据(比如明星八卦新闻),按照LRU,这些"一次性热点"会把之前积累的真正热点数据全部挤出缓存。等这批流量过去,缓存命中率会直接跌到谷底,所有请求都穿透到数据库,引发雪崩。
Caffeine用的W-TinyLFU(Window Tiny Least Frequently Used)就是为了解决这个问题而生的。 你可以把它理解为三层过滤网:
第一层:窗口缓存(Window Cache)
- 占总容量的1%(可配置)
- 使用LRU策略
- 专门接收新来的数据,给它们一个"试用期"
- 相当于公司的"实习生池"
第二层:频率素描(Count-Min Sketch)
- 这不是个存储结构,而是个概率数据结构
- 用极少的内存(通常几百字节)统计所有key的访问频率
- 特点是:可能存在误判(高估频率),但绝不会低估
- 相当于HR部门的"人才评估系统"
第三层:主缓存(Main Cache)
- 占总容量的99%
- 使用SLRU(Segmented LRU)策略,分为保护段和 probation段
- 只有经过频率筛选的"优秀实习生"才能转正到这里
- 相当于公司的"正式员工编制"
工作流程是这样的:
- 新数据进来,先进入窗口缓存(当实习生)
- 当需要淘汰数据时,候选者来自两个地方:窗口缓存中最久未使用的数据 vs 主缓存probation段中最久未使用的数据
- 让这两个候选者PK:查Count-Min Sketch,谁的访问频率高谁留下
- 赢家进入(或留在)主缓存,输家直接被淘汰
这样设计的好处是:
- 突发的一次性热点只会污染1%的窗口缓存,不会冲击主缓存
- 真正的高频访问数据会被准确识别并长期保留
- 用极小的内存开销实现了近似完美的访问频率统计
踩坑经历:缓存"失忆"之谜
我们在灰度环境发现一个奇怪现象:某个关键配置项的缓存,白天命中率99%,但每天早上8点总会准时出现一波缓存穿透,持续大概5分钟。监控显示这期间该key的QPS并没有突增。
排查过程:
- 首先怀疑是缓存过期,但TTL设置的是24小时,不应该每天同一时间过期
- 检查代码,没有发现主动清除的逻辑
- 然后怀疑是Count-Min Sketch的哈希冲突导致频率统计失真
- 最后在Caffeine的源码里找到了答案:频率衰减机制
原来Count-Min Sketch为了避免历史数据权重过高(比如一个去年火爆今年冷门的key),会定期对所有频率计数进行衰减(默认是每10亿次访问衰减一次)。在某些特定访问模式下,可能会导致某个key的频率计数被衰减到低于新来的key,从而在PK中被淘汰。
解决方案:
我们给这个特定的key使用了weakKeys() + softValues()的组合,确保它不会被常规淘汰策略清除。同时,我们对这类"重要但可能访问不均"的数据,启用了基于权重的容量限制,确保它们在缓存中占有固定份额。
独家见解:
- 不要盲目追求大容量: Caffeine的默认容量是基于条目数的,但实际生产中更应该用
maximumWeight来控制总内存占用 - 理解你的数据访问模式: 如果是均匀访问,LRU可能就够了;如果是突发稀疏访问,W-TinyLFU优势明显
- 重要数据要特殊对待: 对于系统关键配置、字典数据等,考虑使用软引用或单独缓存实例
二、异步与权重:应对真实场景的组合拳
2.1 异步加载:不让一个慢请求拖垮整个缓存
这是Caffeine最被低估的特性之一。看这段代码:
java
// 同步加载:一个线程慢,所有线程等
LoadingCache<String, Data> cache = Caffeine.newBuilder()
.build(key -> queryFromDB(key)); // 如果queryFromDB卡住,所有请求都会卡住
// 异步加载:一个线程慢,其他线程不等,直接返回null或旧值
AsyncLoadingCache<String, Data> asyncCache = Caffeine.newBuilder()
.buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> queryFromDB(key), executor));
踩坑经历:缓存击穿引发的连锁反应
我们有个商品价格缓存,使用同步加载。某天价格服务出现波动,单个查询耗时从10ms增加到2s。结果就是:第一个请求卡在加载数据,后面的999个请求全部在等待这个锁。线程池迅速被打满,整个商品服务不可用。
改进后的方案:
java
AsyncLoadingCache<String, Price> priceCache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES) // 异步刷新
.buildAsync((key, executor) ->
CompletableFuture.supplyAsync(() -> fetchPrice(key), executor));
// 使用时
CompletableFuture<Price> future = priceCache.get(productId);
future.thenAccept(price -> {
// 正常处理
}).exceptionally(ex -> {
// 降级:返回上次缓存的值或默认值
return getFallbackPrice(productId);
});
关键改进点:
- 异步加载:一个请求慢不会阻塞其他请求
- 异步刷新:在缓存过期前主动刷新,避免集中过期
- 降级策略:加载失败时返回兜底数据
2.2 基于权重的容量控制
这是另一个实战必备特性。不同对象的大小差异可能很大:
java
Cache<String, byte[]> cache = Caffeine.newBuilder()
.maximumWeight(100 * 1024 * 1024) // 100MB
.weigher((String key, byte[] value) -> value.length)
.build();
但我们遇到过更复杂的情况:有些对象计算权重很昂贵(比如需要序列化后才能知道大小)。这时候可以采用分层策略:
- 一级缓存:存储小对象,使用精确权重控制
- 二级缓存:存储大对象,使用条目数控制,并设置较短的TTL
独家见解:
- 异步不是银弹: 异步加载会增加代码复杂度,需要权衡。对于简单的、快速的查询,同步可能更合适
- 权重计算的代价: 如果weigher函数本身就很耗时,可能会影响缓存性能。这时候可以考虑抽样统计
- 分级缓存策略: 对于电商系统,商品基本信息(小)和商品详情HTML(大)应该用不同的缓存策略
三、监控与调优:从能用走向好用
3.1 监控指标:不只是命中率
很多人只关注缓存命中率,但这远远不够:
java
Cache<String, Data> cache = Caffeine.newBuilder()
.recordStats() // 开启统计
.build();
// 获取统计信息
CacheStats stats = cache.stats();
System.out.println("命中率: " + stats.hitRate());
System.out.println("平均加载耗时: " + stats.averageLoadPenalty());
System.out.println("驱逐总数: " + stats.evictionCount());
System.out.println("加载失败数: " + stats.loadFailureCount());
关键指标解读:
- 平均加载耗时:如果这个值很高,说明数据源响应慢,可能需要优化查询或考虑异步加载
- 加载失败率:失败率过高可能需要调整重试策略或降级方案
- 驱逐原因分布:是被大小限制驱逐?还是被时间过期?这决定了优化方向
3.2 动态调优:基于实时数据的决策
我们在生产环境实现了一套动态调优系统:
- 每分钟采集各缓存的统计信息
- 分析命中率、加载耗时、驱逐原因
- 基于规则自动调整配置:
- 命中率低于阈值且加载耗时低 → 增加容量
- 命中率高但内存占用高 → 适当减少容量
- 加载失败率高 → 启用降级策略
踩坑经历:JVM内存的"隐形杀手"
有一次上线后,服务运行几天后突然Full GC。堆dump分析发现,Caffeine缓存占了80%的内存。但我们的配置明明是10000个条目,实际只有3000个左右。
原因在于: 我们缓存的对象里包含了SoftReference引用的外部资源。Caffeine只计算了对象的浅层大小,但这些软引用持有的实际数据没有被统计在内。
解决方案:
- 实现自定义的Weigher,通过反射估算对象真实大小(有性能损耗)
- 改用
maximumSize而不是maximumWeight,通过压力测试确定安全值 - 对于大对象,改用外部缓存(如Redis)
3.3 与Spring Cache集成的高级玩法
Spring Boot集成Caffeine很简单,但想要用好需要一些技巧:
# application.yml
spring:
cache:
caffeine:
spec: maximumSize=10000,expireAfterAccess=600s
cache-names: user,product,order
# 多缓存差异化配置
multi:
user:
spec: maximumSize=5000,expireAfterWrite=3600s
product:
spec: maximumSize=20000,expireAfterWrite=1800s,refreshAfterWrite=300s
独家配置技巧:
- 按业务区分:用户信息和商品信息访问模式不同,应该用不同配置
- 预热策略:在应用启动时主动加载热点数据
- 两级缓存:本地缓存 + Redis分布式缓存,本地缓存失效后再查Redis
四、避坑指南:这些年我们踩过的雷
4.1 对象突变问题
java
Data data = cache.get(key);
data.setName("new name"); // 危险!直接修改了缓存中的对象
正确做法:
java
// 方案1:返回不可变对象
@Data
@Builder(toBuilder = true)
public class ImmutableData {
private final String id;
private final String name;
}
// 方案2:返回深拷贝
public Data getCopy(String key) {
Data original = cache.get(key);
return deepCopy(original); // 需要实现深拷贝
}
4.2 缓存污染问题
- 场景:查询不存在的商品ID,每次都会穿透到DB
- 解决方案:使用布隆过滤器或在缓存中存储空值(注意设置较短TTL)
4.3 时序一致性问题
- 场景 :先更新数据库,再失效缓存。在并发下可能出现:
- 线程A更新数据库
- 线程B读取数据(此时缓存未失效,读到旧值)
- 线程A失效缓存
- 解决方案:使用分布式锁或采用Cache Aside Pattern的变种
结尾
Caffeine之所以能在众多缓存库中脱颖而出,不是因为它API设计得漂亮(虽然确实不错),而是因为它直面了生产环境中的真实问题:突发稀疏流量、内存精确控制、异步高性能加载、完善的监控统计。
但它也不是万能钥匙。我的经验是:本地缓存适合高频读、低频写、数据量可控、一致性要求不极致的场景。如果你的数据需要跨JVM共享,或者数据量超大,还是得上Redis。
缓存设计本质上是一种权衡:内存 vs CPU、一致性 vs 性能、复杂度 vs 收益。没有最好的方案,只有最适合当前场景的方案。
最后抛个问题:你们在什么场景下会选择Caffeine而不是Redis?在超高并发下,你们是怎么解决缓存一致性和雪崩问题的?欢迎在评论区分享你们的实战经验。