ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Caffeine与Guava Cache深度对比:从W-TinyLFU到性能实测选型指南

Caffeine与Guava Cache深度对比:从W-TinyLFU到性能实测选型指南 前阵子在给一个读多写少的推荐接口做压测单机QPS到2万左右的时候Redis的GET请求量已经逼近每秒1万次监控面板上Redis那一路CPU直接飙到百分之三四十。这时候我做了个本地缓存把热点商品数据放到进程内存里Redis压力瞬间掉了一个数量级。但紧接着又冒出一个新问题本地缓存的轮子到底选哪个Caffeine和Guava Cache这对Java本地缓存里的老熟人和当红炸子鸡名字听起来很像API更是几乎长一个样子。但也正因为太像很多人压根没搞清楚它们之间的差距其实不在“多两行少两行代码”而在底层数据结构和淘汰算法上完全是两套设计哲学。这篇就把两个组件从原理到实战拆开揉碎讲明白包含我压测到的性能数据、迁移踩过的坑以及最终如何根据自己的业务场景做选型。适合正在做服务端缓存优化、或者打算升级本地缓存组件但又拿不准从哪入手的Java开发。1. 为什么有了Redis我们还是要研究本地缓存先聊一个问题分布式缓存已经这么普及了Redis能扛的QPS也不低为什么项目里仍然需要本地缓存1.1 从一场压测说起Redis扛不住的并不是QPS数字很多人以为Redis的性能瓶颈是QPS达到上限其实根本不是。内网访问Redis的单次往返大概在零点几毫秒到一两毫秒之间这个延迟在低并发下完全不是问题。问题出在并发放大当你的单机接口有2万QPS而其中一半请求都需要访问同一个热点数据时这1万次Redis GET请求会瞬间把连接池打满、把网卡和CPU占住Redis主进程还得处理RDB快照、命令队列、慢查询。更要命的是一旦某个缓存key失效同一时刻涌进来的大量请求会全部穿透到数据库这就是经典的缓存击穿。本地缓存解决的就是这个问题。它的位置就在JVM进程内部数据就在你的堆内存里躺着get操作是纳秒级的连网络协议栈都不用碰。它挡在Redis前方把80%的重复热点请求直接消化在进程内Redis的压力自然就下来了。1.2 缓存分层的逻辑本地缓存是最后一公里服务端缓存其实是一层套一层的结构CPU多级缓存L1/L2/L3纳秒级完全由硬件管理JDK控制不了。进程内缓存Caffeine、Guava Cache就在这一层微秒级应用自己控制容量与过期。分布式缓存Redis、Memcached毫秒级服务间共享。数据库最终数据源磁盘/网络IO速度最慢。每一层都有各自的代价而越往上走速度越快但能存的东西越少。说到这里打个比方Redis就像集团总部的物料仓房什么都有但每次取货都要办手续、走物流本地缓存则是每个门店自己门口的小货架爆款商品提前放上去顾客进门顺手就拿走了。没有门店货架的支撑所有门店的取货需求都涌向总部仓库再宽的物流通道也会堵。很多人忽略的一点是本地缓存不单是“加速”它更能保命。当Redis出现故障、网络抖动或者执行慢命令时本地缓存能兜底扛住核心读流量给运维争取恢复时间。1.3 本地缓存的边界一致性要付出的代价本地缓存没有免费午餐。每个服务实例各持一份数据副本当一个实例更新了缓存其他实例并不知道必然存在短暂的不一致窗口。多实例部署下同一份数据在不同的进程里可能是不同版本。我在实际项目里的处理原则是这样的允许暂时不一致、但最终需要收敛的数据才放本地缓存比如商品标题、用户昵称、配置参数、公告内容。而订单状态、库存数字这类强一致数据绝不放进本地缓存还是老老实实走Redis或者直接查库。这个边界问题会在后面选型章节再展开。2. 底层算法之战Guava Cache的LRU近似与Caffeine的W-TinyLFU这两个组件最大的差异不在API命名而在一个核心问题上当缓存容量快满时到底该淘汰谁先说结论Guava Cache用的是LRU的近似实现而Caffeine用的是W-TinyLFU算法。这个差异直接决定了两者在特定负载下性能表现的巨大分野。2.1 Guava的LRU近似实现简单但扛不住扫描Guava Cache的经典实现是分层段式的内部像ConcurrentHashMap一样分成多个Segment每个Segment里维护一个访问队列和一个写队列。当一个key被读取时它所在的节点会被移动到访问队列的队尾淘汰时从队首开始移除。问题就出在这个“访问就移动到队尾”的机制上。一旦遇到批量遍历扫描的场景比如后台上百个商品ID循环调用、报表任务全量读取、运营批量导出一次性把大量低频key读了一遍这些低频key会被不断推到队尾把真正高频访问的热点key硬生生挤出缓存。这就是业界常说的LRU扫描污染。更隐蔽的一点是Guava Cache每个Segment的LRU是独立的淘汰时只在当前Segment内进行。当某个Segment的容量满了而其他Segment还很空闲时它也只能在自己的段里做淘汰可能把一个本可以留着的key赶出去。所以准确说Guava Cache是“分段LRU近似”不是全局限度的LRU。2.2 W-TinyLFU到底做了什么窗口、考勤表与三个区域Caffeine采用的W-TinyLFU是一个组合策略拆开看包含三个关键设计。第一是Count-Min Sketch频率估计器。Caffeine不可能为每个key精确记录它被访问过多少次——那本身就要消耗大量内存。它用的是一个固定大小的二维计数数组每个key通过若干个哈希函数映射到数组的多个位置每次访问就把对应位置的计数值加1。由于哈希冲突频率估计会有误差但误差可控而且内存占用极小。可以理解成一张“模糊考勤表”不记人名只在对应格子画正字虽然偶尔会记串但大体趋势不会错。第二是窗口缓存区Window。新进入的key并不直接进入主缓存区而是先放到一个容量很小的窗口区这个窗口区用简单LRU管理。新key只有在窗口区里待过一段时间、证明了自己不是一次性访问之后才有资格进入主缓存区。第三是主缓存区的SLRU结构。主缓存区分成试用区Probation和保护期Protected两块保护期里的key是经过一段观察后仍然保持高访问频率的热点数据。试用区的key一旦在窗口区被淘汰后进来会和试用区里的“老住户”进行频率PK谁的频率高谁留下保护区的key继续被访问会留在保护期偶尔一次没被访问不会被马上踢出去。这套组合拳的效果是真正高频访问的key因为频率在“考勤表”里累积很难被低频扫描批量挤掉而一次性访问的key就算把窗口区挤爆了也进不了主缓存区太多空间。这就是W-TinyLFU名字里“Tiny”的意义——用极小的内存开销换来了远比LRU精准的访问频次识别。2.3 为什么频率记忆能救缓存污染拿电商大促举例。平时用户的访问集中在几百个爆款SKU上这些key在Caffeine的频率记录里已经被打了很高的分。运营某天做了一次全量商品信息同步几万个SKU在后台被批量读取一遍。换成LRU实现这几万个低频key会继续被放在队尾很快把前面几百个爆款SKU全部挤掉接下来用户的真实请求又会全部打到Redis和数据库缓存形同虚设。换成W-TinyLFU虽然后台批量读取也会把几万个SKU的频率各加1但爆款SKU原本的频率计数可能是几千甚至上万靠一次扫描撼动不了它们的地位。同时新进入的几万个低频key在窗口区就被频繁淘汰进不了主缓存区几次。一次大范围扫描之后热点数据依然稳稳留在缓存里。这也解决了我一直觉得LRU很别扭的地方LRU只记住了“最近一次”的时间但完全没记住“历史上被访问了多少次”。Caffeine等于多了一个长期记忆而这个记忆只需要很小的内存开销就能换来。3. 读路径竞争才是性能差距的根源算法差异解决了“淘汰谁”的问题但还有一个更底层的差异直接决定了高并发下的吞吐量读缓存这一步的竞争成本到底有多高。3.1 Guava Cache命中的代价每次读取都要“动”链表先看清楚Guava Cache在命中时发生了什么。如果你调用getIfPresent并且key已存在Guava不仅要把value取出来还要把这个entry从双向链表里摘下来再重新插到队尾。这个“摘下来再插队尾”的动作如果多个线程并发做就必须有锁保护避免链表指针相互覆盖。高并发场景下读请求虽然不需要互斥等待同一个key但大家都去操作同一个队列结构时锁竞争就会产生。当一个线程持锁调整链表其他线程只能阻塞等待锁释放。压测时看线程状态能看到不少线程卡在锁等待上。3.2 Caffeine的读路径事件异步归并锁让位给缓冲区Caffeine在命中时做了什么它的主缓存查询是直接从ConcurrentHashMap中取出value返回结果然后大概率只是把“这个key被访问了一次”这个事件写进一个环形缓冲区。写环形缓冲区用的是CAS无锁操作几个线程同时写只需要原子地推进一个游标不互相阻塞。后台会有一个线程专门消费这个缓冲区里的事件批量更新频率估计器、批量移动节点位置。也就是说访问顺序的维护被“记账式”地异步化和批量化了而不是每次读都同步操作链表。这个设计很像外卖平台的订单系统每个顾客下单都是一次写操作但骑手取餐时是批量把一批订单一起带走的而不是送一单回来一次。代价是访问顺序更新有一定延迟但对于缓存淘汰来说差几个毫秒更新访问记录完全没关系整体收益大得多。Caffeine另外还做了一个近似时钟的优化。它维护一个虚拟时钟而不是每次访问都调用System.nanoTime()。在高频读场景下系统时间调用虽然快但累积起来也是可观的原子指令开销用一个轻量的ticker来推进模拟时间能再省下一部分开销。3.3 伪共享、缓存行与内部细节底层同样决定上限Caffeine在底层还做了不少让人佩服的细节优化。比如它对缓冲区的数组做了缓存行填充尽量避免不同的线程写入相邻数组槽位时触发CPU缓存行伪共享。伪共享这个东西很多人没意识到它是高并发性能的隐形杀手线程A改了数组里第0个元素线程B在改第1个元素虽然看起来各改各的但因为它们在同一个缓存行上每次修改都会导致对方缓存行失效被迫重新从内存加载。这类优化在低并发下根本感觉不出来但在几十线程同时操作缓存时效果非常明显。也正是这些细节叠加让Caffeine在官方和其他开源的JMH基准测试里高并发纯读吞吐量长期比Guava Cache高出一截。4. 性能实测数据与测试方法参考聊完底层原理必须用数据说话。但先说清楚性能数字在不同机器、不同JDK版本、不同并发度下差异很大下面这些数据是我在自己压测环境和社区常见benchmark中观察到的典型范围目的是给一个量级参考不是绝对值。4.1 基准测试的合理做法防止跑出无意义数字跑本地缓存benchmark有几个关键参数一定要控制好。并发度单线程跑两者差距很小8到32线程才能体现真实并发场景下的锁竞争差异。命中率如果大量模拟未命中的key测出来的其实是缓存加载器的耗时而不是缓存的性能。预热缓存JIT要预热W-TinyLFU的频率估计器也需要时间“学习”热点分布不预热的结果基本没有参考价值。避免死代码消除读出来的value如果不消费JIT可能把整个方法优化成空操作。我被JMH写过一段极简的对比结构大致是这样BenchmarkMode(Mode.Throughput) OutputTimeUnit(TimeUnit.SECONDS) State(Scope.Benchmark) public class CacheBenchmark { private static final int KEY_COUNT 10_000; private CacheLong, String guava; private CacheLong, String caffeine; private final ThreadLocalRandom random ThreadLocalRandom.current(); Setup public void setup() { guava CacheBuilder.newBuilder() .maximumSize(KEY_COUNT) .build(); caffeine Caffeine.newBuilder() .maximumSize(KEY_COUNT) .build(); for (long i 0; i KEY_COUNT; i) { guava.put(i, v- i); caffeine.put(i, v- i); } } Benchmark public String guavaGet() { return guava.getIfPresent(random.nextLong(KEY_COUNT)); } Benchmark public String caffeineGet() { return caffeine.getIfPresent(random.nextLong(KEY_COUNT)); } }4.2 读密集场景的对比结果高并发下差距明显在我自己的机器上8核16线程JDK 178个线程并发、缓存容量1万、100%读命中的场景Caffeine的吞吐量大约是Guava Cache的3到5倍。如果写入比例从2%增加到10%Caffeine的优势更明显因为Guava写操作既要加锁更新链路又要维护淘汰队列而Caffeine的写可以走异步归并路径。这组数据跟社区里常见的benchmark结果是吻合的。Caffeine官方在设计时也做了大量对比核心目标就是在高并发读多写少场景下把竞争降到最低。但要泼一盆冷水如果你的服务单机QPS本来就只有几十到几百比如一个内部管理后台那这两个组件的性能差异你运行十年也感知不到。这种情况下选型更多看团队维护习惯不必为了追新强行换。4.3 不同负载下差距有多大我测试中观察到的规律是线程数越高、命中率越高、单次读取的数据量越小时Caffeine的相对优势越明显。相反如果单次读取就要做大量计算的value比如从Map里取一个很大的JSON字符串缓存本身的耗时占比很小整体差异会被稀释掉。还有一个容易被忽略的点统计功能的开关对性能影响很大。Guava Cache和Caffeine的recordStats()都会引入一定开销Caffeine用LongAdder做计数器高并发开销低于Guava的AtomicLong。生产环境如果不需要采集命中率指标建议不开统计功能。5. 从Guava换到CaffeineAPI迁移成本远比你想的低如果说底层差别让Caffeine在性能上胜出那么API层面的兼容性是大多数团队最终决定迁移的最大理由——因为实在没什么可改的。5.1 API对照为什么说Caffeine是Guava Cache的“完全平替”Caffeine的API设计初衷就是向Guava Cache看齐。构建器CacheBuilder对Caffeine、LoadingCache接口直接用、CacheLoader直接用、RemovalListener也基本是一一对应。直接看对照表功能点Guava CacheCaffeine构建入口CacheBuilder.newBuilder()Caffeine.newBuilder()最大容量maximumSize(long)maximumSize(long)过期时间expireAfterWrite(long, TimeUnit)expireAfterWrite(Duration)访问过期expireAfterAccess(long, TimeUnit)expireAfterAccess(Duration)自动加载build(CacheLoader)build(CacheLoader)删除监听removalListener(RemovalListener)removalListener(RemovalListener)统计开关recordStats()recordStats()手动缓存CacheK,VCacheK,V异步加载无buildAsync(AsyncCacheLoader)刷新策略refreshAfterWriterefreshAfterWrite新旧版本有个参数差异要留意Guava老版本过期时间用的是TimeUnit枚举Caffeine从2.x后期到3.x全面推荐DurationGuava在较新版本里也支持Duration了但写代码的时候还是先查一下项目里的版本。5.2 一份真实的迁移样例代码以一个典型场景为例加载用户信息缓存1万条5分钟过期带删除监听和统计。Guava写法LoadingCacheLong, UserInfo guavaCache CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() .removalListener((RemovalNotificationLong, UserInfo n) - { System.out.println(removed cause n.getCause()); }) .build(new CacheLoaderLong, UserInfo() { Override public UserInfo load(Long key) { return userService.getById(key); } });Caffeine写法LoadingCacheLong, UserInfo caffeineCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .removalListener((Long key, UserInfo value, RemovalCause cause) - { System.out.println(removed cause cause); }) .build(key - userService.getById(key));差异就是构建器类名换一下、TimeUnit换成Duration、CacheLoader匿名类换成Lambda、RemovalListener的签名从RemovalNotification换成(key, value, cause)三个参数。其余像get、getIfPresent、put、invalidate这些方法名完全一样调用方代码一行都不用动。5.3 Spring Boot接入比很多人想的还简单如果你的项目通过Spring Cache注解使用缓存那切换更简单。Spring Boot从2.x开始默认支持Caffeine作为缓存实现只需要在配置里指定spring: cache: type: caffeine caffeine: spec: maximumSize10000,expireAfterWrite5m启动类上加EnableCaching业务里继续用Cacheable、CacheEvict这些注解底层实现已经切到Caffeine了。唯一要注意的是spec里的时间单位简写5m表示5分钟5s表示5秒写错了启动会直接报错你排查时会看到spec解析失败的信息。5.4 异步加载Guava没有的能力Caffeine还有一个Guava完全不具备的杀手级能力AsyncCache和AsyncLoadingCache。这个能力在缓存加载耗时较长时非常有价值。AsyncLoadingCacheLong, UserInfo asyncCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .buildAsync(key - userService.getByIdAsync(key)); // 调用方拿到CompletableFuture避免线程被缓存加载阻塞 CompletableFutureUserInfo future asyncCache.get(1L);这个模式很契合目前的主流服务框架比如WebFlux或异步Servlet或者你用CompletableFuture编排了几个缓存查询。Guava的CacheLoader是同步的想在Guava上实现异步得自己在业务层包装代码会乱很多。6. 选型决策参考什么情况下继续用Guava也没问题性能和迁移成本都聊完了还是要回到一个很现实的问题到底该不该换6.1 优先选Caffeine的几个信号新项目落地没有历史包袱直接选Caffeine没有第二个选项。服务并发较高单机接口QPS超过几千或者对TP99延迟敏感。存在明显的热点数据比如爆款商品、热点新闻、热搜榜单这类数据很适合W-TinyLFU的频率识别。需要异步加载希望缓存未命中时异步回源不让调用线程卡在DB查询上。Spring Boot项目Spring Cache对Caffeine作为默认本地缓存实现支持得很丝滑。每个服务都可能在上述场景中出现所以用Caffeine的场景覆盖度非常高。6.2 继续用Guava也完全合理的场景存量老系统代码里到处是CacheBuilder而且服务QPS很低换了也感知不到差别没必要引入新依赖。团队统一依赖管理项目组统一维护在Guava版本上换Caffeine意味着多引入一个组件、多一份依赖扫描工作对于内部管理后台这类系统不值得。只用极简缓存功能仅仅是缓存几个配置项手动put/get连过期和淘汰都用不上那Guava完全可以胜任。这些场景的共同点是缓存不是瓶颈服务本身的业务复杂度才是。与其折腾缓存组件不如把精力放在其他地方。6.3 都不该用本地缓存的情况选型这件事最怕的不是选错组件而是在错误的场景里硬塞本地缓存。以下几种情况建议干脆不用强一致要求写入后其他实例必须立刻读到新值本地缓存无法保证。数据更新极频繁每分钟都有大量key被修改每次修改都要所有实例失效本地缓存失效成本比命中收益还高本地缓存成了负资产。数据集超大可达百万甚至千万级别而且访问分布没有明显热点这时候本地缓存既装不下也没有命中率。内存极其受限JVM堆只有几百MB再塞一个大缓存就OOM了不如老老实实走Redis。本质上本地缓存是给“多点访问同一份热点数据”这种场景准备的加速器。没有热点的缓存就是浪费内存的摆设。7. 生产环境避坑记录过期语义、异步刷新与大ValueCaffeine和Guava Cache都设计得非常易用但也正因如此不少人在生产环境里踩过一些看起来“API用错了”其实“语义没搞懂”的坑。这些大部分我自己都踩过不止一次。7.1 过期语义的坑expireAfterAccess和refreshAfterWriteexpireAfterAccess表示“多久没有被访问就过期”。注意关键词是“访问”——读操作也会刷新过期时间。如果你的接口每几秒就会读一次某个key那这个key理论上可以永远不过期因为读操作一直在给它续命。反过来expireAfterWrite是按写入时间固定过期不管怎么读到点就没了。这两个语义我都踩过坑。之前有个配置缓存本意是5分钟强制更新一次我用了expireAfterAccess结果高峰期连续跑了几个小时都没更新过配置改了也纹丝不动。排查了半天才发现是访问续期把key养成了“不死之身”。refreshAfterWrite则更隐蔽。它的含义是“写入后经过一段时间下一次读取时触发刷新”同时会返回旧值。这个“下一次读取时触发”如果配置了异步刷新还好如果没配置Executor那么刷新动作会直接发生在当前调用线程里——一次慢DB查询会堵住所有读该key的请求。正确做法是显式配置一个带界队列的线程池并指定拒绝策略Executor refreshExecutor new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() ); Caffeine.newBuilder() .refreshAfterWrite(Duration.ofMinutes(1)) .executor(refreshExecutor) .build(key - loadFromDB(key));7.2 大Value与内存估算size不是对象大小很多刚接触的人会默认maximumSize(1000)表示缓存最多占1000字节内存。这是错的。maximumSize的单位是条目数不是字节数。如果你缓存的是大JSON字符串一个value可能就占用几百KB这时候按条数算容量毫无意义。Caffeine也提供了maximumWeightweigher的方式可以自定义权重Caffeine.newBuilder() .maximumWeight(100_000) .weigher((key, value) - value.toString().length()) .build();代价是权重计算本身也有开销。我在实践中的做法是如果value在几十KB以内用maximumSize限制条数就好如果一个缓存项可能到几百KB甚至MB级别比如大列表、文档、图片字节一定改用权重模式并且预估好最大值防止单条数据就把内存撑爆。缓存大Value还有一个隐性坑当大量大Value同时过期并触发了清理垃圾回收会一次性处理海量内存造成明显的GC停顿。比如某次我缓存了一万个平均50KB的对象到期那一瞬间CMS/Parallel GC的耗时突增到几百毫秒。解决思路是给过期时间加一点随机偏移不让所有key在同一秒失效。7.3 上线前的缓存检查清单最后分享一个我每次把缓存组件推进生产环境前会过一遍的清单过期策略到底用expireAfterWrite还是expireAfterAccess要根据业务是否允许“无访问就清理”来判断大多数配置类业务应该用write。刷新方式是否配置了refreshAfterWrite如果是确认Executor线程池参数合理。容量限制maximumSize或maximumWeight是否设置防止内存被无限撑涨。统计开关需要观察命中率就开recordStats配合CacheStats暴露给监控平台。监听器removalListener里千万别做耗时操作比如再次查库或远程调用不然会阻塞缓存操作。并发预检高并发下提前压测而不是上线后再观察。这套清单看起来基础但每一条背后都是线上事故换来的经验。另一个经验是缓存永远要预留冗余。maximumSize设的是上限不代表系统能只用这么多内存。我给容量算预算的时候会按“最大条目数 × 平均条目大小的两倍”来预估堆内存增量宁可多留一点也不要让Full GC频繁来找我。
返回列表