第一次接触Caffeine是在处理一个电商秒杀系统性能问题的时候。当时我们的商品详情页在高峰期频繁崩溃,数据库压力爆表,TPS从3000直接掉到200。尝试了各种优化手段后,最后引入Caffeine缓存,系统吞吐量直接提升了8倍,效果立竿见影。
Caffeine是一个基于Java 8的高性能本地缓存库,由Google Guava缓存的原作者开发。它最厉害的地方在于采用了Window-TinyLFU淘汰算法,这个算法结合了LRU和LFU的优点,能实现接近理论最优的缓存命中率。我实测过,在相同内存条件下,Caffeine的命中率比Guava Cache高出15-20%。
举个生活中的例子:假设你经营一家咖啡店,Caffeine就像是那个能记住每位老顾客喜好的金牌服务员。当顾客A进店时,服务员不用询问就直接准备好他常喝的美式咖啡;而新顾客B第一次点单后,服务员会记住他的选择。当柜台空间有限时,服务员会优先保留那些经常点单的顾客偏好,而不是最近一次点单的记录。这就是Caffeine的智能之处。
在技术层面,Caffeine提供了这些核心能力:
java复制// 基础使用示例
Cache<String, Product> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
Product product = cache.get(productId, id -> productDao.get(id));
手动加载模式就像手动挡汽车,给你完全的控制权。我在处理金融交易系统时特别偏爱这种方式,因为能精确控制每个缓存项的生成逻辑。
java复制Cache<String, Account> cache = Caffeine.newBuilder()
.maximumSize(5_000)
.build();
// 获取账户信息
Account account = cache.getIfPresent(accountId);
if(account == null) {
account = accountService.getAccount(accountId);
if(account != null) {
cache.put(accountId, account);
}
}
这种方式的优点是:
但要注意缓存穿透问题。我曾经遇到过恶意攻击者不断请求不存在的ID,导致数据库压力剧增。解决方案是使用空对象模式:
java复制Account account = cache.get(accountId, id -> {
Account a = accountService.getAccount(id);
return a != null ? a : Account.NULL_ACCOUNT; // 特殊空对象
});
对于商品详情这类常规场景,自动加载能让代码简洁很多。我们电商系统90%的缓存都采用这种方式。
java复制LoadingCache<String, Product> cache = Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(id -> productService.getProduct(id));
// 一行代码搞定
Product product = cache.get("P10086");
自动加载有个坑要注意:当批量查询时,默认会串行执行多个load方法。我优化过一个案例,加载100个商品串行执行要2秒,改成下面这种方式后降到200毫秒:
java复制LoadingCache<String, Product> cache = Caffeine.newBuilder()
.buildAsync(id -> CompletableFuture.supplyAsync(
() -> productService.getProduct(id), threadPool))
.synchronous();
Map<String, Product> products = cache.getAll(productIds);
在千人同时抢购的热门商品场景下,异步加载表现非常出色。它能将并发请求合并,避免重复加载。
java复制AsyncLoadingCache<String, Stock> cache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.buildAsync(id -> CompletableFuture.supplyAsync(
() -> stockService.getStock(id)));
// 非阻塞获取
CompletableFuture<Stock> future = cache.get("S10010");
future.thenAccept(stock -> System.out.println(stock));
实测数据显示,使用异步加载后,在5000QPS的压力下,数据库查询量减少了98%。但要注意线程池配置,我曾经因为线程池过小导致请求堆积,最终OOM。
这是我最推荐的加载方式,结合了自动加载的简洁和异步加载的高性能。我们实时推荐系统就采用这种方案。
java复制AsyncLoadingCache<String, List<Recommendation>> cache = Caffeine.newBuilder()
.refreshAfterWrite(1, TimeUnit.MINUTES)
.buildAsync((id, executor) ->
CompletableFuture.supplyAsync(
() -> recommendationService.getRecs(id), executor));
// 使用示例
cache.get("U1001").thenAccept(recs -> {
// 更新推荐列表
});
设置缓存最大容量是最基本的防护措施。但要注意,单纯限制数量可能不够,因为不同对象大小差异可能很大。
java复制// 电商分类树缓存
LoadingCache<String, CategoryTree> cache = Caffeine.newBuilder()
.maximumSize(500) // 基于条目数
.weigher((String key, CategoryTree tree) -> tree.size()) // 自定义权重
.maximumWeight(100_000) // 基于总节点数
.build(key -> loadCategoryTree(key));
我曾经踩过一个坑:缓存了10万个小型配置对象,虽然没超数量限制,但总内存占用达到了2GB。后来加入weigher后完美解决了问题。
对于价格、库存等时效性强的数据,时间策略是必须的。我们采用分层策略:
java复制// 价格缓存1分钟,库存缓存5秒,商品信息缓存30分钟
Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES) // 价格
.expireAfterWrite(5, TimeUnit.SECONDS) // 库存
.expireAfterAccess(30, TimeUnit.MINUTES) // 商品信息
.build(key -> loadData(key));
特别注意:expireAfterAccess在热点数据场景可能导致老数据长期不释放。我们曾因此OOM,后来改用expireAfterWrite更安全。
在处理大型媒体文件时,软引用能帮大忙。但要注意它可能引起的性能问题。
java复制Cache<String, byte[]> cache = Caffeine.newBuilder()
.softValues() // 内存不足时自动回收
.maximumSize(1_000) // 兜底限制
.build();
byte[] image = cache.get("IMG1001", id -> loadImage(id));
实测发现,使用softValues后,在内存紧张时GC时间会明显增加。所以只建议在缓存大对象时使用,小对象反而得不偿失。
刷新策略是我们秒杀系统的关键。通过后台异步刷新,用户永远感知不到缓存重建的过程。
java复制LoadingCache<String, SecKillInfo> cache = Caffeine.newBuilder()
.refreshAfterWrite(30, TimeUnit.SECONDS) // 每30秒刷新
.executor(refreshExecutor) // 专用线程池
.build(key -> loadSecKillInfo(key));
这里有个重要细节:refreshAfterWrite只是触发刷新条件,实际刷新是异步的。如果刷新耗时较长,期间请求会返回旧值。我们通过监控发现,高峰期刷新可能堆积,后来通过动态调整刷新间隔解决了问题。
淘汰监听器能帮我们实现缓存一致性。比如当本地缓存淘汰时,自动更新Redis。
java复制Cache<String, Product> cache = Caffeine.newBuilder()
.removalListener((key, value, cause) -> {
if(cause.wasEvicted()) {
redis.del(key); // 同步删除Redis缓存
}
})
.build();
但要注意监听器执行异常会导致缓存操作阻塞。我们曾因此导致整个系统卡顿,后来改用异步处理并添加了降级策略。
Caffeine提供了丰富的统计功能,这对性能调优至关重要。
java复制Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 开启统计
.build();
// 定期打印命中率
System.out.println(cache.stats().hitRate());
我们根据这些数据做了这些优化:
最终使缓存命中率从75%提升到93%。
去年我们重构了整个商品系统的缓存架构,核心就是Caffeine。新架构分为三层:
关键实现代码:
java复制public Product getProduct(String id) {
// 第一层:本地缓存
Product product = localCache.get(id);
if(product != null) {
return product;
}
// 第二层:Redis缓存
product = redis.get(id);
if(product != null) {
localCache.put(id, product);
return product;
}
// 第三层:数据库
product = db.get(id);
if(product != null) {
redis.set(id, product, 30, TimeUnit.MINUTES);
localCache.put(id, product);
}
return product;
}
这个架构经受住了双11的考验,峰值QPS达到12万,平均响应时间保持在15ms以内。最关键的是,Caffeine的本地缓存挡住了90%的请求,Redis只处理了9%,最后只有1%的请求会落到数据库。