news 2026/10/10 9:53:22

Java本地缓存之王:Caffeine核心原理与实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java本地缓存之王:Caffeine核心原理与实战指南

Java缓存之王:Caffeine权威指南

做Java后端这几年,只要提到进程内缓存,我脑子里蹦出的第一个词永远是Caffeine。这个库从名字就透着咖啡因那种“提神”的味道,性能上确实也把本地缓存的体验拉到了一个新的高度,成了我实际项目中默认选择的本地缓存框架。

这篇文章想好好聊一聊Caffeine。它是什么?简单说,这是一个基于Java 8开发的、脱胎于Guava Cache的高性能进程内缓存库,目前在Maven中央仓库的本地缓存类目中占有率相当夸张,Spring Boot等主流框架也把它当作默认的本地缓存实现。很多同学对它的了解停留在“导个依赖、new一个Cache,put/get就完事了”,但真正面试聊到缓存淘汰算法、过期策略、刷新机制、多级缓存治理的时候,往往就讲不出东西了。这篇内容适合刚接触缓存的新手,也适合准备Java面试、或者想把自己项目的缓存命中率再往上提一提的开发者。我会从核心API、淘汰策略、刷新机制、监控统计、Spring Boot集成到常见坑位全部过一遍。

1. 为什么Caffeine配得上“缓存之王”这个称号

1.1 它的定位:本地缓存里的顶配选手

聊Caffeine之前,先确认一下边界。我们平时说的“缓存”其实分两大类:一类是Redis、Memcached这种网络分布式缓存,数据放在另一台服务器上,访问要走网络;另一类是本地缓存,数据放在当前JVM进程内存里,读写不走网络,速度是纳秒级别的。Caffeine属于后者。

我经常跟团队同学说,本地缓存和分布式缓存是互补关系,不是替代关系。Redis管的是多实例共享的热点数据,比如登录态、配置中心下发的内容;但有些数据每个节点都一样、又极其高频地读,再绕一圈走Redis就完全没必要了。这时候本地缓存直接挡住流量,效果立竿见影。Caffeine就是在本地缓存这个场景里做到了接近极致的选项:吞吐量高、命中率高、内存控制精细,还提供异步加载、统计、监听、权重淘汰这些能力。业界常拿它和ConcurrentHashMap比性能,也常拿它和Guava Cache比功能和命中率——比下来它几乎都是赢的那个。

1.2 和Guava Cache、ConcurrentHashMap、Redis的区别

这四样东西我工作中都常用,把它们放在一起对比会很直观:

方案定位适用场景核心优势
ConcurrentHashMap并发安全的Map并发读写容器、无淘汰诉求吞吐极高,API简单
Guava Cache本地缓存(Caffeine前身)本地热点数据缓存有淘汰、刷新、统计能力
Caffeine本地缓存(增强版Guava)高并发本地缓存W-TinyLFU算法、异步加载、内存控制更好
Redis分布式缓存多节点共享缓存跨进程共享、持久化、集群扩展

Guava Cache当年是Google内部用的缓存库,设计很经典,但后来出现的痛点也很明显:它的淘汰策略是LRU(最近最少使用),对“突发流量型”的偶尔热数据不友好,命中率不够极致;内存占用和锁竞争也偏高。Caffeine的底层数据结构经过重构,加了一个基于TinyLFU改进的W-TinyLFU淘汰算法,并引入了异步加载、Scheduler调度器等机制,在性能和命中率上把Guava甩开了。作者Benjamin Manes自己也发文详细对比过:在CPU密集的读多写少场景里,Caffeine的吞吐量明显高于Guava Cache。

对ConcurrentHashMap来说,它本质上只是个Map,没有容量上限的概念,也没有过期时间。如果直接用ConcurrentHashMap做缓存,可能短时间内还好,一旦数据越来越多又不清理,轻则GC压力大,重则直接OOM。Caffeine相当于在ConcurrentHashMap之上封装了一整套缓存生命周期管理能力。

1.3 用数据说话:性能到底好在哪

Caffeine的官方JMH Benchmark结果在日常使用的场景下,读吞吐量可以达到千万级每秒,写吞吐也是百万到千万级别。这个数字直接带来的业务价值就是:在接口的热点路径上花在本地缓存上的时间几乎可以忽略不计,能把更多的CPU时间留给真正的业务逻辑。

举一个我实际遇到过的例子:某个广告投放系统的曝光数据上报接口,单机QPS峰值为3000,每次请求都要查一次配置信息(数据库查询耗时约20毫秒)。上线Caffeine本地缓存之后,配置查询逻辑从20毫秒降到0.3毫秒左右,接口整体RT从35毫秒压到18毫秒,而且数据库的QPS压力几乎归零。这就是典型的高频读、低频变场景,本地缓存的价值在这个例子里体现得极其充分。

2. 三分钟上手:从依赖到核心API

2.1 引入依赖与版本选择

Caffeine的Maven坐标长这样:

<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency>

最新3.x系列要求JDK 11以上,如果你的项目还是JDK 8,那就用2.x的最后一个稳定版2.9.3。Gradle的写法也顺手给出:

implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'

版本这一块我的经验是:新项目直接上3.x,别在2.x上纠结太久,Apache Commons、Spring Boot 2.4+等都默认支持Caffeine 3.x,生态兼容性已经非常成熟。部分低版本Spring Boot(2.3以下)的项目如果遇到版本冲突,可以单独指定2.9.3,不会出大问题。

2.2 Cache:最基础的读写接口

最简单的构建方式:

Cache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .build(); // 写入 cache.put("product_1001", product); // 读取 Product product = cache.getIfPresent("product_1001"); if (product == null) { // 真正加载数据,再放回缓存 product = loadFromDb("product_1001"); cache.put("product_1001", product); }

Cache接口是最基础的形态,但它缺乏“自动加载”的语义。上面这种写法,每次都要自己判断key存不存在,代码会比较啰嗦。更常用的做法是用LoadingCache。

有人可能会问:直接用getIfPresent之后判断,再put回去,跟我用get(key, mappingFunction)有什么区别?区别在于Caffeine内置了原子性保障。get(key, mappingFunction)内部能保证同一个key在并发访问时只触发一次加载,不会在缓存失效瞬间出现“缓存击穿”式的并发穿透数据库。我自己用这个接口从没遇到过并发重复加载的问题,而手动getIfPresent+put是做不到这点的。

2.3 LoadingCache:让缓存学会自己加载数据

当业务数据是从固定来源加载时,更适合用LoadingCache。构建时需要传入一个CacheLoader:

LoadingCache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .build(new CacheLoader<String, Product>() { @Override public Product load(String key) { return productMapper.selectByCode(key); } }); // 缓冲区未命中时,自动调用load方法加载 Product product = cache.get("product_1001");

这里最舒服的一点是:缓存系统自己管理加载逻辑,业务代码只需要一句cache.get(key)。如果底层存储并发压力可控,还可以重写loadAll方法实现批量加载,getAll自然就能把多个key一次性查出来,对数据库更加友好。

2.4 异步缓存:当数据加载很慢时

有些数据源加载特别慢,比如接口调第三方服务需要1-2秒。如果同步加载,请求就要卡在这等;这时可以用异步的特性来把加载过程放到后台线程里,返回一个CompletableFuture:

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .buildAsync(new CacheLoader<String, Product>() { @Override public Product load(String key) { return slowLoadProduct(key); } }); // 返回CompletableFuture CompletableFuture<Product> future = cache.get("product_1001");

异步缓存适合那种“加载慢、并发高、可以容忍旧值”的场景。需要注意的是异步返回的CompletableFuture本身不能为null,如果load返回null会抛异常,这一点和同步cache不一样,后面避坑章节会细聊。

3. 淘汰策略详解:W-TinyLFU凭什么能提高命中率

3.1 基于容量和基于权重的淘汰

Caffeine最基础的淘汰维度是maximumSize,这个数字代表缓存能容纳的key数量上限:

Caffeine.newBuilder().maximumSize(10_000).build();

当缓存数量达到上限时,Caffeine就会按照淘汰算法算出一个“最该被淘汰”的key,把它清出去。如果你的场景里每个key对应的value大小差异很大,比如有的value是几十字节的小对象,有的是几兆的图片大对象,用数量去限制就不够精准了。这时候可以用权重模式:

LoadingCache<String, Product> cache = Caffeine.newBuilder() .maximumWeight(10 * 1024 * 1024) // 总权重10MB .weigher((key, value) -> value.sizeInBytes()) // 每个条目权重 .build(loader);

使用权重模式时有个注意点:一旦设置了weigher,就不能再用maximumSize了,二者只能选一个。这一点看着是API设计限制,实际却是在倒逼我们明确衡量指标,而不是凭感觉拍板。

3.2 三种基于时间的过期策略

Caffeine提供了三个基于时间的方法,使用场景差异比较大:

方法语义适用场景
expireAfterWrite写入后固定时间过期配置、字典、基础数据等“变化频率低”的数据
expireAfterAccess最后一次访问后固定时间过期会话Token、临时权限等“活跃续期”数据
expireAfter自定义Expiry,由外部决定每条目的过期时长复杂业务场景,如不同类别数据不同TTL

expireAfterWrite最常用,它保证数据写入后过一定时间必然失效,适合数据更新频率可预估的场景。expireAfterAccess则适合“只要还在被用就留着”的场景,比如在线状态。但如果只配expireAfterAccess,可能会遇到一个问题:某个冷门的key因为偶尔被访问一下,永远得不到清理,长期残留在缓存里,内存压力会慢慢变大。所以我的实际建议是:能用expireAfterWrite就用它,确实需要滑动的场景再考虑expireAfterAccess,尽量不要指望“大而全的组合”替你兜底。

自定义expireAfter用法稍微复杂一点,它需要返回一个Expiry对象,并且要求每次操作都能算出“剩余存活时间”。例如:

Expiry<String, Product> expiry = new Expiry<>() { @Override public long expireAfterCreate(String key, Product value, long currentTime) { // 创建后30秒过期 return TimeUnit.SECONDS.toNanos(30); } @Override public long expireAfterUpdate(String key, Product value, long currentTime, long currentDuration) { // 更新后重置为30秒 return TimeUnit.SECONDS.toNanos(30); } @Override public long expireAfterRead(String key, Product value, long currentTime, long currentDuration) { // 读取不改变过期时间 return currentDuration; } };

这个接口的功能很强大,但性能开销比前两者大,如果业务场景不是“必须动态计算每条的过期时间”,我建议能用前两种就别用自定义Expiry,保持简单也是一种可维护性。

3.3 W-TinyLFU算法到底是怎么回事

很多Java面试题里会问到“Caffeine为什么命中率高”,答案核心就在这个算法。

传统的LRU基于“最近访问”来排序,最近被访问过的数据留在缓存里,最久没被访问的被淘汰。LRU的问题很典型:一个偶尔被大量访问的冷门key(比如明星突然上了热搜)进入缓存后,会把之前的高频热门key挤掉;而如果这个冷门key之后不再被访问,缓存里挤掉的却是一个真正高频使用的数据,命中率就下降了。LFU(最不经常使用)可以应对这种突发流量,但它需要记录每个key的历史访问频率,内存开销大,而且存在“历史热点永不衰减”的问题——一个半年前很热、现在没人访问的key,因为历史频率高一直占着位置不走。

TinyLFU用了一个叫Count-Min Sketch的概率数据结构来近似统计访问频率,内存开销极低;同时引入了“频率衰减”机制:每隔一段时间把计数整体除以2,让旧热点的频率逐渐衰减,给新热点腾位置。W-TinyLFU是在TinyLFU基础上又加了一个小窗口区(Window),专门用来暂存新进入的候选数据。新到的key先放在Window区,如果短时间内被访问了多次,就能快速提升频率计数,有机会晋升到主区(Main区);主区里又分为Probation(试用区)和Protected(保护区)。这样的分层带了一个很大的好处:既能识别真实的频繁访问数据,又能快速响应突发的新热点。

用人话说一个场景:缓存只能放100个商品,平时大家买“手机”、“电脑”这种品类商品,LRU也能把它们留在缓存里;突然有一天“限量版球鞋”被大量点击,LRU会把这个球鞋放进来,同时挤掉长期热门的数据,导致后面缓存命中率雪崩一样掉。Caffeine的W-TinyLFU会先观察这个球鞋是不是短时间内反复被访问,如果只是昙花一现的偶然访问,它对主区的高频数据影响很小;如果是真实热点,它又能很快在频率统计上反超老数据,完成席位交接。这个特性,是我在真实项目中体会最明显的地方:突发流量场景下的命中率稳定性,Caffeine确实做得比LRU系缓存好很多。

3.4 Scheduler:谁在触发清理动作

Caffeine内部的清理和执行逻辑依赖一个Scheduler组件,触发时机分为两种,一种是惰性触发:每次get操作时顺带清理过期条目;另一种是主动清理:通过Scheduler定时把过期/淘汰的条目真正移除。Caffeine默认的Scheduler是Scheduler.disabled(),也就是“没有主动后台清理”,所有清理都发生在下次读写时。这样做的好处是避免引入额外的后台线程,降低开销;坏处是缓存容量可能在短时间内微超,或者过期条目没有第一时间被移除。如果你希望过期清理更及时,可以显式配置一个调度器:

Caffeine.newBuilder() .scheduler(Scheduler.systemClock()) .build();

在Spring Boot的CaffeineCacheManager场景里,框架会帮你配好合理的Scheduler,所以直接使用时不用过度关心;但如果你是纯手写工具类封装缓存,可以考虑用Scheduler.systemClock()来驱动定时清理。

4. 刷新机制:expire和refresh要分清

4.1 refreshAfterWrite的特别之处

Caffeine里有一个另类的过期机制:refreshAfterWrite。它记录的是写入后多久“允许刷新”,而不是“强制失效”。读数据时如果发现条目的年龄已经超过这个阈值,Caffeine会触发一次异步重载,但触发的同时还会把旧值继续返回给调用方。用专业术语叫“Stale While Revalidate”,中文可以叫“用旧值撑着,后台刷新”。

LoadingCache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) .refreshAfterWrite(Duration.ofMinutes(1)) .build(key -> productMapper.selectByCode(key));

比如票务系统里商品价格每30秒变一次,你希望尽量让用户尽快看到新价格,但又不想每次读都穿透到数据库。设置refreshAfterWrite(Duration.ofSeconds(30))之后,前30秒内直接命中缓存;超过30秒后第一次请求触发异步刷新,并暂时返回当前旧价格;等后台加载完成,后续请求拿到新价格。整个过程接口的RT几乎不受影响,因为刷新的耗时被放到了异步线程里。

4.2 refresh与expire搭配使用的黄金组合

很多人用refreshAfterWrite有个误区:只配refreshAfterWrite而不配expireAfterWrite。这样逻辑上会有一个问题:如果数据源一直宕机,刷新线程反复失败,旧值就永远不过期,数据会一直“陈旧”下去。所以更稳的做法是把两者配合使用:

Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .refreshAfterWrite(Duration.ofMinutes(1)) .build(loader);

简单解释一下:1分钟之内直接命中缓存;1到10分钟之间,读操作触发异步刷新,你拿到的可能是旧值,但最多旧10分钟;超过10分钟后,缓存强制过期,下次读取就必须同步从数据源加载。这套组合把“时效性”和“并发稳定性”平衡得非常好,也是我日常处理“数据不能太旧,但数据库又不能被打爆”这类问题时的默认方案。在很多Java面试题里,这组搭配也属于经典的送分题,答出来会显得你对缓存设计真的很熟。

4.3 手动刷新与移除操作

手动控制在做数据变更时很重要。比如运营后台改了商品价格,那本地缓存里的旧值就必须立刻失效。Caffeine提供了invalidate系列方法:

cache.invalidate("product_1001"); // 移除单个key cache.invalidateAll(keys); // 批量移除 cache.invalidateAll(); // 清空所有

如果使用的是Cache接口,还可以用put直接覆盖旧值。大多数业务场景我习惯的做法是:发生变更时先更新数据库,再invalidate缓存,让下一次读取时自动重新加载。关于为什么是先更新数据库而不是先删缓存,涉及到缓存一致性,后面会单独展开。

4.4 防止缓存击穿:只有一个线程去加载

在谈刷新机制时,我顺便聊一下缓存击穿问题。某个热点key过期后,瞬间有大量并发请求穿透到数据源,这依赖Caffeine内部的能力可以规避:它用了一个叫JCache适配的computeIfAbsent式原子加载,即使1000个线程同时get同一个key,也只会有一个线程真正执行加载逻辑,其余线程等待结果。别的本地缓存或手写的代码未必能做到这点。很多时候我们在系统里看到的数据库瞬时压力,就是缓存击穿引起的——Caffeine在这块的保障是我非常看重的一个原因。

5. 数据统计、监听与扩展能力

5.1 开启统计:命中率怎么算出来的

不开启统计功能,你对着一个缓存系统只能靠猜。开启后Caffeine会记录hitCount、missCount、hitRate、evictionCount、loadSuccessCount、loadFailureCount等指标,方便接入监控和调优:

Cache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) .recordStats() .build(); CacheStats stats = cache.stats(); double hitRate = stats.hitRate(); long evictionCount = stats.evictionCount();

hitRate是最直观的健康度指标。我个人习惯:热点数据命中率低于60%意味着配置大概率需要优化,有资格背锅了。低于80%也算正常但可提升;已经到95%以上,基本说明配置很合理。另外loadFailureCount也很值得关注,如果加载失败的次数很高,说明底层数据源存在较严重的稳定性问题,而不是缓存本身配置的问题。

5.2 removalListener:监听条目的移除

有些缓存在条目被驱逐时,需要释放外部资源(比如关闭连接、解绑会话)。这时可以用removalListener:

Cache<String, UserSession> sessionCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .removalListener((key, session, cause) -> { if (cause.wasEvicted()) { // 被自动驱逐,清理外部资源 session.close(); } }) .build();

RemovalCause枚举可以区分:EXPLICIT(显式移除)、REPLACED(更新替换)、EXPIRED(过期)、SIZE(容量驱逐)、COLLECTED(垃圾回收)。注意这个监听器是同步执行还是异步执行取决于你配置的Executor,如果要执行重型逻辑,建议自己传入一个业务线程池,别把业务线程卡的太久。

5.3 CacheWriter:与外部存储联动

如果想实现“写缓存的同时同步写数据库”这种强一致模式,Caffeine提供了CacheWriter接口:

Cache<String, Product> cache = Caffeine.newBuilder() .maximumSize(10_000) .writer(new CacheWriter<String, Product>() { @Override public void write(String key, Product value) { productMapper.updateByCode(key, value); } @Override public void delete(String key, Product value, RemovalCause cause) { productMapper.deleteByCode(key); } }) .build();

CacheWriter会在写入、删除、刷新时同步执行回调。它的定位是和外部存储保持同步,在使用时注意写操作会走额外的IO,不能把数据源写动作时间内放到热点路径中,否则缓存反而会变成性能瓶颈。

6. Spring Boot集成与多级缓存实战

6.1 使用Spring Cache抽象:注解方式接入

Spring Cache抽象层提供了@Cacheable、@CachePut、@CacheEvict三个核心注解。其中@Cacheable会在方法执行前先查缓存,命中就跳过方法体;未命中则执行方法并把返回值放进缓存。要接入Caffeine,只需在配置里指定CacheManager实现为Caffeine:

spring.cache.type=caffeine spring.cache.cache-names=userCache,productCache

Spring Boot会自动装配CaffeineCacheManager(老版本默认是ConcurrentMapCacheManager)。不过要注意,这种默认方式只会创建简易缓存,没法配置过期时间、最大容量等细节。所以实际项目中我更喜欢完全手动定义一个配置类,让CacheManager带上定制化的Builder。

6.2 自定义CaffeineCacheManager:配置TTL和容量

Spring Cache抽象对每个缓存名(Cache Name)是一套独立配置。实现多租户式TTL的方案如下:创建多个CaffeineCache实例,每个实例绑定不同的Caffeine配置:

@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager(); Caffeine<Object, Object> userCacheCaffeine = Caffeine.newBuilder() .maximumSize(5_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats(); Caffeine<Object, Object> productCacheCaffeine = Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(30)); manager.setCaffeine(userCacheCaffeine); manager.registerCustomCache("userCache", userCacheCaffeine.build()); manager.registerCustomCache("productCache", productCacheCaffeine.build()); return manager; } }

这里有个比较容易踩的坑:如果同时使用setCaffeine设置了默认Builder,又使用registerCustomCache注册了定制缓存,那么不同缓存名会有不同的TTL。但如果你用的是@Cacheable(cacheNames = "userCache"),并给同一个CacheName在多个方法上使用,就会共享同一个缓存配置,做不到按方法级别隔离TTL。真要按方法区分TTL,就需要拆缓存名,比如userCache_10min、userCache_30min,这是Spring Cache抽象层无法避免的颗粒度限制。

6.3 两级缓存:Caffeine + Redis怎么配合

我在中大型项目中常用的一套组合拳是:本地Caffeine做一级缓存,Redis做二级缓存,数据库在最底层。查询链路按如下顺序:

查询本地Caffeine,命中则直接返回 未命中,查询Redis,命中则回填Caffeine 未命中,查询数据库,回填Redis和Caffeine

代码实现上可以这样组织:

public Product getProduct(String code) { // 一级缓存 Product local = localCache.getIfPresent(code); if (local != null) { return local; } // 二级缓存 String redisKey = "product:" + code; String json = redisTemplate.opsForValue().get(redisKey); if (json != null) { Product p = JSON.parseObject(json, Product.class); localCache.put(code, p); return p; } // 三级来源 Product p = productMapper.selectByCode(code); redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(p), 30, TimeUnit.MINUTES); localCache.put(code, p); return p; }

这种组合能极大降低Redis压力和DB压力。但需要注意“双写一致性”和“缓存击穿/穿透/雪崩”的常态化治理。我的实践经验是:更新数据时,先更新数据库,再删除Redis缓存和本地缓存;删除本地缓存无法精准地清除其他节点的缓存,因此其他节点最多只能依赖过期时间兜底,这在业务可容忍短时间不一致时是完全可接受的。如果对一致性要求很高,一般考虑CDC监听Binlog变更格式化消息,把所有节点缓存全部失效,这块可以单独写一大篇文章了。

6.4 进程内缓存的天然短板

使用Caffeine这类本地缓存时,我们必须清醒认识到:数据只在当前JVM实例内可见。如果服务是单节点部署,一切都没问题;一旦横向扩容到10个节点,同一份数据在每个节点各自缓存一份,更新一个节点后其他节点还是旧数据,直到各自过期。所以在很多需要“实时一致”的场景里,本地缓存不适合单独使用,而是和Redis或者消息通知机制配合,数据更新时通过广播/订阅的形式通知各节点失效本地缓存。一个比较常用的方案是借助Redis Pub/Sub或者MQ广播一个“缓存失效事件”,各节点收到事件后执行cache.invalidate(key)。

这块内容在面试里经常被追问:分布式场景下什么是缓存一致性?怎么治理?当然,如果业务对“一致”要求没那么高,比如允许最多5分钟延迟,那么Caffeine的过期时间本身就可以用来兜底,这也是轻量级方案的通行做法。

7. 常见问题与调优避坑实录

7.1 缓存value不能为null

Caffeine默认不允许缓存null值。直接用cache.get(key)时如果loader返回null,会直接抛出异常;虽然可以设置Caffeine.newBuilder().build((CacheLoader) key -> null)吗?不行,仍然会被判定为加载失败。适合的做法是返回一个Optional:

LoadingCache<String, Optional<Product>> cache = Caffeine.newBuilder() .maximumSize(10_000) .build(key -> Optional.ofNullable(productMapper.selectByCode(key))); // 使用时 Optional<Product> result = cache.get("1001"); if (result.isPresent()) { Product p = result.get(); }

7.2 最大容量设置不合理导致“缓存命中率崩塌”

很多新手图方便,随手设置maximumSize(10_000)。如果一个系统的商品总量有50万条,而热点数据明显超过1万条,这个容量下缓存永远在剧烈抖动,每次业务查询都不命中,命中率可能跌到20%以下,等于没做缓存。

调优的关键是“压出一个相对真实的容量需求”。我自己的做法是:先设置一个感性的初始容量,开启recordStats(),结合应用监控观察几天命中率曲线;再用maximumWeight加上按对象实际大小计算的weigher,把缓存内存预算控制在例如512MB以内。这个调优过程必须由数据驱动,而不是拍脑袋加容量。

7.3 被“刷新”骗了的过期

前面说过,refreshAfterWrite返回旧值并不代表数据没过期。对应地,如果只配refreshAfterWrite,而不设置expireAfterWrite,数据源长时间不可用会让旧数据无限期占用缓存。

我见过一个真实场景:某个配置服务下线后,下游服务因为只配refreshAfterWrite,旧配置在系统里存活了好几周,排查了很长时间。所以配置刷新时必须同时配置一个兜底过期时间,防止数据源故障导致数据永久陈旧。这是Caffeine使用里最容易被忽略的坑之一。

7.4 被自动对象GC“意外”清除

Caffeine支持weakKeys、weakValues、softValues这些引用方式。默认情况是强引用。如果你用softValues,当JVM内存不足时,缓存条目可能在没有触发任何驱逐监听的情况下被垃圾回收掉。这种回收从业务角度看,表现为“缓存无缘无故消失了”。用到weak/soft时,务必确认你的业务能接受这种“不保证存活”的语义,否则别用。

7.5 Executor线程池耗尽问题

Caffeine的刷新、异步加载、监听执行默认使用ForkJoinPool.commonPool。在高并发场景下,如果异步加载任务特别多,这个公共池可能成为瓶颈,甚至影响其他使用了ForkJoinPool的任务。建议独立配置一个专门的线程池:

Executor executor = Executors.newFixedThreadPool(8); Caffeine.newBuilder() .executor(executor) .buildAsync(loader);

注意线程池别配太小,否则异步任务排队也会拖慢数据刷新;别配太大,避免引入过大资源开销。一般8到16线程对大多数业务够用。

7.6 精确命中率的测算

要判断一个缓存配得好不好,最直观的是看命中率。但命中率数据是“缓存访问次数”的命中百分比,随着系统压力变化,它并不稳定。我会关注两个时间维度:小时级指标,快速发现配置或流量突变;天级指标,做长期容量规划。命中率长期低于50%时,优先检查容量是否偏小,TTL是否过短,key是否出现“动态化”——比如把用户ID拼到缓存key里,导致每个用户一份缓存,容量爆炸,命中率自然低。这种情况的解法是调整缓存维度或者按业务维度缩小缓存粒度。

7.7 缓存的序列化与内存占用

存对象到Caffeine时,如果直接存强引用,那对象本身不会被序列化,内存占用就是对象在JVM堆里的真实大小。如果中间经过Redis等外部存储,再回填到Caffeine时反序列化出来的全新对象,存在两份内存,GC压力就会变大。做法上尽量复用同一个对象引用,避免JSON反复解析。对超大value(比如大于几MB),建议仔细评估是不是真的适合放本地缓存,这种情况我更倾向于单独用Redis,而不是放内存里占着宝贵的堆空间。

一些个人实战体会

最后没有太多总结可写,就说说习惯性的做法:半年多以前我把公司一个老项目从Guava Cache迁移到Caffeine后,最直观的感受就是再也不用天天盯着那点堆内存发愁了,W-TinyLFU算法带来的命中率提升不是玄学,线上数据能看到实实在在的收益。凡是高频读、写较少、且可接受短暂不一致的SQL查询,我都会先问一句“能不能用Caffeine挡一层”。本地缓存的定位,从来不是取代数据库或Redis,而是给整个系统加一层极低延迟的加速器。希望这篇“权威指南”能帮你真正用好它。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 9:53:20

手写正则引擎:Python实现NFA确定化与DFA最小化

简介&#xff1a;一个面向编译原理课程设计的完整Python实现项目&#xff0c;围绕正则表达式转NFA、NFA确定化为DFA、DFA最小化三个核心步骤展开。压缩包共9个文件&#xff0c;大小约243KB&#xff0c;包含3个Python源码文件、3张原理示意图、2份Markdown说明文档和1份LICENSE许…

作者头像 李华
网站建设 2026/10/10 9:52:37

Navicat for MySQL 10.1.7 绿色版免安装配置与实战避坑指南

简介&#xff1a;Navicat for MySQL 10.1.7 绿色中文版是一款面向数据库开发与运维人员的轻量级 MySQL 图形化管理工具&#xff0c;免安装即可运行&#xff0c;适合初学者练习 SQL、开发者日常建库建表以及运维人员做数据迁移与查询调试。压缩包共 30 个文件&#xff0c;约 20.…

作者头像 李华
网站建设 2026/10/10 9:51:55

SpringBoot+Vue+MySQL校园商铺管理系统源码拆解与部署指南

说实话&#xff0c;第一次拿到这套“SpringBoot校园商铺管理系统”源码的时候&#xff0c;我脑子里就蹦出一句话&#xff1a;这又是一个典型的毕设/课程设计项目&#xff0c;但它恰好能把你前后端的所有基本功串成一个完整闭环。SpringBoot、Vue、MySQL这三件套放在今天&#x…

作者头像 李华
网站建设 2026/10/10 9:50:45

基于支持向量机的数据回归预测:从libsvm到可一键运行的Matlab工程

简介&#xff1a;本资源为基于支持向量机&#xff08;libsvm&#xff09;的数据回归预测完整实践包&#xff0c;面向计算机、人工智能、自动化、通信工程等专业的在校学生与教师&#xff0c;适用于课程设计、期末大作业、毕业设计及项目初期立项演示等场景。包内共5个文件&…

作者头像 李华
网站建设 2026/10/10 9:50:19

物联网平台设备接入实战:从网关、MQTT到毕业设计全流程解析

开年在好几个项目上跟物联网平台打交道&#xff0c;说实话我对“解决方案提供商”这个词是有戒备的&#xff0c;因为很多公司把它当成PPT专用词汇。但“智捷云”这个定位——快捷、智能、高效的物联网解决方案提供商——我反而觉得可以掰开揉碎聊一聊。物联网现在最大的问题不是…

作者头像 李华
网站建设 2026/10/10 9:50:11

元启发式算法介绍,按解的数量分类

分类框架&#xff1a;单解方法与种群方法 按照“算法在任一时刻维护多少个候选解”这一维度&#xff0c;元启发式算法可分为两大类&#xff1a;对比维度单解方法&#xff08;轨迹方法&#xff09;种群方法候选解数量只维护 1 个“当前解”同时维护几十到几百个解搜索轨迹形态解…

作者头像 李华