1. Guava Cache 到底是什么
先说个业务场景。假设你维护一个订单查询接口,每次请求进来都要读一次商品信息。商品数据的变化频率很低,一天可能就改那么几次,但接口的 QPS 动不动就是几千。这时候如果你每次都去查数据库,数据库的压力会非常大,响应时间也会被拖垮。很多团队的常规做法是在应用里加一层 Redis,但中间多一次网络跳转,延迟还是能到几毫秒。如果数据量不大、机器内存充裕,其实更优雅的方案是在 JVM 进程内直接放一层缓存,也就是本文要聊的 Guava Cache。
Guava Cache 是 Google Guava 工具库里的一个模块,专门用来做进程内的本地缓存。它的核心价值就一句话:把最常用的数据放到应用自己的内存里,查询的时候直接从内存拿数据,不用跨网络、不用查数据库,耗时直接从毫秒级或者几十毫秒降到微秒级。除了快之外,它还自带过期策略、容量控制、统计监控、并发控制等能力,等于把你在生产环境中自己写缓存要考虑的大部分坑都提前帮你填了。
具体来说,它适合这几类场景:
- 数据变更不频繁,但读非常频繁的数据,比如配置项、字典表、商品基础信息;
- 单机应用就能扛住的数据量,缓存数据总量在几百 MB 到几个 GB 以内,放内存不心疼;
- 对数据一致性要求不是每毫秒都必须一致,能接受秒级或者分钟级的短暂延迟;
- 没有现成的 Redis 集群,或者不想为了一个简单的热点读场景引入额外的中间件。
它不适合的场景也很明确:如果你需要跨多个应用节点共享同一份缓存数据,或者缓存的数据量超过几个 GB,那 Guava Cache 就不太合适了。这种时候老老实实用 Redis 或者 Caffeine 搭配分布式缓存方案,别硬上。
接下来的内容,我会从原理层面拆解 Guava Cache 的关键机制,再用实际代码和场景带你走一遍完整实战,最后把我在线上遇到的一些典型坑和排查思路分享出来。
2. 核心原理拆解:它是怎么做到这么快的
2.1 分段锁与 ConcurrentHashMap 的底层支撑
Guava Cache 的底层存储结构是一个自定义的 LocalCache,你也可以把它理解成一个增强版的 ConcurrentHashMap。它把内部数据分成了多个 Segment,每个 Segment 是独立的一段哈希表结构,都有自己独立的锁。并发写入的时候,不同 Segment 之间的线程互不干扰,只有在操作同一个 Segment 的时候才需要竞争锁。这个设计借鉴了 ConcurrentHashMap 的分段锁思想,就是为了降低并发冲突的概率,提高读写吞吐。
我拿一个真实的数据对比一下。在某个压测场景里,用 8 个线程并发读写一个包含 10 万条数据的缓存,Guava Cache 的 TPS 大概能到每秒 20 万次以上,而用一个全局锁包住所有读写操作的单机实现,TPS 大概只有 5 万左右。差距非常明显。
有一点需要专门说明:Guava Cache 的键和值都不允许为 null。为什么?因为 CacheLoader 加载数据的时候,如果返回值是 null,框架会认为加载失败,会抛出异常而不是默默放一个空值进去。这个设计其实很有讲究——如果你允许 null 缓存进去,你的业务代码就要额外判断“缓存里拿到的是数据还是 null 占位”,很容易写出糊涂的逻辑;而让 null 直接报错,反而能逼着你保证缓存中的数据都是有效数据。
2.2 过期机制与真正的内容淘汰时机
Guava Cache 提供了三种过期方式:基于写入时间的 expireAfterWrite、基于访问时间的 expireAfterAccess,以及通过重写 CacheLoader 的 load 方法配合 refreshAfterWrite 实现的定时刷新。这三个用起来各有利弊,下面展开说。
先说 expireAfterWrite。这个比较好理解,就是数据写入缓存后的 N 秒内有效,超过 N 秒再访问,就会触发重新加载。适合那些变化频率非常低的配置型数据。比如某个应用的业务开关配置,每 10 分钟检查一次更新就够了,那设置 expireAfterWrite(10, TimeUnit.MINUTES) 非常合适。
expireAfterAccess 则是基于最后一次访问时间来计算的,比如你设置了 5 分钟过期,那这个数据只有在连续 5 分钟没有任何访问的情况下才会被淘汰。它适合那种“访问频率会间歇性变化”的热点数据,但有一个问题:如果某个 key 一直被访问,它永远不会过期,会造成长期占用内存。所以用 expireAfterAccess 的时候一定要配合 maximumSize 限制总容量。
还有一个很多人没搞明白的关键细节:Guava Cache 并不保证在数据刚过期的那一刻就立刻从内存里删除。它的淘汰分两种场景——惰性删除和后台维护删除。惰性删除就是当你访问一个 key 的时候,发现它已经过期了,那这次访问就会触发重新加载并替换旧值;后台维护则是由一个 ScheduledThreadPoolExecutor 定期扫描一部分缓存,把过期的条目删掉。这个“定期”不是每秒都全量扫,而是有间隔的。因此,严格来说,Guava Cache 是一个“近似过期”的缓存库,它没办法做到时间一到就立刻删除,如果你有强实时淘汰的需求,需要自己另做触发机制。
refreshAfterWrite 和 expireAfterWrite 是另一组容易混淆的概念。refreshAfterWrite 的意思是:数据写入 N 秒后,如果它被访问到了,那么这次访问会立即返回旧值,同时异步触发重新加载数据,新值加载完成后替换旧值。这个机制特别适合“访问频繁、数据偶尔变化、并且能接受短暂读旧值”的场景,比如用户的地理位置信息或者运营配置。它能避免在缓存大规模失效时,大量请求同时回源数据库造成的雪崩问题。
2.3 容量控制与淘汰策略
Guava Cache 支持两种容量控制方式:按条数 maximumSize 和按权重 maximumWeight。
maximumSize 最简单,你设置缓存最多能放多少个 key。当条目数超过这个上限时,会通过 LRU(Least Recently Used)近似算法淘汰最久没有被访问过的条目。注意,这里说“近似 LRU”是因为 Guava Cache 维护的是一个根据访问时间排序的 AccessQueue,按序淘汰访问时间最早的条目,和严格意义的 LRU 效果非常接近,但实现上更轻量。
maximumWeight 则是按权重来限制。什么意思?就是说缓存里每个条目可以设置不同的权重,如果一个对象占用内存特别大,你会希望它被淘汰得快一点。比如某个数据对象平均 1KB,另一个平均 100KB,如果都按条数限制,很多小对象会把大对象挤出去。按权重限制,你就可以给大对象一个更高的权重。权重计算是通过 Weigher 接口实现的,具体代码下面会给出。
还需要注意一个小坑:maximumSize 和 maximumWeight 不能同时用,它们内部用的是同一个容量控制逻辑,你设置了两者中只有一个会生效,甚至可能导致配置混乱。实际项目中二选一就好。
2.4 引用类型与内存释放
Guava Cache 还提供了一种特殊能力:设置键或值的引用类型为 weak(弱引用)或 soft(软引用),配合使用场景做内存优化。
- weakKeys:使用 WeakReference 包装 key,当 key 对象没有其他强引用时,GC 时会被回收。适合 key 是业务对象、并且你希望 key 对象不再被业务使用时就自动释放缓存的场景。
- softValues:使用 SoftReference 包装 value,JVM 内存不足时回收。适合缓存 value 很大、你想在内存压力大时降级淘汰的场景。
这里我有一个经验值:实际生产环境里,weakKeys 用得非常少,因为它的语义很容易造成困惑——你可能以为缓存里还有数据,但 key 没了,缓存就自然也取不到了;用的最多的是 softValues,尤其是缓存的大对象,配合 maximumSize 双重控制,内存稳定很多。
不过,我还想提醒一句:不要被“弱引用能优化内存”这句话误导。弱引用和软引用并不能解决缓存占内存的所有问题,它只是在 JVM 垃圾回收时帮你做了一层保障。真正决定缓存占内存大小的,还是你的 maximumSize 或 maximumWeight 设置得够不够合理。
3. 实战前的准备工作与参数选型
3.1 Maven 依赖与基础配置
Guava 的坐标很简单:
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>33.2.1-jre</version> </dependency>如果你用的是 Gradle:
implementation 'com.google.guava:guava:33.2.1-jre'版本号我建议跟一下最新稳定版,至少别用 30 以下的版本。有些老版本和 Java 新版本之间会有模块化兼容问题。
然后一个最基础的 Guava Cache 构建代码长这样:
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import java.util.concurrent.TimeUnit; LoadingCache<String, UserInfo> cache = CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(new CacheLoader<String, UserInfo>() { @Override public UserInfo load(String key) throws Exception { return queryUserFromDb(key); } });这里的关键角色是 LoadingCache。它和普通 Cache 最大的区别是:当你 get(key) 时如果缓存里没有数据,会自动调用 CacheLoader 的 load 方法去加载,加载成功后再放回缓存。这个过程对调用方完全透明,也就是所谓的“自动加载”。而普通 Cache 则需要你自己判断是否存在,手动调用 put 填充数据。
如果不想用 CacheLoader,也可以这么写:
Cache<String, UserInfo> cache = CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterAccess(10, TimeUnit.MINUTES) .build(); UserInfo user = cache.getIfPresent("userId"); if (user == null) { user = queryUserFromDb("userId"); cache.put("userId", user); }这两种写法各有适用场景。LoadingCache 适合那些加载逻辑固定、不需要额外参数的场景,比如根据 userId 查询用户;Cache 适合那些加载逻辑复杂、还需要额外参数的场景,比如要先查 userId 对应的租户 id,再根据租户 id 去查数据。
3.2 参数的三个黄金原则
第一次用 Guava Cache 的人都容易犯一个毛病:看到参数多就想着全配上。我强烈建议你在配置参数前先问自己三个问题:
第一,这个缓存允许数据有多旧?也就是能容忍的最大不一致时间。如果业务上要求数据库改了,缓存必须立刻失效,那 Guava Cache 其实并不合适,你应该考虑换支持订阅通知的缓存方案。如果业务上能接受 5 分钟延迟,那 expireAfterWrite 设成 5 分钟,简单省心。
第二,这个缓存最多能占多少内存?建议你评估一下线上机器的堆内存和当前已用内存。假设 JVM 堆是 4GB,当前老年代已经用了 3GB,那缓存最多也就分到 500MB。如果每条数据平均 2KB,那 maximumSize 可以设到 20 万左右。千万别拍脑袋设一个 1000 万条的上限,很可能缓存容量还没达到上限,JVM 先 Full GC 了。
第三,缓存失效后,回源数据库的流量能扛住吗?这是一个很容易被忽略的参数。假设你有 5 个 key 在同一秒过期,回源时数据库扛得住;假设你有 5 万个 key 在同一秒过期,数据库大概率就雪崩了。遇到这种情况,你应该考虑用 refreshAfterWrite 或者给缓存加载逻辑加分布式锁来控制回源流量。
我再补充一个参数:recordStats()。它能统计缓存的命中率、加载成功/失败次数、淘汰数这些指标。别小看这个功能,线上排查缓存问题,没有命中率数据你基本就是瞎猜。
LoadingCache<String, UserInfo> cache = CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() .build(...); CacheStats stats = cache.stats(); System.out.println("命中率: " + stats.hitRate()); System.out.println("加载成功次数: " + stats.loadSuccessCount()); System.out.println("淘汰总数: " + stats.evictionCount());3.3 工具选型:为什么我没提 Caffeine 和 Redis
说到本地缓存,很多人肯定会问:现在不是有 Caffeine 吗?性能比 Guava Cache 好很多,为什么不直接用 Caffeine?
我的看法是:如果是一个全新项目,而且你对第三方库没有任何限制,那 Caffeine 确实是一个很好的选择。它的 API 设计在很大程度上是参考 Guava Cache 做的,迁移成本极低,性能在某些场景下也确实更好。但现实情况是,很多公司已有的老项目早就引了 Guava 依赖,可能只是用了 String、集合这些基础工具类,再加一个 Guava Cache 几乎是零成本。而且 Caffeine 在某些版本上对 Java 版本有要求,你还得排查依赖冲突。
至于 Redis,它的定位完全不一样。Guava Cache 是进程内的、每个 JVM 实例各存一份的缓存;Redis 是独立的、跨进程的共享缓存。如果你有多个应用实例,用 Guava Cache 时每个实例都有自己的副本,数据的一致性全靠过期时间去兜底;用 Redis 则所有实例共享一份。这两者不是替代关系,很多时候是配合使用:Redis 作为一级缓存,Guava Cache 作为 Redis 前面的二级缓存,先查本地,再查 Redis,最后回源数据库。
4. 核心实战:手写一个用户信息缓存模块
4.1 用户信息缓存的设计思路
这是我们项目里真实落地过的一个场景。系统里有一个用户基础信息服务,被订单、营销、消息推送等多个模块调用,读请求量非常大,每天几千万次。用户信息本身来自一个 MySQL 表,偶尔会被用户运营平台修改。最初我们就是这个接口每次查库,后来发现数据库连接池经常被打满,接口平均耗时 80ms,高峰期甚至到 200ms,严重影响下游依赖方。
改造思路很直接:加本地缓存。用户信息的 key 就是用户 ID,value 是一个包含昵称、头像、等级、状态等信息的对象。因为这个数据允许最多 5 分钟的延迟,我们用 expireAfterWrite 就能很好地控制数据新鲜度。同时,用户信息数据量不大,线上用户规模大约 100 万,每条用户信息大约 1.5KB,总量 1.5GB 左右,这在 8GB 堆内存的机器上是可以接受的。
4.2 完整代码与核心机制解析
先给出完整的实现类:
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.cache.RemovalListener; import com.google.common.cache.RemovalNotification; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class UserInfoCacheService { private static final Logger log = LoggerFactory.getLogger(UserInfoCacheService.class); // 创建线程池用于异步刷新 private static final ListeningExecutorService refreshPool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(4)); private final LoadingCache<String, UserInfo> cache; public UserInfoCacheService() { this.cache = CacheBuilder.newBuilder() // 最多缓存 120 万条 .maximumSize(1_200_000L) // 写入 5 分钟后过期 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入 2 分钟后开始异步刷新 .refreshAfterWrite(2, TimeUnit.MINUTES) // 统计命中率等指标 .recordStats() // 淘汰时打印日志,方便观察缓存行为 .removalListener(new RemovalListener<String, UserInfo>() { @Override public void onRemoval(RemovalNotification<String, UserInfo> notification) { log.info("缓存条目被移除, key={}, 原因={}", notification.getKey(), notification.getCause()); } }) .build(new CacheLoader<String, UserInfo>() { @Override public UserInfo load(String userId) throws Exception { return queryUserFromDb(userId); } @Override public ListenableFuture<UserInfo> reload(String key, UserInfo oldValue) throws Exception { // 异步刷新,不阻塞请求线程 return refreshPool.submit(() -> { try { return queryUserFromDb(key); } catch (Exception e) { log.error("异步刷新缓存失败, key={}", key, e); // 刷新失败时返回旧值,保证可用性 return oldValue; } }); } }); } public UserInfo getUserInfo(String userId) { try { return cache.get(userId); } catch (Exception e) { log.error("获取用户信息缓存失败, userId={}", userId, e); // 兜底逻辑:失败时查询数据库 return queryUserFromDb(userId); } } public void invalidate(String userId) { cache.invalidate(userId); } private UserInfo queryUserFromDb(String userId) { // 这里省略具体的数据库查询逻辑 // 真实项目中建议先查 Redis,再查 MySQL return new UserInfo(userId, "昵称", 1, System.currentTimeMillis()); } }这段代码有几个值得展开讲的点。
第一点是 maximumSize + expireAfterWrite + refreshAfterWrite 的组合。我解释一下为什么是这个组合。expireAfterWrite(5, MINUTES) 保证了缓存里数据最多只有 5 分钟的延迟,5 分钟后强制重新加载;refreshAfterWrite(2, MINUTES) 则保证当一个 key 被访问时,如果它已经写入超过 2 分钟,就会异步触发一次刷新,把最新的数据拿回来。这样做的好处是:在缓存过期前,我们尽量提前把数据刷新为新版本,避免过期瞬间大量请求回源。
第二点是 reload 方法。这个方法是 refreshAfterWrite 触发后调用的方法。它的关键优势在于:当缓存条目达到 refresh 条件时,请求线程会立刻返回旧值,同时后台线程池异步执行 reload。reload 的结果不影响当前请求,只影响后续请求。这就能避免缓存失效时请求线程被数据库查询阻塞。
第三点是 removalListener。它会在缓存条目被淘汰或手动移除时收到回调。你可以在这里做日志记录、数据统计等操作。注意,removalListener 的回调是在淘汰发生时的线程里执行的,不要在里面做耗时的操作,否则会影响缓存性能。
4.3 用于异步批量预加载失败重试的补充设计
还有一个很多场景都会用到的补充能力:手动刷新和批量预热。
比如你上线了一个新功能,希望缓存里提前加载好一批热点用户的用户信息,避免上线后首个请求因为缓存缺失而回源数据库。你可以手动调用:
userInfoCacheService.refresh("hotUserId1");refresh 和 get 的区别在于,refresh 不会因为缓存中已经有值而跳过加载,它是无条件触发一次重新加载。如果缓存里压根没有这个 key,refresh 也不会帮你自动 put 进去,这一点要注意。所以预热的时候建议用 get 先加载,再 refresh。
批量加载可以用 getAll 方法。如果你希望一次传入多个 key 一次性获取一批数据,可以这么写:
List<String> userIds = Arrays.asList("1001", "1002", "1003"); ImmutableMap<String, UserInfo> result = cache.getAll(userIds);getAll 的默认行为是逐个调用 load 方法,这在批量场景下会变成串行查库,性能很一般。如果你希望批量加载,需要重写 CacheLoader 的 loadAll 方法:
@Override public Map<String, UserInfo> loadAll(Iterable<? extends String> keys) throws Exception { // 批量执行 SQL 查询,例如 SELECT * FROM user WHERE user_id IN (...) return queryUsersFromDb(keys); }这里有个大坑:如果你重写了 loadAll,那么当 getAll 获取的 key 列表里只有一个 key 时,Guava Cache 会直接调用 load 方法,而不是 loadAll;只有 key 列表大于一个时才走 loadAll。这个行为不是 bug,是设计如此,但很多人第一次用都会在这里困惑。
5. 高级实战:多级缓存设计与热点配置管理
5.1 多级缓存的整体架构
很多业务系统为了解决接口性能问题,会同时引入本地缓存和分布式缓存,形成两级架构。我画一个简单的流程描述:请求进来,先查 Guava Cache,命中直接返回;没命中,查 Redis,Redis 有则写回 Guava Cache;Redis 还没有,再查数据库,拿到结果后写回两级缓存。
这种架构下,Guava Cache 承担的是最热数据的快速路径,Redis 承担的是跨节点数据共享和兜底。它的核心收益是:能把接口平均耗时从几十毫秒降到 1 毫秒以内,同时大大降低 Redis 的 QPS 压力。
我举一个实际数据。某营销活动页面需要查询用户的优惠券列表,这个接口高峰期 QPS 是 3000。改造前,每次请求都要查 Redis,Redis 的 QPS 冲到 6000 左右,偶尔还有超时;改造后,本地缓存承担了 70% 的请求,Redis QPS 降到 2000,接口 p99 耗时从 85ms 降到 6ms。
5.2 热点配置缓存如何用 Guava Cache 做实时感知
还需要考虑一个问题:配置中心的配置变更,怎么才能让本地缓存快速感知?
一个比较简单可靠的办法是:在配置变更回调中,主动调用 Guava Cache 的 invalidate 方法,把对应 key 清除。这样下一个请求来的时候会重新加载数据库或 Redis。
要注意,invalidate 方法执行时只会从缓存中移除该 key,不会触发 CacheLoader 的重新加载。你需要自己在下次 get 时让它自动加载。所以配置变更的回调逻辑应该是:invalidate(key) —— 清除旧值,而不是 refresh(key) —— 立即加载新值。因为配置中心的回调线程通常不是安全可重入的线程,在回调里立即加载新值反而可能造成数据竞争。
如果你希望在配置变更后立即让其他线程感知到新值,可以在 invalidate 之后,用线程池异步调用 get(key) 强制把新值加载到缓存里。但这里要小心并发问题——多个线程同时 get 同一个 key,Guava Cache 内部会保证只有一个线程去加载,其他线程阻塞等待结果,所以不用自己加锁。
5.3 超大对象缓存与权重计算
有一个业务场景是缓存用户的个性化推荐结果。这个推荐结果是一个 JSON 字符串,热门用户的最长能达到 500KB,而普通用户可能只有 2KB。如果直接按条数设置 maximumSize,比如 10 万条,一旦全是热门用户,内存占用可能就是 50GB 以上,直接内存溢出。
这种场景必须用权重控制。代码写法如下:
LoadingCache<String, String> recommendCache = CacheBuilder.newBuilder() .maximumWeight(500 * 1024 * 1024L) // 总权重上限 500MB .weigher((key, value) -> value.getBytes(StandardCharsets.UTF_8).length) .expireAfterWrite(30, TimeUnit.MINUTES) .build(new CacheLoader<String, String>() { @Override public String load(String key) { return loadRecommendFromDb(key); } });这里的权重函数是直接把 value 的字节数当成权重。当缓存总字节数超过 500MB 时,Guava Cache 会按 LRU 顺序淘汰最久未访问的条目,直到总权重降到上限以内。注意,权重的计算发生在写入缓存时,如果同一个 key 被更新得越来越频繁,它的权重会持续变化,但这不会影响淘汰机制的运作。
6. 常见问题与排查技巧实录
6.1 驱逐不全:为何设置了 maximumSize 仍然感觉内存超标
这是我在维护缓存时最常被问到的问题之一。明明设置了 maximumSize(100_000),为什么 JVM 的堆内存还是不断上涨、迟迟不触发淘汰?
可能性有两个。第一是条目本身占用内存巨大,虽然条数没超过上限,但每条数据占的内存超出预期。比如你缓存的是 value 是一个 List,每个 List 里有 1 万个元素,那 10 万条缓存就等于 10 亿个元素。这种场景建议一定配合权重限制。
第二是 LRU 淘汰时机的问题。Guava Cache 的淘汰不是在写入时立即执行的,而是写入时记录一个临界值,当进行读取或写入时发现超限,才触发异步清理。如果你的缓存只写不读,或者读的频率很低,那清理动作也会很有延迟。所以如果你的缓存是“高写入、低读取”的模式,建议在写入操作之后主动调用 cache.cleanUp() 来立即执行清理。
cache.put(key, value); cache.cleanUp(); // 手动触发清理6.2 InvalidCacheLoadException:加载器抛异常导致取缓存失败
有一个非常典型的误用。你的 CacheLoader.load 方法里,如果查不到数据库数据,你想返回 null 表示“没有这条数据”,但 Guava Cache 会直接抛 InvalidCacheLoadException。为什么?前面说过,键和值都不允许 null,这是框架的硬性规定。
所以正确的做法是,如果确定数据库没有数据,就抛一个自定义的异常,或者返回一个带有“空状态”标记的占位对象。比如:
@Override public UserInfo load(String userId) throws Exception { UserInfo info = queryUserFromDb(userId); if (info == null) { throw new UserNotFoundException("user not found: " + userId); } return info; }这里又会牵出一个新问题:如果每次都抛异常,每次请求都会触发一次数据库查询,缓存就形同虚设了。解决办法是缓存这个“不存在”的状态,可以设置一个特殊的占位对象,value 为 null 的替代品。比如 UserInfo 类加一个静态 EMPTY 实例,然后业务侧去判断。
@Override public UserInfo load(String userId) throws Exception { UserInfo info = queryUserFromDb(userId); return info != null ? info : UserInfo.EMPTY; }6.3 缓存雪崩与击穿:如何避免大量请求同时回源
Guava Cache 的 expireAfterWrite 单独使用,会在某个时刻出现“缓存集中过期”的问题,如果这些 key 恰好又都是大热点 key,那过期瞬间带来的数据库负载压力会非常恐怖。这个现象在缓存领域叫缓存雪崩。
我踩过一次挺惨的坑。线上有一个热点商品信息接口,缓存设置 expireAfterWrite(10, MINUTES)。商品数量不多,大概 5 万个 key,但因为所有 key 几乎是在同一批时间写入的,所以它们也会在几乎同一时间过期。每到整点附近,数据库的 QPS 会突然从 500 冲到 5000,持续几十秒。
解决办法就是前面提到的 refreshAfterWrite。它不是等 key 过期了才去加载,而是在 key 写入后的一个提前时间点就去异步刷新,旧的 value 在刷新期间仍然可以被访问到,因此可以避免批量回源。我最后的配置是 expireAfterWrite(10, MINUTES) + refreshAfterWrite(5, MINUTES)。这样每个 key 在写入 5 分钟之后就会被异步重新加载一次,到 10 分钟的硬过期时间点,它已经提前刷新过了,过期后基本不会再有大量回源。
关于缓存击穿,也就是单个热点 key 过期后大量请求同时回源,Guava Cache 内部也做了处理:同一个 key 并发请求时,只有一个线程会真正执行 load 方法,其他线程会阻塞等待那个线程的返回值。这个机制默认开启,你不需要额外设置。但要注意,阻塞等待时间是有限制的,如果你的 load 方法执行时间过长(比如查库耗时 2 秒),其他线程会一直在阻塞等待,这本身也是一件需要关注的事情。如果数据库查询异常缓慢,你应该考虑在 load 方法内部加超时控制。
6.4 缓存统计与监控:如何及时发现缓存异常
CacheBuilder 提供了 recordStats() 方法,开启后可以通过 cache.stats() 获取 CacheStats 对象,里面包含了命中次数、未命中次数、加载成功/失败次数、淘汰次数等信息。
我建议,线上环境一定要开启这个统计,并且定期把数据输出到监控系统。比如每分钟打印一次命中率和淘汰数量:
ScheduledExecutorService monitorExecutor = Executors.newSingleThreadScheduledExecutor(); monitorExecutor.scheduleAtFixedRate(() -> { CacheStats stats = cache.stats(); log.info("缓存统计, 命中率={}, 加载失败数={}, 淘汰数={}", stats.hitRate(), stats.loadFailureCount(), stats.evictionCount()); }, 1, 1, TimeUnit.MINUTES);通过命中率的变化,可以快速判断缓存配置是否合理。如果命中率长期低于 50%,说明大部分请求都在穿透到数据源,缓存的价值没有发挥出来,你要么提升过期时间,要么优化 key 的设计。如果淘汰数量一直居高不下,说明缓存容量不够,这种情况下再加缓存条数上限往往不是最优解,优先考虑业务上是否可以拆分缓存,减少无效条目。
缓存穿透还有一种经典场景:请求的 key 本身是随机且无意义的,比如用户恶意构造大量不存在的 userId 来请求。这种场景下,缓存永远无法命中,每个请求都会回源数据库。解决办法是用布隆过滤器判断 key 是否可能存在,或者对“空结果”也做短时间的缓存占位。Guava 本身不带布隆过滤器,但 Guava 库里有 BloomFilter 类,用起来也不复杂。
BloomFilter<String> bloomFilter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 100_000, 0.01); // 初始化时把已知的 userId 都加入布隆过滤器 bloomFilter.put("1001"); bloomFilter.put("1002"); // 查询前先判断 if (!bloomFilter.mightContain(userId)) { // 一定不存在,直接返回空 return null; }布隆过滤器判断“可能存在”时会有误判,但判断“一定不存在”时是准确的。放在缓存前面,可以挡掉大部分穿透流量。
7. 一个完整的业务落地案例拆解
前面讲了很多理论和零散代码,这一节我把一个真实的业务场景从头到尾过一遍,展示整个选型、构建、优化的过程。
业务背景:某个积分商城模块,每次用户进入首页都要展示其会员等级、当前积分、今日签到状态等信息。这些信息通过一个聚合接口查询,内部要调会员服务、积分服务、签到服务等三个下游 RPC。改造前,首页接口的 p99 耗时是 320 毫秒,每天调用量 3000 万次。
缓存选型思路如下:
- 会员等级和积分数据变化不频繁,来自会员服务和积分服务,允许 5 分钟延迟。
- 今日签到状态属于高频更新数据,每天凌晨 0 点需要重新计算,而且更新频率高(用户可能一分钟内签到一次)。
- 首页接口对性能要求极高,不能容忍每次请求都打到底层 RPC。
于是我把数据拆成两组缓存,一组用 expireAfterWrite 控制 5 分钟过期,另一组用 refreshAfterWrite 控制 30 秒自动刷新。这样做的核心考量是:会员等级等数据的查询成本相对较低,相差几分钟完全可接受;但签到状态的准确性要求高,用户签到后若缓存还显示“未签到”,业务上会很尴尬,所以刷新频率要高很多。
缓存 key 的设计上也踩过一个小坑。最开始我用 userId 直接当 key,后来发现会员等级可能区分不同的等级体系,比如普通会员和 VIP 会员的等级字段含义完全不一样。如果把这两种数据混在同一个缓存 key 里,value 对象结构会非常臃肿。最后我改为按业务维度拆 key:level:{userId}、points:{userId}、sign:{userId}。虽然 key 数量多了,但每个缓存都是轻量独立、生命周期可控的。
上线后的数据表现:接口 p99 从 320ms 降到 9ms,下游 RPC 的调用量从每天 3000 万次降到每天 200 万次,数据库连接池的负载大幅下降。缓存命中率稳定在 93% 左右。
这中间还有两个非常关键的操作。
一个是预热逻辑。我们系统每天早上 8 点有一个流量高峰,如果缓存是空的,高峰第一波请求会全部回源。所以我在每日定时任务里加了一个预热操作,先把前一天的热点 userId 列表查出来,然后分批调用 cache.get 把这些用户的信息加载到缓存中。预热代码很简单:
List<String> hotUserIds = queryHotUserIds(10000); for (String userId : hotUserIds) { userInfoCache.get(userId); }另一个是优雅关闭。JVM 关闭时,Guava Cache 本身没有内置的关闭方法,它只是一个普通的 Java 对象,会随着 JVM 退出自然消失。但如果你在缓存中维护了线程池(比如 refresh 用的线程池),需要确保在应用关闭时能正确关掉,否则会有线程残留问题。我的做法是注册一个 JVM 关闭钩子:
Runtime.getRuntime().addShutdownHook(new Thread(() -> { refreshPool.shutdown(); }));这个细节很容易被忽略,但线上环境多实例滚动发布时,如果线程池不释放,每次发布都会多出一批空闲线程,时间长了也会占用不少资源。
8. 我对 Guava Cache 实际使用中的一些心得体会
最后再分享几个我在实际项目中越用越深的心得。
不要试图让 Guava Cache 帮你解决所有缓存问题。它的定位就是进程内缓存,而且是单 JVM 层面的,它没有多节点一致性、没有分布式通知、没有持久化能力。一旦你的系统发展为多实例部署,并且对数据一致性有比较严格的要求,就必须引入外部存储。我见到的很多踩坑案例,都是在系统早期单实例时用 Guava Cache 非常爽,到了多实例部署后才发现缓存各存一份、数据不一致的问题非常棘手,最后被迫迁移到 Redis 或 Caffeine 方案。所以做架构选型时,要把系统未来的规模纳入考虑。
关于设置缓存参数,我的建议是:上线阶段宁可把过期时间设短一点,也不要一开始就把过期时间设得很长。短期过期虽然会带来更多的回源请求,但至少能保证业务数据的“新鲜度”;等运行一段时间后,根据监控数据逐步放宽过期时间。反过来,如果你一开始就设一个很长的过期时间,上线后数据不更新引发的用户投诉,可比性能慢一点要严重得多。
Guava Cache 的 API 看起来很简单,但它的坑都藏在细节里:null 的处理、refresh 与 expire 的区别、权重与容量的组合、异步刷新线程池的管理。把这些细节都掌握了,你才算真正把这工具用好了。而且这些经验完全可以直接迁移到 Caffeine 上,因为你会发现 Caffeine 的 API 和设计思路与 Guava Cache 非常相像。
如果你现在正在做接口性能优化,或者正在为“要不要在项目里引入本地缓存”犹豫不决,我希望这篇文章能给你提供一个比较清晰的判断框架:数据读多写少、允许短延迟、数据量可控,那就值得上一套 Guava Cache;不满足这些前提,就先按兵不动,别为了炫技引入一个不合适的技术。