资讯详情

Guava Cache自动加载+异步刷新:从原理到实践解决缓存击穿

📅 2026/10/13 5:37:03 | 华诺云谱 👁 阅读
Guava Cache自动加载+异步刷新:从原理到实践解决缓存击穿
日常开发里本地缓存算是每个后端工程师都绕不开的基建。尤其是热点数据、配置项、基础信息这类读多写少的场景直接查库和远程调用不仅慢还会在流量上来的时候把下游打垮。我以前在好几个项目里都遇到过同一个问题缓存过期那一瞬间大量请求同时回源数据库连接数直接拉满接口超时然后引发连锁反应。后来用了 Guava Cache 的自动加载加异步刷新这个问题才算从根本上解决。Guava Cache 虽然是 Google Guava 里的老成员了放在今天看依然很能打。它提供的 LoadingCache 能在缓存未命中时自动调用加载逻辑而不是让每个业务方自己去判断“有没有缓存、没有就查库、查完再放进去”。而自动加载之外的异步刷新则是解决热点 key 过期阻塞的关键手段。这篇文章我就围绕“Guava Cache 自动加载异步刷新”的实现来拆解为什么要这么设计、底层原理是什么、完整代码怎么落地、有哪些坑是我实测踩过的。1. 为什么需要自动加载和异步刷新1.1 本地缓存的常见痛点很多项目的第一版缓存代码长这样先从 Map 里取取不到再去查数据库查到了塞回 Map。这套逻辑单独看没有大问题但只要并发一上来你会立刻遇到两个非常恶心的现象。第一个是缓存穿透。某个 key 在缓存里没有同时来了 100 个请求这 100 个请求全部查库数据库瞬间被打出慢查询。第二个是缓存击穿。单个热点 key 到了过期时间缓存里正好没有大量请求同时回源虽然这个 key 只对应一条数据但并发量足够大时数据库一样扛不住。Guava Cache 解决这两个问题的方式很直接把“查缓存”和“加载数据”封装进同一个组件里。当多个线程同时请求同一个缺失 key 时只有一个线程真正执行加载其他线程阻塞等待结果。这就是它的自动加载机制。1.2 同步加载的短板自动加载解决了“多个线程重复加载”的问题但如果你使用的是默认的同步加载还有一个隐患某个 key 的加载逻辑很慢比如一个聚合接口要调三个外部服务耗时 800ms。那么在这 800ms 内所有请求这个 key 的线程都会堵在缓存上。更麻烦的是Guava Cache 默认的expireAfterWrite策略是“过期后重新加载”。也就是说一旦 key 到了过期时间下一次请求会触发同步加载这次加载期间其他线程全部阻塞等待。如果加载特别慢或者刚好碰上流量高峰接口 RT 会从 5ms 飙到 800ms甚至超时。这就是异步刷新要解决的场景缓存值快过期了我提前用一个后台线程去刷新它刷新期间旧值继续可用业务线程完全感知不到刷新过程。1.3 异步刷新能带来什么异步刷新配合refreshAfterWrite效果是缓存写入后经过指定时间下一次请求不会触发同步加载而是返回旧值同时触发一个异步任务去更新缓存。这样热 key 永远不会因为过期而阻塞请求业务侧看到的始终是“有值”的缓存。我打个比方同步加载是饿了才去煮饭饭没熟之前一家人全等着异步刷新是饭快吃完了就提前让电饭煲开始煮下一锅大家碗里始终有饭。这个思路在配置类、基础数据类、低频变更类的缓存场景里特别实用。2. 核心实现LoadingCache 与 CacheLoader2.1 基础形态的 LoadingCache先看一个最基本的自动加载写法LoadingCacheString, UserInfo cache CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.MINUTES) .build(new CacheLoaderString, UserInfo() { Override public UserInfo load(String key) throws Exception { return userService.queryByUserId(key); } });用起来更简单直接cache.get(userId_123)如果缓存里没有这个 keyGuava 会自动调用load方法去加载并且保证同一时刻同一个 key 只有一个线程在执行加载其他线程阻塞等结果。这里有几个基础但必要的认知。maximumSize是容量上限超出后会按 LRU 淘汰expireAfterWrite是写入后多久过期过期后下次访问会触发重新加载。这套组合适合数据变更不频繁的场景。2.2 CacheLoader 的职责边界CacheLoader 是 LoadingCache 的加载入口它的load方法定义了“缓存没有值的时候怎么把值算出来”。这个方法的耗时直接决定了缓存未命中时的接口耗时所以尽量让load快不要在里面做太重的逻辑。同时要注意load方法里抛异常的话get调用方会直接收到这个异常。所以很多生产环境里的 CacheLoader 并不会直接查库而是先查一个更底层的持久化缓存查不到再查库并且对异常做了降级处理。还有一种写法是实现loadAll一次性加载多个 key适合批量查询场景。但 Guava Cache 的getAll并不一定会调用loadAll它的默认行为是逐个调用load。如果想真正用上批量加载需要在getAll之前对缺失的 key 做一次筛选。2.3 get 与 getUnchecked 的选择LoadingCache 提供了两种获取值的方式get和getUnchecked。区别在于异常处理。// get 会抛出受检异常 try { UserInfo user cache.get(userId_123); } catch (ExecutionException e) { // load 内部抛出的异常会包在 ExecutionException 里 } // getUnchecked 不抛受检异常 UserInfo user cache.getUnchecked(userId_123);getUnchecked只能在 CacheLoader 的load方法不声明受检异常时使用。如果load里可能抛出 RuntimeException用它更省事。但是要注意它内部如果遇到异常会包装成UncheckedExecutionException同样需要处理只是不用写 catch。我个人在实际项目里更推荐get因为它会明确告诉你加载失败的可能逼着调用方考虑降级逻辑而不是让异常静默穿透。3. 异步刷新的实现原理与完整方案3.1 refreshAfterWrite 到底做了什么Guava Cache 在 14.0 之后加入了refreshAfterWrite。这个参数的语义是一个 key 写入缓存后经过指定时间它并不会被立刻淘汰而是被标记为“待刷新”。下次有请求访问这个 key 时Guava 会检查当前时间是否超过了刷新时间点。如果超过了它不会像expireAfterWrite那样直接丢弃旧值而是返回旧值的同时触发一次刷新动作。关键就在这里这个“刷新动作”是同步还是异步取决于你有没用 asyncReloading。我见过不少同学以为配了refreshAfterWrite就是异步刷新这是错的。默认的CacheLoader.load是同步执行也就是说第一个访问过期 key 的线程会去执行加载其他线程拿旧值继续走而执行加载的那个线程本身还是会阻塞在加载逻辑上。如果加载很慢那个线程同样会超时。只有asyncReloading配合线程池才能真正把加载逻辑扔到后台线程执行让访问线程永远不碰加载逻辑。3.2 用 asyncReloading 实现真正的异步刷新完整的异步刷新实现需要三个组成部分一个线程池、一个 CacheLoader、一个asyncReloading包装。先看代码private static final ExecutorService REFRESH_POOL new ThreadPoolExecutor( 4, 16, 30L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(cache-refresh-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); LoadingCacheString, UserInfo cache CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.MINUTES) .refreshAfterWrite(5, TimeUnit.MINUTES) .build(CacheLoader.asyncReloading( new CacheLoaderString, UserInfo() { Override public UserInfo load(String key) throws Exception { return userService.queryByUserId(key); } }, REFRESH_POOL ));这段配置表达的意思是key 写入后 5 分钟进入“可刷新”状态key 写入后 30 分钟会过期过期后不能再返回旧值必须重新加载刷新时通过REFRESH_POOL异步执行load方法刷新期间访问旧值的线程完全无感知。注意这里的两个时间参数是有配合关系的。refreshAfterWrite必须小于expireAfterWrite才有意义。如果两个值相等几乎不会产生刷新行为因为 key 一到刷新点就同时过期了直接走同步加载。3.3 refresh 与 expire 的触发差异这是 Guava Cache 面试也常问的一个点refreshAfterWrite和expireAfterWrite到底什么关系。expireAfterWrite是硬过期过期后必须重新加载加载期间没有旧值可用其他线程会阻塞在get上。refreshAfterWrite是软刷新到点后旧值仍然有效只是触发一次刷新而且刷新由异步线程完成。两者可以共存形成“软刷新 硬兜底”的组合。实际生产中的典型配置是refreshAfterWrite(5分钟)expireAfterWrite(30分钟)代表每 5 分钟异步刷新一次如果刷新连续失败 6 次导致数据超过 30 分钟没更新那么缓存强制过期下一次请求同步加载。这种组合的收益非常明显。热点数据最多有 5 分钟的陈旧周期但永远不会因为缓存过期造成请求阻塞。对于内容类、配置类数据5 分钟的陈旧窗口完全可接受。4. 代码落地与参数调优4.1 一个可以直接抄的完整实现下面我贴一个我在模拟项目 X 里用的完整实现场景是用户基础信息缓存。这个场景的特点是每个用户的资料读取频率高数据变更频率低可以接受分钟级延迟。Service public class UserCacheService { private static final int REFRESH_CORE_POOL_SIZE 4; private static final int REFRESH_MAX_POOL_SIZE 16; private static final int REFRESH_QUEUE_CAPACITY 200; private static final int REFRESH_KEEP_ALIVE_SECONDS 30; private final ExecutorService refreshPool; private final LoadingCacheString, UserInfo userCache; public UserCacheService(UserService userService) { this.refreshPool new ThreadPoolExecutor( REFRESH_CORE_POOL_SIZE, REFRESH_MAX_POOL_SIZE, REFRESH_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, new ArrayBlockingQueue(REFRESH_QUEUE_CAPACITY), new ThreadFactoryBuilder().setNameFormat(user-cache-refresh-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); this.userCache CacheBuilder.newBuilder() .maximumSize(50_000) .expireAfterWrite(30, TimeUnit.MINUTES) .refreshAfterWrite(5, TimeUnit.MINUTES) .recordStats() .build(CacheLoader.asyncReloading( new CacheLoaderString, UserInfo() { Override public UserInfo load(String key) { return userService.queryByUserId(key); } }, refreshPool )); } public UserInfo getUser(String userId) { try { return userCache.get(userId); } catch (ExecutionException e) { // 这里做降级处理比如返回一个空对象或者抛业务异常 throw new BusinessException(用户信息加载失败, e); } } public CacheStats stats() { return userCache.stats(); } }这段代码可以直接跑起来唯一需要替换的是UserService.queryByUserId的实际实现。为了模拟测试你可以先用一个Thread.sleep(200)的方式模拟慢查询然后用压测工具验证效果。4.2 线程池参数怎么定asyncReloading的线程池配置是很多人容易忽视的地方。我给几个配置参考线程池核心线程数建议等于应用部署机器的 CPU 核心数或者略大于核心数。比如 4 核机器配 4 到 8 个核心线程。因为刷新任务是 IO 密集型也就是查下游服务、查数据库线程可以稍微多一点。最大线程数不建议设得很大16 到 32 足够毕竟刷新任务是低频的只在 key 到刷新点时触发。队列容量控制在 100 到 500 之间防止瞬时大量 key 同时需要刷新时把线程池打满。拒绝策略建议用CallerRunsPolicy。它的含义是当线程池队列满了新任务不会被丢弃而是由提交任务的线程自己执行。这对缓存刷新来说反而是好事因为说明刷新任务积压了此时让某个请求线程同步去执行加载总比让缓存数据一直陈旧要好。4.3 容量与时间参数的调优思路maximumSize不是越大越好。本地缓存是 JVM 内存的一部分如果 key 特别多每条数据又比较大很容易把堆内存占满。建议按照单个 key 的平均大小和可用堆内存来估算。比如 4GB 堆内存分配 512MB 给这个缓存单个 value 平均 4KB那么容量上限可以设置 10 万左右。refreshAfterWrite的时间取决于业务对数据新鲜度的要求。数据可以接受 10 分钟延迟就用 10 分钟只能接受 1 分钟就用 1 分钟。但要注意刷新越频繁线程池的负担越大下游查询压力也越大。不要为了追求新鲜度把刷新频率拉得太高。expireAfterWrite通常设置为refreshAfterWrite的 4 到 6 倍。这样即使异步刷新连续失败也能有一段缓冲期不至于立刻击穿。我习惯用 5 分钟刷新、30 分钟过期这个组合实测下来稳定性最好。4.4 recordStats 与缓存监控上面代码里的recordStats()会被很多人忽略但在生产环境里它是排查问题的关键。开启后可以通过cache.stats()拿到命中率、加载次数、加载异常次数、总加载时间等指标。CacheStats stats userCache.stats(); double hitRate stats.hitRate(); long loadCount stats.loadCount(); long loadExceptionCount stats.loadExceptionCount(); long totalLoadTime stats.totalLoadTime();我把这些指标接入过监控系统每天都能看到命中率在 99% 以上加载异常次数为零。一旦某个下游服务抖动loadExceptionCount会立刻上涨这时候你就能及时发现而不是等用户投诉了才去看日志。这个习惯强烈建议保持。5. 常见问题与排查实录5.1 refreshAfterWrite 不是异步的这个问题前面提过但我还想再强调一次因为太容易踩了。配了refreshAfterWrite但没用asyncReloading效果只是“其他线程用旧值有一个线程同步执行刷新”。如果刷新逻辑只要 10ms这问题不大如果刷新逻辑要 500ms那个幸运线程的接口就会超时。排查方式很简单给load方法加一行日志打印当前线程名称。如果线程名是http-nio-8080-exec-*这类业务线程说明刷新是在业务线程里同步执行的你还没接异步如果线程名是user-cache-refresh-*那才是真正的异步刷新。5.2 load 异常会导致什么load方法抛异常时异步刷新会把异常记录在 CacheStats 里并且不会更新缓存。这意味着旧值会一直保留直到过期。业务侧不会有感知但你会发现数据一直不变此时去查 CacheStats 的loadExceptionCount大概率在涨。对于刷新异常我的建议是在load内部做兜底能降级就降级不要让异常抛出来。比如查数据库失败时可以返回上一次缓存中的一个快照或者查一个备用数据源。这样能最大程度保证缓存有值。另外get调用时如果缓存过期且同步加载失败异常会直接抛给调用方。所以在get的外层建议做一次 try-catch 降级。5.3 缓存击穿还是发生了怎么办即使配置了异步刷新极端情况下仍然可能出现缓存击穿。最常见的原因是expireAfterWrite时间到了key 确实过期了而且异步刷新恰好在某次异常后没有成功更新缓存这时候大量请求直接打在load上数据库压力骤增。这种情况下除了检查刷新失败原因还可以做一层“请求级别的互斥”用分布式锁或者在load内部加一个单键锁保证即使缓存过期同一时刻也只有少量线程能真正回源。Guava 自身对同一个 key 的并发加载是有互斥的它保证同一个 key 只有一个线程执行加载其他线程阻塞等待。但如果你用的是分布式缓存加本地缓存的多级结构就需要自己去控制全局层面的互斥。5.4 多级缓存下异步刷新的布局有些项目的缓存结构是本地 Caffeine 或 Guava 做一级Redis 做二级数据库做三级。这种情况下异步刷新通常是刷新一级缓存load方法里先查 RedisRedis 没有再查数据库。这种布局下Guava Cache 的刷新线程池只负责刷本地缓存即使 Redis 查询耗时 50ms也完全不影响业务线程因为业务线程拿到的永远是本地缓存里的旧值。这种多级缓存加异步刷新的组合是我在实际项目里最推荐的形态。6. 个人经验与扩展想法用 Guava Cache 做自动加载加异步刷新我已经在多个项目里落地过整体感受是配置代码量不大但收益非常明显。关键是要理解 refresh 和 expire 的区别并且一定记得用asyncReloading接线程池否则配置了和没配置区别不大。几个让我印象深刻的经验第一refreshAfterWrite一定要配合expireAfterWrite来用而且前者必须小于后者。我之前在一套配置里把两个时间设成了同样的值结果刷新机制形同虚设后来查 CacheStats 才发现加载次数异常。第二线程池的拒绝策略选CallerRunsPolicy比AbortPolicy好。缓存刷新这个场景宁可让一个请求线程同步执行加载也不要丢任务。丢了任务意味着缓存数据更新失败数据陈旧看起来问题不大但累积到一定时间过期后就是一次集中回源。第三recordStats()一定要开。没有监控就看不到缓存命中率和加载异常次数出了问题只能靠猜。开了之后配合告警能把很多隐患消灭在用户感知之前。这个方案后续还可以做的扩展方向有两个一个是在load内部接入一个轻量级的指标采集把这个缓存的加载延迟和下游成功率直接对接监控大盘另一个是基于CacheBuilder.removalListener记录淘汰原因和 key 分布方便做容量规划。如果你也在用本地缓存不妨从这套自动加载加异步刷新的方案入手改造一下我能确定的是改造完之后你再看原来的手动缓存代码不会再想回去了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑