先说个我自己踩过的坑。上个月给一个内部报表系统做性能优化,单表三百万行,一个聚合查询平时要跑九百多毫秒。接口高峰期两三百 QPS 不算高,但没有任何缓存层,每次请求都直接查库,数据库 CPU 一度飙到 80%。后来在应用层加了个 Guava 本地缓存,热数据 30 秒过期,接口响应时间直接从 900ms 降到 20ms 出头,数据库 CPU 稳在 15% 左右。改动量极小,一个配置类加一个缓存封装类就搞定了。
今天这篇博文就是把这类实战经验系统梳理一遍:SpringBoot 项目里怎么正确集成 Guava 本地缓存。从选型逻辑、加载方式、容量与过期策略,到工程化封装、监控埋点、高频踩坑,每一步都会给出可以直接抄的配置和代码。适合读过 SpringBoot 基础、想在项目里引入本地缓存但又不想直接上 Redis 的开发者,也适合那些已经用了 Guava Cache 但总觉得命中率不高、时不时内存飙高的同学——大概率是你把过期策略和加载方式用错了。
1. SpringBoot集成Guava本地缓存:这个项目到底做了什么
1.1 解决什么样的痛点
SpringBoot 单体应用最常见的性能瓶颈其实不在代码效率,而在数据库访问。业务系统里总有那么几个"查询逻辑复杂、返回结果相对固定"的数据,比如字典表、地区树、系统配置、最近一小时的排行榜聚合结果。每次请求都重新查库,等于用一台数据库扛住所有重复劳动。
本地缓存解决的就是这个问题:把一部分数据直接放到应用进程内存里,第一次查库加载,之后直接内存命中返回。Guava Cache 是 Google Guava 工具库里的一个重要组件,也是 Java 生态里最经典的进程内缓存实现之一。和后来流行的 Caffeine 相比,Guava Cache 胜在稳定、零额外依赖、API 足够清晰,尤其适合那种"不想引入复杂缓存框架、只想给某个热点查询加一层短时缓存"的场景。
这套方案我在好几个项目里验证过:一个每日千万级流水的统计接口,加了 Guava 本地缓存后,P99 从 3 秒降到 120ms;一个登录态接口,用 expireAfterWrite 5 分钟的策略缓存 token 解析结果,网关转发耗时几乎减半。效果都很直接。
1.2 项目最终交付形态
这个实战项目最终会交付三样东西:
第一,一个 Guava Cache 的 SpringBoot 配置类,集中管理所有缓存实例,初始化参数外部化到 application.yml,不同业务模块可以拥有独立的缓存桶。
第二,一个通用缓存装饰服务,把"查缓存-未命中加载-回填-失效"这套动作封装成模板方法,业务代码里只需要传 key、加载函数、过期时间即可。
第三,一套监听和统计机制。用 Guava 自带的recordStats()收集命中率,暴露一个接口给监控系统或告警平台,再加一个定时任务在缓存使用率偏高时打印内存快照,防止线上不知不觉 OOM。
这套结构的好处是:业务代码基本不感知缓存细节,未来某个模块要迁移到 Redis,只需要替换底层实现类,接口不动。
2. 为什么本地缓存选Guava:跟Caffeine、Redis、Spring Cache的差异
2.1 SpringBoot项目里的缓存选型逻辑
先摆结论:在 SpringBoot 里做缓存,有四个常见选型方向——Caffeine、Guava Cache、Spring Cache 抽象、Redis。很多团队上来就上 Redis,但 Redis 的本质是分布式缓存,解决的是多实例共享和跨进程一致性,代价是每次都走一次网络 I/O。如果你的服务是单实例部署,或者缓存数据本就可以接受"每台机器各自缓存、短暂不一致",直接用进程内缓存反而是最优解。
Guava Cache 和 Caffeine 是同一个类型的选手,都在进程内。Caffeine 性能更强,支持异步加载、淘汰策略更丰富,Caffeine 内嵌了 TinyLFU 相关能力和一个独立的数据结构优化,在读写吞吐量极高的场景下确实更猛。但 Guava Cache 也不是没有优势:API 足够稳定,团队里大部分成员都熟悉,完全不需要引入额外第三方依赖——因为很多项目本来就已经依赖了 guava 工具库。对于大多数业务接口的低并发读取场景,Guava 的性能瓶颈根本不会暴露出来,选它的维护成本更低。
还有一类方案是 Spring Cache 抽象,也就是@Cacheable那一套。它本质上是一个门面,不直接提供缓存能力,底层还是要指定一个 CacheManager。你可以把@Cacheable和 Guava 结合用,但不建议在业务里直接依赖注解,原因下面细说。
2.2 Guava Cache最容易被忽略的三个能力
第一个能力是定时刷新而不过期。refreshAfterWrite可以做到"缓存数据在后台线程加载新值,老值继续对外服务",这是很多系统从 Redis 切换到本地缓存后最怀念的能力。第二个能力是按权重淘汰,maximumWeight配合weigher,可以按缓存对象的真实内存占用而不是条目数来限制缓存总大小,这个对防止内存膨胀特别有用。第三个能力是自动统计,recordStats()会记录 hit count、miss count、load success count 等指标,很方便接监控。
这些能力很多人在项目里从来没用过,甚至不知道存在。后面章节我会把这三个能力对应的使用场景和参数配置展开讲。
3. 三种加载方式:从入门到真正适配业务场景
3.1 CacheLoader:固定加载逻辑的首选
Guava Cache 最经典的用法是构建时传入一个CacheLoader,定义"缓存 miss 时如何从数据源加载数据"。这种方式的语义是:整个缓存只服务于一种固定加载逻辑,调用方只需要 get(key)。
LoadingCache<String, SysConfigDO> configCache = CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(new CacheLoader<String, SysConfigDO>() { @Override public SysConfigDO load(String key) throws Exception { return sysConfigMapper.selectByKey(key); } }); // 使用 SysConfigDO cfg = configCache.get("sms.switch");这种方式的优点是:代码最简洁,get 操作完全不关心数据从哪来,拿到就是可用的。符合"缓存只是数据源的一个加速层"这种设计语义。
但注意一个容易忽略的点:CacheLoader是全局绑定的,如果你在一个缓存里既要查配置、又要查用户信息,那就得想办法把 key 设计成带前缀的复合键,比如"config:" + key和"user:" + id都传进来,然后在 load 里按前缀分派。我个人不太推荐这种揉法,会让缓存逻辑的职责变得混乱。遇到这种情况,早一点拆成多个缓存实例更好。
3.2 Callable:把加载逻辑推迟到调用处
第二种方式是cache.get(key, Callable),把加载逻辑放在每次调用时传入:
Cache<String, BigScreenData> dailyReportCache = CacheBuilder.newBuilder() .maximumSize(100) .expireAfterWrite(Duration.ofMinutes(10)) .build(); BigScreenData data = dailyReportCache.get( dateStr, () -> bigScreenService.queryDailyData(dateStr) );这个方式非常灵活,同一个 Cache 实例可以服务于完全不同来源的数据。它的内部逻辑是:key 有值返回缓存值;没有值则执行 Callable,将结果放入缓存并返回。且并发时同一个 key 只会有一个线程执行加载逻辑,其他线程等待结果,这就是 Guava 的LocalCache分段锁在起作用。
需要留意的点是:Callable 里面不能有耗时不可控的重试循环。如果加载逻辑是个慢调用,最好在外面设置一个超时保护。否则某个缓存 key 首次 miss 时,所有打到同一 key 的请求都会卡在你的加载函数上,这个在高峰期很容易触发雪崩。我第一次上线时就遇到过一个 2 秒的远程调用被塞进 Callable,缓存热启动阶段接口直接全部超时。后来调整思路:加载逻辑尽量直连数据库或本地文件源,针对慢依赖单独设超时。
3.3 手动 put 与失效清理
第三种方式是手动写入。cache.put(key, value)用于你提前知道数据或主动覆盖数据;cache.invalidateAll()缓存失效调用后清空。常见的场景是数据变更后主动刷新。比如后台管理员改了配置,实时调一下configCache.put("key", newValue),或者直接 invalidate 让下次 get 重新加载。
这里我给一个小经验:不要循环调用 invalidate 上千个小 key 来清理全量缓存。一次invalidateAll()对应全表刷新,性能损失远小于逐个失效。如果是批量更新了部分 key,可以把 key 收集到一个 Set 里,然后统一调用cache.invalidateAll(keys),而不是在循环里逐个调。
手动操作最需要注意的坑是:put 进去的值不会执行任何加载器逻辑,所以你自己在外面已经查了一遍数据库。这就带来一种性能损耗——每次 put 前要查一次,get 也会查一次,如果同一段数据同时走 put 和 get 两个通路,数据库压力反而加倍。我见过不少项目改配置刷新时先 invalidate 再调 get,让 get 重新走 load,这样其实更稳,把"加载"这个动作始终收敛到 load 函数内部。
3.4 缓存失效的触发时机
很多新手以为expireAfterWrite会在写入后固定时刻扫描并删除旧数据,这是误解。Guava 的过期策略是惰性的:缓存条目在读写时检查是否过期。你可以自己控制代码中如果 miss 很高,往往不是缓存没设置,而是缓存的过期时间设置得很短,甚至还没有触发 load 后数据就被再次清理。调整过期时间、调整清洗频率,才是命中率管理的重点。
另外,CacheBuilder有一个cleanUp()方法,可以手动触发清理。我的经验是不要写一个定时器每秒钟去调cleanUp(),会拖慢线程且收益极低——Guava 在写入和读取时已经会附带清理过期条目。真要防止老数据堆积,只要保证maximumSize设置合理就行。
4. 容量、过期与并发参数实操
4.1 maximumSize 和 maximumWeight 到底怎么设
maximumSize是按条目数量限制容量。设置多少要结合单条数据的内存占用、业务数据总量、JVM 堆大小三个因素来评估。举个例子:你的配置表一共 2000 条,每条平均 2KB,那么maximumSize(10000)就已经冗余了,设置为 3000 左右比较合适。但如果你缓存的是「商品详情」这类大对象,单条可能 50KB,那 10000 个条目就是 500MB,基本就要出事了。
所以对于大对象缓存,我强烈建议用maximumWeight+weigher:
Cache<String, ProductDetail> productCache = CacheBuilder.newBuilder() .maximumWeight(50L * 1024 * 1024) // 50MB .weigher((String key, ProductDetail value) -> { // 粗略估算:对象字段大小总和,这里用 String.length() 近似 int estimated = value.getName().length() * 2 + value.getDesc().length() * 2 + value.getSkuList().size() * 64; return estimated; }) .build();权重的单位你自定义,可以按字节数也可以按"预估的内存占用"打分。关键是评估逻辑要一致,别把整数和大对象的权重算得一个数量级。维护一个粗略估算公式,保证权重总和控制在 JVM 可承受范围内。
4.2 expireAfterWrite、expireAfterAccess、refreshAfterWrite 的区别
这三兄弟是最容易混的。
expireAfterWrite:写入或替换后经过指定时间,条目过期。适合"数据变更频率低、但一旦变更必须及时看到"的场景,比如系统配置。expireAfterAccess:条目最后一次读取后经过指定时间,如果没有读操作则过期。适合"热点数据频繁读但不希望老旧数据霸占内存"的场景,比如活跃用户列表。refreshAfterWrite:写入后经过指定时间,会触发异步刷新,但刷新期间老值仍然可读。这个我认为是 Guava 本地缓存里最有价值的一个参数。
一个经典的组合是:expireAfterWrite设置一个大一点的兜底值(比如 1 小时),refreshAfterWrite设置一个更活跃的值(比如 5 分钟),这样数据每 5 分钟在后台刷新一次,即使刷新失败也能继续服务,直到 1 小时的硬过期兜底。
但要注意 refresh 的坑:如果调用get(key)时并发太重,刷新这个动作本身也可能造成读放大。因为 Guava 的 refresh 默认还是同步触发,只是加载时会让旧值继续返回。如果加载慢,同一时间大量线程可能都参与到刷新任务里。如果你的加载逻辑非常慢,建议直接设置一个自定义线程池来执行 refresh,或者在加载函数里引入分布式锁之类的保护。
4.3 并发级别和统计开关
concurrencyLevel参数指定了 LocalCache 内 segm 的数量,默认值是 4。这个参数直接影响并发插入和获取时的锁粒度。如果你的缓存会被几十个线程高频写入,concurrencyLevel(16)会减少锁竞争。注意这个值只在缓存构建时生效,后期无法修改,且通常不需要很大的值,JVM 线程越多不代表分段锁越多就越好,盲目调大反而增加内存开销。
统计开关必须在构建时开启,否则cache.stats()全会是 0:
Cache<String, Object> cache = CacheBuilder.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build();通过cache.stats()你会拿到hitRate()、missCount()、loadExceptionCount()、averageLoadPenalty()。这些指标是长期优化缓存最重要的数据来源。
5. SpringBoot中的工程化集成落地
5.1 配置类与外部化参数
SpringBoot 集成 Guava 的第一步是定义缓存参数,外部化到配置文件里,不要散落在代码各个角落写CacheBuilder.newBuilder().maximumSize(...)。这样后续调参不需要改代码再发版。
# application.yml cache: config: max-size: 10000 expire-after-write: 30m refresh-after-write: 5m product: max-size: 3000 expire-after-write: 10m weight-limit: 50MB对应的配置属性类:
@Data @ConfigurationProperties(prefix = "cache") public class CacheProperties { private CacheItem config = new CacheItem(); private CacheItem product = new CacheItem(); @Data public static class CacheItem { private Integer maxSize; private Duration expireAfterWrite; private Duration refreshAfterWrite; private Long weightLimit; } }然后在主启动类或配置类上启用@EnableConfigurationProperties(CacheProperties.class)。这个机制不算新东西,但很多人确实没有利用 SpringBoot 的属性绑定去管理缓存参数,导致每次优化都要翻代码。
5.2 提供一个缓存工厂和通用服务
我不推荐给每个业务模块单独建一个独立的 CacheBuilder 构建流程。更工程化的做法,是把缓存构建收敛到一个工厂类里:
@Component public class GuavaCacheFactory { public <K, V> Cache<K, V> build(CacheProperties.CacheItem prop) { CacheBuilder<K, V> builder = CacheBuilder.newBuilder() .maximumSize(prop.getMaxSize()) .recordStats(); if (prop.getExpireAfterWrite() != null) { builder.expireAfterWrite(prop.getExpireAfterWrite()); } if (prop.getRefreshAfterWrite() != null) { builder.refreshAfterWrite(prop.getRefreshAfterWrite()); } return builder.build(); } public <K, V> LoadingCache<K, V> buildLoading(CacheProperties.CacheItem prop, CacheLoader<K, V> loader) { // 构建 LoadingCache 同理 CacheBuilder<K, V> builder = CacheBuilder.newBuilder() .maximumSize(prop.getMaxSize()) .recordStats(); return builder.build(loader); } }然后再写一个通用服务避免业务层直接操作 Guava 的底层 API:
@Service public class CacheTemplate { private final GuavaCacheFactory cacheFactory; private final Map<String, Cache<String, Object>> caches = new ConcurrentHashMap<>(); public <T> T get(String cacheName, String key, Supplier<T> loader, Duration ttl) { Cache<String, Object> cache = caches.computeIfAbsent(cacheName, name -> cacheFactory.build(ttl)); try { return (T) cache.get(key, loader::get); } catch (ExecutionException e) { throw new RuntimeException("缓存加载失败", e.getCause()); } } public void invalidate(String cacheName, String key) { Cache<String, Object> cache = caches.get(cacheName); if (cache != null) { cache.invalidate(key); } } }业务里的使用就变得非常干净:
DictData data = cacheTemplate.get("dict", "state_type", () -> dictService.loadByType("state_type"), Duration.ofMinutes(10));这套封装还有一个好处:如果未来某个 cacheName 要替换成 Redis 实现,只需要改CacheTemplate的底层,所有调用方零感知。
5.3 缓存命中率监控与告警
工程化集成最重要的不是集成代码,而是后续的观测能力。我给自己的项目每台机器都暴露了一个轻量接口,返回各缓存实例的统计信息:
@RestController @RequestMapping("/internal/cache") public class CacheMetricController { private final CacheManagerService cacheManagerService; @GetMapping("/stats") public Map<String, Map<String, Object>> stats() { Map<String, Map<String, Object>> result = new HashMap<>(); for (Map.Entry<String, Cache<String, Object>> entry : cacheManagerService.getAllCaches().entrySet()) { CacheStats stats = entry.getValue().stats(); Map<String, Object> item = new HashMap<>(); item.put("hitRate", stats.hitRate()); item.put("missCount", stats.missCount()); item.put("loadExceptionCount", stats.loadExceptionCount()); item.put("averageLoadPenalty", stats.averageLoadPenalty()); result.put(entry.getKey(), item); } return result; } }再用一个@Scheduled定时任务,每小时打印一次命中率和内存使用量。如果某缓存命中率连续低于 50%,就要考虑是不是过期时间太短或缓存 key 设计有问题。命中的曲线是你调参的唯一依据,千万不能拍脑袋。
6. 高频坑位与排查技巧实录
6.1 缓存穿透、击穿与雪崩的本地化思考
缓存穿透指的是查询一个一定不存在的数据,每次都绕过缓存落到底层存储。Guava 本地缓存也没有自动防穿透机制,你 get 一个不存在的 key,它一样会执行 load。解决办法很简单:把空结果也缓存起来,比如 load 返回 null 时,用Optional.empty()包装后放入缓存。这样下次同一个查不到的 key 就不会再打穿到下层。
缓存击穿指的是并发场景下某个热点 key 过期,大量线程同时触发 load。Guava 缓存内部机制已经做了相同 key 的并发控制——同一时刻只有一个线程执行 load,其他线程阻塞等待。但注意这个"同一个 key"的语义,它是基于 key 等值判断的,如果你的 key 带了 uid 这类高散列字段,每个 key 的穿透概率就很高,此时排查方向就偏了:不是要找并发锁,而是要检查 key 是不是设计得太细,以及过期时间的凹凸性。
6.2 内存膨胀与 OOM 的防护
Guava 缓存只是内存管理的一个组件,它不会感知 JVM 堆中其他对象的情况。最常见的问题,是缓存对象本身持有大集合或引用链很长,比如把一个包含 100 万个元素的 List 缓存起来,一个 key 就压垮了堆。第二个常见问题是:用maximumSize限制了条目数,但每一条数据很大,总内存还是爆。这种情况要走maximumWeight路线。
我给自己的项目里加了一个保护:在CacheTemplate的 get 方法里,对返回对象做一层SizeEstimator,如果对象估算内存超过缓存预算的 5%,直接拒绝缓存并打 warn 日志。因为大对象缓存收益低、风险高,不如让请求直接查库。
6.3 Spring Cache 自调用与 AOP 代理的坑
如果你把 Guava Cache 作为 Spring Cache 的底层 CacheManager,那会踩到@Cacheable自调用失效的经典坑:
@Service public class DemoService { @Cacheable(cacheNames = "dict") public String getConfig(String key) { return queryFromDb(key); } public String wrapper(String key) { return getConfig(key); // 自调用,@Cacheable 不生效 } }原因很简单:@Cacheable是通过 Spring AOP 代理实现的,代理对象上的方法才能被拦截。自调用是内部方法调用 this.getConfig(),走的是普通方法调用,不走代理,所以缓存永远不会命中。这个问题和项目是 SpringBoot 2.x 还是 3.x 无关,和代理模式无关,本质就是 self-invocation。
我当时排查这个问题花了整整一个下午:日志里缓存总是 miss,翻来覆去检查配置没问题。最后把方法拆到一个独立 Service 里注入调用,或者直接改用前面写的CacheTemplate手动缓存,问题立刻消失。所以我的建议是:生产代码里少用@Cacheable依赖注解魔法,多用手动缓存模板,排查路径更短,行为也更可控。
6.4 单元测试里如何控制时间
Guava Cache 的过期依赖系统时钟,这给单元测试带来一个麻烦:你没法快速验证一个 30 分钟后过期的缓存行为。我的做法是把时钟抽象到工具类里:
public class TimeProvider { public long currentTimeMillis() { return System.currentTimeMillis(); } }测试时用一个可以拨动的 fake clock 替换进去。Guava 的Ticker和Clock其实都支持自定义,CacheBuilder也支持ticker(Ticker)。可以这样:
@Test void testExpireAfterWrite() throws Exception { FakeTicker ticker = new FakeTicker(); LoadingCache<String, String> cache = CacheBuilder.newBuilder() .ticker(ticker::read) .expireAfterWrite(Duration.ofHours(1)) .build(new CacheLoader<String, String>() { @Override public String load(String key) { return "value_" + key; } }); cache.put("k1", "old"); ticker.advance(Duration.ofMinutes(61)); String val = cache.get("k1"); Assertions.assertEquals("value_k1", val); }这样测试完全可控,不需要Thread.sleep。我很多项目里的缓存测试都是这么做的,稳定且快。
6.5 高并发场景下的额外提醒
如果你用的是 SpringBoot 3.2 以上,配置了虚拟线程执行器后,缓存首次 miss 时加载慢的问题会更隐蔽。虚拟线程看似"不阻塞",但慢加载消耗的仍然是 CPU 和 IO 资源。如果你发现最近优化了线程模型后数据库压力反而上升,回头看看是不是本地缓存refreshAfterWrite触发得太频繁、加载函数里做了太多外部调用。适当调大刷新间隔,或者给刷新单独设置线程池,能明显缓解。
写在最后
我个人在实际项目里的体会是:缓存不是银弹,本地缓存更不是万能药。它解决的是"单实例进程内重复查询"这一个大问题,带来的是"多实例间缓存不一致、内存占用上升、数据变更后需要主动失效"这三个新问题。所以引入 Guava 缓存之前,先想清楚你的数据是否可以接受短时间的不一致。如果可以,那就放心用,按本文这套结构把参数外部化、把统计打开、把失效路径设计清楚,收益会非常可观。
最后分享一个小技巧:每次调完缓存参数,先跑一轮全量接口回归,观察hitRate和averageLoadPenalty两个指标。如果命中率高但averageLoadPenalty也高,说明你的加载逻辑是主要瓶颈,应该优化的不是缓存策略,而是底层查询本身。这个方向的判断,比反复调整过期时间要有用得多。