
本地缓存选型Guava Cache、Caffeine 与 Cache2k摘要配置表、玩家数据、会话状态游戏服里到处要用本地缓存。候选绕来绕去就三个Guava Cache、Caffeine、Cache2k。这篇把三者的真实差异讲清楚用一个LoadingCache 缓存玩家数据的实战例子展示用法最后给选型结论。一、本地缓存在游戏服的位置先把分层摆清楚才知道本地缓存管什么请求 → 本地缓存JVM 内存纳秒级 未命中 → Redis毫秒级跨进程共享 未命中 → DB毫秒到几十毫秒持久层配置表、字典这种一旦加载几乎不变的东西放 Redis 都嫌远玩家数据这类读多写少的实体本地缓存加定时落库也是常见组合。我们项目里的用法玩家的核心数据用一个 LoadingCache 缓着访问未命中时自动从 DB 加载实体构建旁边还有一个 60 秒的短命缓存专门接住新玩家登录期间的临时实体登录完成后移除。两个缓存接力各管一段生命周期后面当实战例子细讲。二、三个候选分别是什么Guava Cache老牌够用Google Guava 里的缓存模块。底层数据结构脱胎自 ConcurrentHashMap按段拆分降低锁竞争淘汰用近似 LRU。支持容量回收、定时回收按写入时间或访问时间、基于引用的回收weakKeys/softValues也支持 CacheLoader 自动加载和 refreshAfterWrite 刷新。这里先纠正一个流传很广的说法“Guava Cache 不支持自动加载和刷新”——不对这两个能力它都有我们项目里的 LoadingCache 就是证据。它真正的短板是年代LRU 淘汰的命中率有天花板读写都是同步阻塞没有异步加载。CaffeineGuava Cache 的现代化重写Caffeine 的作者就是 Guava Cache 底层 ConcurrentLinkedHashMap 的作者API 基本照搬从 Guava 迁移过去改个 import 就完成大半剩下的小半是 Guava 的 get 抛受检的 ExecutionException不想 catch 得用 getUncheckedRemovalListener 的参数签名也不同。性能和功能都拉开了差距淘汰算法换成了 W-TinyLFU用少量内存记录访问频率新条目要证明自己被频繁访问才能挤掉老条目。对游戏服的典型访问模式少量热玩家贡献大部分读命中率明显高于 LRU读操作近似无锁读写通过环形缓冲异步记录再批量处理高并发读的吞吐显著好于 Guava异步加载AsyncCache 返回 CompletableFuture加载不阻塞调用线程。Cache2k小众但快设计目标是极致的简单和性能淘汰用的是 Clock-Pro 的改良版实现类就叫 ClockProPlusEvictioncold、hot、ghost 三段加自动调参靠 ghost 段记住被淘汰过的 key 来抗扫描污染。思路不同于 W-TinyLFU但同样不是普通 LRU。注意它并不提供淘汰策略切换Cache2k 支持 LRU/LFU/FIFO这个说法不准确可切换淘汰策略的是十几年前的 Ehcache 2.x。基准测试里 cache2k 常和 Caffeine 互有胜负单看吞吐时常占上风——不过流传最广的那套基准出自 cache2k 作者之手看结论时留个心眼。缺点是社区小、中文资料少遇到问题基本要啃英文文档和源码。三、差异一张表能力Guava CacheCaffeineCache2k淘汰算法近似 LRUW-TinyLFU命中率优Clock-Pro 改良版自适应自动加载✅ LoadingCache✅✅异步加载❌✅ AsyncCache 返回 Future⚠️ 有异步 loaderget 仍阻塞刷新不阻塞读⚠️ 需重写 reload 才真异步✅ 默认异步✅ refreshAhead命中率统计recordStatsrecordStats内置指标Spring 集成Spring 5 起移除官方支持官方推荐官方模块 Boot 自动配置中文资料/社区多多少四、实战LoadingCache 缓存玩家数据拿我们项目的玩家数据缓存来看Caffeine 写法Guava 大体同形差别在 get 要处理受检的 ExecutionExceptionLoadingCacheLong,PlayerDataplayerCacheCaffeine.newBuilder().maximumSize(50_000)// 最大缓存条目数.expireAfterAccess(Duration.ofHours(6))// 6 小时无访问即过期.recordStats()// 打开命中率统计.build(playerId-{// CacheLoader未命中自动加载PlayerEntityentitydb.fetch(playerId);returnentitynull?null:PlayerData.of(entity);});// 业务侧只管拿命中直接返回未命中自动走 loaderPlayerDatadataplayerCache.get(playerId);几个配置数字背后的考虑。maximumSize(50_000) 是按单服玩家规模定的。条目是对象引用实际内存约等于条目数乘以单个 PlayerData 的大小容量规划时拿这个乘一下——本地缓存的账要记进堆预算专栏堆外那篇的公式里它算堆内一项。用 expireAfterAccess 而不是 expireAfterWrite是因为玩家数据是活跃就保留的语义离线超过 6 小时的自然淘汰下次登录重新加载。loader 里查无此人返回 null这里有个迁移时会踩的差异三家行为都不一样Caffeine 的同步 loader 允许返回 null视为无此条目同样的代码放 Guava 会抛 InvalidCacheLoadException它不允许 loader 返回 nullcache2k 默认也拒绝 nullloader 返回 null 会抛 NullPointerException 并被包成 CacheLoaderException要么开 permitNullValues(true)要么改用 Optional 或空集合来表达没有。再看看配套的那个登录临时缓存CacheLong,PlayerEntityloginCacheCaffeine.newBuilder().maximumSize(4_096).expireAfterWrite(Duration.ofSeconds(60))// 只活 60 秒.build();// 新玩家登录期间 putPlayerData 加载完成后主动 invalidate登录是个多步骤流程中间态实体用短命缓存接力流程终点主动移除。过期时间兜底防泄漏关键节点主动移除保证语义准确双保险。五、几个实用细节refreshAfterWrite 和 expireAfterWrite 的行为差异要分清refresh 是到点后由下一次读触发加载期间返回旧值expire 是到点后条目失效下一个读要同步等加载完成。不过读不阻塞这件事三家不完全一样Caffeine 默认把刷新丢到 executor 上异步跑谁都不等Guava 的 CacheLoader.reload 默认实现是同步委托给 load其他线程读旧值确实不阻塞但触发刷新的那个线程要原地等加载完想真异步得重写 reload或者用 CacheLoader.asyncReloading(loader, executor) 包一层。配置表这种旧值短暂可容忍的用 refresh防加载抖动玩家数据这种要最新的用 expire。maximumSize 计的是条目数不是内存字节数。对象大小差异大时用 maximumWeight 加 weigher 自己称重不然缓存的实际内存可能远超预期。key 的 equals/hashCode 要可靠。缓存和 HashMap 一样靠它判等自定义 key 对象比如服务器 ID 加玩家 ID 的组合键老老实实实现或者直接用拼接字符串——TreeSet 那篇的教训在这里同样适用。上线后盯着 recordStats 的命中率看。命中率长期上不去要么容量给小了要么这份数据根本不适合缓存别凭感觉调参。六、选型结论性能排序可以参考 cruftex 的 Java Caching BenchmarksCache2k ≥ Caffeine Guava Cache。但这个排序要打个折看它是 cache2k 作者本人跑的不算中立第三方而且数据停在 2017 年这几个库后来都迭代了很多版。想看新一点的cache2k 官方的 Benchmarks 更新到 2021 年 12 月Java 17 加 JMH 1.33对比 cache2k 2.6.0、Caffeine 3.0.5、EHCache3 3.9.6结论仍是 Cache2k ≥ Caffeine只是这轮没带 Guava而它同样出自 cache2k 项目。排序仅供量级参考。实际选型基本不用纠结新项目直接 Caffeine。性能足够好和 cache2k 的差距在实际业务里几乎感知不到Spring Boot 官方集成中文资料多异步加载和 W-TinyLFU 都是真实收益老项目里的 Guava Cache 不必急着换。低并发场景它没有任何问题真遇到性能瓶颈或需要异步加载时再动手改个 import 就完成大半cache2k 留给特殊场景对吞吐有极致要求、且团队愿意啃英文资料时再考虑。一句话总结这题十年前的答案是 Guava今天是 Caffeine怎么用好的关键是分清 refresh 和 expire、条目数和内存以及过期兜底加主动移除这个双保险的设计习惯。