第4章:本地缓存 - Guava Cache 实战

说到本地缓存,我得先坦白一件事。我最早做后端时,Redis 用得顺手,导致小项目也上个 Redis,结果运维跟我说“服务器内存不够”——其实只是一张配置表,十几条数据。后来我学了 Guava Cache,才发现本地缓存在这种场景下简直是绝配。

Guava Cache 是 Google Guava 库里的一个模块。说白了,它是一个内嵌在 JVM 进程里的缓存库。不需要额外部署,不需要网络,拿过来就用。但你以为它只是简单的 Map?嗯,它比 Map 强太多了。

4.1 为什么不用 ConcurrentHashMap?

很多人第一反应:我直接用 ConcurrentHashMap 加个定时清理不就行了吗?我以前也这么干过,结果写出来的代码又臭又长。你想想看,缓存过期时间怎么定?要淘汰哪条数据?并发写入时怎么保证一致性?全部要自己实现。

Guava Cache 把这些都封装好了。它提供了:

  • 自动加载:缓存缺失时自动调用加载函数
  • 淘汰策略:基于大小、时间、引用的多种淘汰方式
  • 过期机制:写入后过期、访问后过期,随意搭配
  • 统计功能:命中率、加载耗时,方便你监控
  • 并发安全:内部做了细粒度锁,不会同时加载同一 key
我个人的习惯:只要缓存数据量在百万以内,且不需要跨进程共享,优先用 Guava Cache。它比 Redis 省去网络开销,延迟通常 < 1ms。

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,但实际效果足够好。

过期策略有四种:

  1. 基于容量:条目数超过 maximumSize 时,淘汰最久未使用的。
  2. 基于写时间:写入后固定时长过期。
  3. 基于访问时间:最后一次读/写后固定时长过期。
  4. 基于引用:允许 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) + "%");
Guava Cache 核心架构 应用代码 CacheBuilder 配置 maximumSize expireAfterWrite refreshAfterWrite recordStats Cache / LoadingCache 实例 内部:ConcurrentHashMap + 近似LRU + 过期队列 CacheLoader load(key) RemovalListener onRemoval() 数据库 / 外部服务 回填数据 cache.stats() 工作流程 1. get(key) → 2. 命中直接返回 → 3. 未命中调用 CacheLoader → 4. 回填并返回 → 5. 淘汰时通知 RemovalListener

4.8 适用场景总结

Guava Cache 最擅长这些东西:

  • 配置信息缓存(数据库里几万条的配置表)
  • 用户会话数据(短生命周期,如 )
  • 计算密集型的中间结果(比如复杂报表的汇总)
  • 限流器、计数器的本地计数

不适合的场景:

  • 需要跨多个应用实例共享的数据(用 Redis / 分布式缓存)
  • 数据量达到千万级以上的(内存撑不住)
  • 要求强一致性的(本地缓存天然有更新延迟)
记住一句话:Guava Cache 是“进程内、内存级、轻量级”的缓存方案。用它,就接受它的单机属性。别想着用它做分布式。