2、本地缓存实战:Guava Cache 基础用法、过期策略、最大容量限制、统计信息
好,咱们直接上手。上一章聊了缓存的分层和理论,这一章我带你落地第一个实战工具——Guava Cache。
为什么选它?因为它是 Google 开源的,成熟、稳定,而且 API 设计得非常优雅。我在好几个高并发项目里都用它做本地缓存,从来没出过幺蛾子。说白了,它就是 Java 世界里本地缓存的「标配」。
2.1 快速上手:三行代码搞定一个缓存
先看最基础的使用方式。你不需要引入什么复杂的框架,只要在 pom.xml 里加一个依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
然后,你就可以用 CacheBuilder 来构建缓存了。我个人习惯把缓存定义成一个静态字段,方便全局复用:
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;
public class UserCache {
private static final Cache<String, User> userCache =
CacheBuilder.newBuilder()
.maximumSize(1000) // 最多缓存 1000 条
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.recordStats() // 开启统计信息
.build();
public static User getUser(String userId) {
try {
return userCache.get(userId, () -> loadFromDB(userId));
} catch (Exception e) {
// 处理异常
return null;
}
}
private static User loadFromDB(String userId) {
// 模拟数据库查询
System.out.println("查询数据库: " + userId);
return new User(userId, "name_" + userId);
}
}
看到了吗?核心就三件事:设置容量、设置过期、开启统计。然后通过 get(key, loader) 方法,如果缓存里有就直接返回,没有就自动调用 loader 加载并放入缓存。这个模式叫「Cache-Aside」,也是实际项目中最常用的。
get(key, loader) 而不是先 getIfPresent 再手动 put。前者是原子操作,能避免并发场景下的「缓存穿透」问题。我见过太多人自己写 if-else,结果在高并发下把数据库打挂了。
2.2 过期策略:不是所有数据都该永久保留
缓存如果不设过期时间,那就是个「内存泄漏器」。Guava Cache 提供了三种过期策略,我分别说说。
2.2.1 基于写入时间过期(expireAfterWrite)
这是最常用的策略。数据写入缓存后,经过指定时间就自动失效。适合那些变化不频繁、但需要保证最终一致性的数据,比如用户基本信息、配置项。
.expireAfterWrite(30, TimeUnit.MINUTES)
2.2.2 基于访问时间过期(expireAfterAccess)
这个策略是:如果某个缓存项在指定时间内没有被读取或写入,就失效。适合做「热点数据」的淘汰——不热的数据就让它走人。
.expireAfterAccess(5, TimeUnit.MINUTES)
2.2.3 基于引用的过期(weakKeys / weakValues / softValues)
这个用得少,但特定场景很管用。它允许 JVM 在内存不足时回收缓存项。我一般只在缓存大对象、且允许丢失时才用。
.softValues() // 使用软引用,GC 时可能被回收
2.3 最大容量限制:别让缓存吃掉你的堆内存
本地缓存最大的坑就是「内存溢出」。Guava Cache 通过 maximumSize 和 maximumWeight 来控制容量。
2.3.1 maximumSize:按条目数限制
简单粗暴,直接限制缓存能放多少个 key-value 对。当超过限制时,会基于 LRU(最近最少使用) 算法淘汰最不常用的数据。
.maximumSize(5000)
2.3.2 maximumWeight:按权重限制
如果你的缓存项大小不一(比如有的 value 是 1KB,有的是 1MB),用条目数就不太合理了。这时候可以用权重:
.maximumWeight(100_000) // 总权重不超过 100000
.weigher((String key, User value) -> {
// 假设每个 User 对象的大小估算
return value.getName().length() + 50;
})
嗯,这里要注意:maximumWeight 必须和 weigher 一起使用,否则会抛异常。我刚开始用的时候就踩过这个坑。
2.4 统计信息:用数据说话
你优化缓存,总得知道缓存到底有没有用吧?Guava Cache 提供了 recordStats() 方法,开启后可以获取命中率、加载次数、淘汰次数等关键指标。
CacheStats stats = userCache.stats();
System.out.println("命中率: " + stats.hitRate());
System.out.println("命中次数: " + stats.hitCount());
System.out.println("未命中次数: " + stats.missCount());
System.out.println("加载成功次数: " + stats.loadSuccessCount());
System.out.println("加载异常次数: " + stats.loadExceptionCount());
System.out.println("总加载时间: " + stats.totalLoadTime());
我个人习惯在监控面板上展示这些数据。如果命中率低于 80%,说明缓存策略有问题——要么过期时间太短,要么容量太小,要么缓存的数据本身就不适合缓存。
2.5 核心概念一览
我把 Guava Cache 的核心配置整理成了一张表,方便你对照使用:
| 配置项 | 作用 | 适用场景 |
|---|---|---|
| maximumSize | 按条目数限制缓存容量 | 缓存项大小均匀时 |
| maximumWeight | 按权重限制缓存容量 | 缓存项大小差异大时 |
| expireAfterWrite | 写入后固定时间过期 | 数据变化不频繁,需最终一致性 |
| expireAfterAccess | 最后一次访问后固定时间过期 | 热点数据淘汰,非活跃数据清理 |
| recordStats | 开启命中率等统计 | 需要监控缓存效果时 |
| removalListener | 监听缓存项被淘汰的事件 | 需要做清理回调时(如关闭连接) |
2.6 一张图看懂 Guava Cache 工作流程
下面这张 SVG 图展示了 Guava Cache 的核心流程:从请求进来,到命中返回,再到未命中加载,最后淘汰旧数据。你一看就明白。
2.7 避坑指南:我踩过的三个坑
最后,分享几个我实际项目中踩过的坑,你遇到了可以少走弯路。
- 坑一:忘记开启 recordStats。没有统计信息,你根本不知道缓存效果怎么样。我建议所有缓存都默认开启。
- 坑二:过期时间设得太短。我曾经把 expireAfterWrite 设为 1 分钟,结果数据库压力一点没减。后来分析发现,数据其实可以缓存 10 分钟。记住:过期时间要基于业务容忍度来设,不是拍脑袋。
- 坑三:key 设计不合理。比如把用户 ID 和请求参数拼在一起当 key,导致同一个用户的不同请求都未命中。正确的做法是:key 只包含业务唯一标识,不要带无关信息。