ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Guava Cache原理与实战:本地缓存机制、代码示例与避坑指南

Guava Cache原理与实战:本地缓存机制、代码示例与避坑指南 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。当条目数超过这个上限时会通过 LRULeast 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 包装 valueJVM 内存不足时回收。适合缓存 value 很大、你想在内存压力大时降级淘汰的场景。这里我有一个经验值实际生产环境里weakKeys 用得非常少因为它的语义很容易造成困惑——你可能以为缓存里还有数据但 key 没了缓存就自然也取不到了用的最多的是 softValues尤其是缓存的大对象配合 maximumSize 双重控制内存稳定很多。不过我还想提醒一句不要被“弱引用能优化内存”这句话误导。弱引用和软引用并不能解决缓存占内存的所有问题它只是在 JVM 垃圾回收时帮你做了一层保障。真正决定缓存占内存大小的还是你的 maximumSize 或 maximumWeight 设置得够不够合理。3. 实战前的准备工作与参数选型3.1 Maven 依赖与基础配置Guava 的坐标很简单dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.2.1-jre/version /dependency如果你用的是 Gradleimplementation 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; LoadingCacheString, UserInfo cache CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(new CacheLoaderString, UserInfo() { Override public UserInfo load(String key) throws Exception { return queryUserFromDb(key); } });这里的关键角色是 LoadingCache。它和普通 Cache 最大的区别是当你 get(key) 时如果缓存里没有数据会自动调用 CacheLoader 的 load 方法去加载加载成功后再放回缓存。这个过程对调用方完全透明也就是所谓的“自动加载”。而普通 Cache 则需要你自己判断是否存在手动调用 put 填充数据。如果不想用 CacheLoader也可以这么写CacheString, 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()。它能统计缓存的命中率、加载成功/失败次数、淘汰数这些指标。别小看这个功能线上排查缓存问题没有命中率数据你基本就是瞎猜。LoadingCacheString, 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 就是用户 IDvalue 是一个包含昵称、头像、等级、状态等信息的对象。因为这个数据允许最多 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 LoadingCacheString, 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 RemovalListenerString, UserInfo() { Override public void onRemoval(RemovalNotificationString, UserInfo notification) { log.info(缓存条目被移除, key{}, 原因{}, notification.getKey(), notification.getCause()); } }) .build(new CacheLoaderString, UserInfo() { Override public UserInfo load(String userId) throws Exception { return queryUserFromDb(userId); } Override public ListenableFutureUserInfo 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 不会因为缓存中已经有值而跳过加载它是无条件触发一次重新加载。如果缓存里压根没有这个 keyrefresh 也不会帮你自动 put 进去这一点要注意。所以预热的时候建议用 get 先加载再 refresh。批量加载可以用 getAll 方法。如果你希望一次传入多个 key 一次性获取一批数据可以这么写ListString userIds Arrays.asList(1001, 1002, 1003); ImmutableMapString, UserInfo result cache.getAll(userIds);getAll 的默认行为是逐个调用 load 方法这在批量场景下会变成串行查库性能很一般。如果你希望批量加载需要重写 CacheLoader 的 loadAll 方法Override public MapString, 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命中直接返回没命中查 RedisRedis 有则写回 Guava CacheRedis 还没有再查数据库拿到结果后写回两级缓存。这种架构下Guava Cache 承担的是最热数据的快速路径Redis 承担的是跨节点数据共享和兜底。它的核心收益是能把接口平均耗时从几十毫秒降到 1 毫秒以内同时大大降低 Redis 的 QPS 压力。我举一个实际数据。某营销活动页面需要查询用户的优惠券列表这个接口高峰期 QPS 是 3000。改造前每次请求都要查 RedisRedis 的 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 同一个 keyGuava Cache 内部会保证只有一个线程去加载其他线程阻塞等待结果所以不用自己加锁。5.3 超大对象缓存与权重计算有一个业务场景是缓存用户的个性化推荐结果。这个推荐结果是一个 JSON 字符串热门用户的最长能达到 500KB而普通用户可能只有 2KB。如果直接按条数设置 maximumSize比如 10 万条一旦全是热门用户内存占用可能就是 50GB 以上直接内存溢出。这种场景必须用权重控制。代码写法如下LoadingCacheString, String recommendCache CacheBuilder.newBuilder() .maximumWeight(500 * 1024 * 1024L) // 总权重上限 500MB .weigher((key, value) - value.getBytes(StandardCharsets.UTF_8).length) .expireAfterWrite(30, TimeUnit.MINUTES) .build(new CacheLoaderString, 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 类用起来也不复杂。BloomFilterString 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 对象结构会非常臃肿。最后我改为按业务维度拆 keylevel:{userId}、points:{userId}、sign:{userId}。虽然 key 数量多了但每个缓存都是轻量独立、生命周期可控的。上线后的数据表现接口 p99 从 320ms 降到 9ms下游 RPC 的调用量从每天 3000 万次降到每天 200 万次数据库连接池的负载大幅下降。缓存命中率稳定在 93% 左右。这中间还有两个非常关键的操作。一个是预热逻辑。我们系统每天早上 8 点有一个流量高峰如果缓存是空的高峰第一波请求会全部回源。所以我在每日定时任务里加了一个预热操作先把前一天的热点 userId 列表查出来然后分批调用 cache.get 把这些用户的信息加载到缓存中。预热代码很简单ListString 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不满足这些前提就先按兵不动别为了炫技引入一个不合适的技术。
返回列表