本地缓存选型: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 淘汰的命中率有天花板,读写都是同步阻塞,没有异步加载。
Caffeine:Guava Cache 的现代化重写
Caffeine 的作者就是 Guava Cache 底层 ConcurrentLinkedHashMap 的作者,API 基本照搬,从 Guava 迁移过去改个 import 就完成大半,剩下的小半是 Guava 的 get 抛受检的 ExecutionException(不想 catch 得用 getUnchecked),RemovalListener 的参数签名也不同。性能和功能都拉开了差距:
- 淘汰算法换成了 W-TinyLFU:用少量内存记录访问频率,新条目要证明自己被频繁访问才能挤掉老条目。对游戏服的典型访问模式(少量热玩家贡献大部分读)命中率明显高于 LRU;
- 读操作近似无锁:读写通过环形缓冲异步记录,再批量处理,高并发读的吞吐显著好于 Guava;
- 异步加载:AsyncCache 返回 CompletableFuture,加载不阻塞调用线程。
Cache2k:小众但快
设计目标是极致的简单和性能,淘汰用的是 Clock-Pro 的改良版(实现类就叫 ClockProPlusEviction):cold、hot、ghost 三段加自动调参,靠 ghost 段记住被淘汰过的 key 来抗扫描污染。思路不同于 W-TinyLFU,但同样不是普通 LRU。注意它并不提供淘汰策略切换,"Cache2k 支持 LRU/LFU/FIFO"这个说法不准确,可切换淘汰策略的是十几年前的 Ehcache 2.x。基准测试里 cache2k 常和 Caffeine 互有胜负,单看吞吐时常占上风——不过流传最广的那套基准出自 cache2k 作者之手,看结论时留个心眼。缺点是社区小、中文资料少,遇到问题基本要啃英文文档和源码。
三、差异一张表
| 能力 | Guava Cache | Caffeine | Cache2k |
|---|---|---|---|
| 淘汰算法 | 近似 LRU | W-TinyLFU(命中率优) | Clock-Pro 改良版,自适应 |
| 自动加载 | ✅ LoadingCache | ✅ | ✅ |
| 异步加载 | ❌ | ✅ AsyncCache 返回 Future | ⚠️ 有异步 loader,get 仍阻塞 |
| 刷新(不阻塞读) | ⚠️ 需重写 reload 才真异步 | ✅ 默认异步 | ✅ refreshAhead |
| 命中率统计 | recordStats | recordStats | 内置指标 |
| Spring 集成 | Spring 5 起移除官方支持 | 官方推荐 | 官方模块 + Boot 自动配置 |
| 中文资料/社区 | 多 | 多 | 少 |
四、实战:LoadingCache 缓存玩家数据
拿我们项目的玩家数据缓存来看(Caffeine 写法,Guava 大体同形,差别在 get 要处理受检的 ExecutionException):
LoadingCache<Long,PlayerData>playerCache=Caffeine.newBuilder().maximumSize(50_000)// 最大缓存条目数.expireAfterAccess(Duration.ofHours(6))// 6 小时无访问即过期.recordStats()// 打开命中率统计.build(playerId->{// CacheLoader:未命中自动加载PlayerEntityentity=db.fetch(playerId);returnentity==null?null:PlayerData.of(entity);});// 业务侧只管拿:命中直接返回,未命中自动走 loaderPlayerDatadata=playerCache.get(playerId);几个配置数字背后的考虑。
maximumSize(50_000) 是按单服玩家规模定的。条目是对象引用,实际内存约等于条目数乘以单个 PlayerData 的大小,容量规划时拿这个乘一下——本地缓存的账要记进堆预算(专栏堆外那篇的公式里,它算堆内一项)。
用 expireAfterAccess 而不是 expireAfterWrite,是因为玩家数据是"活跃就保留"的语义:离线超过 6 小时的自然淘汰,下次登录重新加载。
loader 里查无此人返回 null,这里有个迁移时会踩的差异,三家行为都不一样:Caffeine 的同步 loader 允许返回 null,视为无此条目;同样的代码放 Guava 会抛 InvalidCacheLoadException,它不允许 loader 返回 null;cache2k 默认也拒绝 null,loader 返回 null 会抛 NullPointerException 并被包成 CacheLoaderException,要么开 permitNullValues(true),要么改用 Optional 或空集合来表达"没有"。
再看看配套的那个登录临时缓存:
Cache<Long,PlayerEntity>loginCache=Caffeine.newBuilder().maximumSize(4_096).expireAfterWrite(Duration.ofSeconds(60))// 只活 60 秒.build();// 新玩家登录期间 put;PlayerData 加载完成后主动 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 Benchmarks:Cache2k ≥ 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、条目数和内存,以及"过期兜底加主动移除"这个双保险的设计习惯。