第4章:本地缓存 - Guava Cache 实战
说到本地缓存,我得先坦白一件事。我最早做后端时,Redis 用得顺手,导致小项目也上个 Redis,结果运维跟我说“服务器内存不够”——其实只是一张配置表,十几条数据。后来我学了 Guava Cache,才发现本地缓存在这种场景下简直是绝配。
Guava Cache 是 Google Guava 库里的一个模块。说白了,它是一个内嵌在 JVM 进程里的缓存库。不需要额外部署,不需要网络,拿过来就用。但你以为它只是简单的 Map?嗯,它比 Map 强太多了。
4.1 为什么不用 ConcurrentHashMap?
很多人第一反应:我直接用 ConcurrentHashMap 加个定时清理不就行了吗?我以前也这么干过,结果写出来的代码又臭又长。你想想看,缓存过期时间怎么定?要淘汰哪条数据?并发写入时怎么保证一致性?全部要自己实现。
Guava Cache 把这些都封装好了。它提供了:
- 自动加载:缓存缺失时自动调用加载函数
- 淘汰策略:基于大小、时间、引用的多种淘汰方式
- 过期机制:写入后过期、访问后过期,随意搭配
- 统计功能:命中率、加载耗时,方便你监控
- 并发安全:内部做了细粒度锁,不会同时加载同一 key
4.2 核心用法:两步搞定
Guava Cache 的构建方式非常直观。来看一个最常用的例子:
LoadingCache<String, UserProfile> cache = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, UserProfile>() {
@Override
public UserProfile load(String key) {
return loadFromDatabase(key);
}
});
// 使用
UserProfile profile = cache.get("user_123");
你看,核心就是两步:CacheBuilder 配置参数,build 传入 CacheLoader负责数据加载。调用 get 时,如果缓存里有就直接返回,没有就自动调 load 方法。
4.3 配置参数详解
Guava Cache 的配置项特别多,但常用的就那么几个。我整理了一张表,你对着调参数就行。
| 参数 | 作用 | 我踩过的坑 |
|---|---|---|
maximumSize(long) |
缓存最多存放多少个条目 | 别设太小,否则频繁淘汰性能反降 |
expireAfterWrite(duration) |
写入后多久过期 | 适合数据更新不频繁的场景 |
expireAfterAccess(duration) |
最后一次访问后多久过期 | 注意:活跃数据不会被淘汰 |
refreshAfterWrite(duration) |
写入后多久自动刷新(不阻塞读) | 常用于“旧数据可用,后台更新”场景 |
recordStats() |
开启缓存统计 | 生产环境建议开启,排查问题利器 |
removalListener(listener) |
监听条目被淘汰时的事件 | 可以用来做清理或日志 |
maximumSize 设成 100,结果热点数据频繁过期,数据库压力暴增。后来改成 10000,命中率从 30% 升到 90%。记得结合 QPS 和数据总量来算。
4.4 三种缓存创建方式
Guava Cache 支持三种模式,适用场景不同:
- LoadingCache:自动加载,适合“有则返回,无则创建”的简单场景。
- Cache:手动控制,适合你需要自定义加载逻辑、甚至先检查后加载的情况。
-
CallableCache:
get(K, Callable)形式,每次调用可以传不同的加载函数。
举个例子,如果你需要做“缓存穿透保护”——第一次 miss 时加载,第二次 miss 时给默认值——用 get(key, () -> defaultValue) 就很方便。
4.5 淘汰与过期机制
Guava Cache 的淘汰是基于近似 LRU 算法(Least Recently Used),加上容量和时间的限制。注意它不是精确 LRU,但实际效果足够好。
过期策略有四种:
- 基于容量:条目数超过
maximumSize时,淘汰最久未使用的。 - 基于写时间:写入后固定时长过期。
- 基于访问时间:最后一次读/写后固定时长过期。
- 基于引用:允许 JVM 在内存不足时回收缓存条目(使用弱引用或软引用)。
我个人的经验:90% 的场景只需要 expireAfterWrite + maximumSize 就够了。expireAfterAccess 容易导致“永远不淘汰的僵尸数据”,需要谨慎使用。
4.6 实战中的坑与优化
-
缓存雪崩:所有 key 在同一时间过期。解决方案:加随机过期时间偏移。比如
expireAfterWrite(10 + new Random().nextInt(5), TimeUnit.MINUTES)。 -
缓存穿透:查询一个不存在的 key,每次都要查数据库。解决方案:在
CacheLoader.load()中返回一个空对象而非 null,并设置短过期。 - 缓存击穿:热点 key 过期后,大量并发请求同时加载。Guava 内部自动做了锁,同一时间只有一个线程加载,其他线程等待。放心用。
-
内存泄漏:如果用弱引用,注意别把 key 的引用丢了导致永远不回收。我一般用 strong reference,配合
maximumSize控制内存。
CacheBuilder.weakKeys() 时要特别小心,因为键是使用 == 比较的,而不是 equals。我很少用这个特性,容易出 bug。
4.7 如何监控缓存效果?
调用 cache.stats() 可以获得 CacheStats 对象,里面包含:
- 命中次数 / 未命中次数
- 总加载时间
- 淘汰条目数
- 当前大小
我习惯在压测时把这些指标打到日志或 Metric 里,观察命中率是否在 80% 以上。如果低于 60%,要么缓存容量太小,要么过期时间太短。
CacheStats stats = cache.stats();
double hitRate = stats.hitRate();
System.out.println("命中率: " + String.format("%.2f", hitRate * 100) + "%");
4.8 适用场景总结
Guava Cache 最擅长这些东西:
- 配置信息缓存(数据库里几万条的配置表)
- 用户会话数据(短生命周期,如 )
- 计算密集型的中间结果(比如复杂报表的汇总)
- 限流器、计数器的本地计数
不适合的场景:
- 需要跨多个应用实例共享的数据(用 Redis / 分布式缓存)
- 数据量达到千万级以上的(内存撑不住)
- 要求强一致性的(本地缓存天然有更新延迟)