资讯动态

Guava Cache异步刷新实战:解决缓存击穿与高并发延迟尖刺

发布时间:2026/10/13 17:30:01 来源:尧图企业网站定制
在项目里做本地缓存最烦的一类问题不是“缓存没生效”而是“缓存生效了但到期回源的那一刻所有请求一起打到数据库直接把下游打挂了”。尤其是配置类、基础资料类这种低频写、高频读的数据用 Guava Cache 做自动加载本来很方便但默认的同步加载在缓存失效瞬间会变成一次分布式环境下典型的缓存击穿。后来我把加载逻辑改成异步刷新踩了几个坑之后这套方案才算真正落地。这篇就完整聊聊这个实现从设计思路到最终代码再到压测排障全部摊开讲。1. 缓存方案选型为什么最终选 Guava Cache 做异步刷新1.1 本地缓存的场景需求与痛点先说业务背景。某跨平台系统的模拟项目X里面有大量字典类配置数据比如用户的基础信息、产品配置、灰度开关等。这类数据的特点非常明显读的频率极高动不动就每秒几千次 QPS但写的基本上只有运营后台人工变更一天可能就改几次。如果每次读取都直接查数据库数据库根本扛不住而且大多数请求读到的都是同样的数据完全没有必要重复查库。引入缓存之后新的问题又出现了缓存过期了怎么办。以前用 Redis 做集中式缓存过期之后要么主动刷新要么等下一次请求进去发现 miss 再回源。回源这个动作如果没加锁高并发场景下就是经典问题——同一时刻成百上千个线程一起发现缓存 miss然后一起往数据库发查询请求。数据库连接池很快就满了接口延迟暴涨严重的时候整条链路雪崩。用 Guava Cache 就是想用它的自动加载能力把回源逻辑收敛起来CacheLoader 保证同一时间只有一个线程去加载数据其他线程阻塞等待结果。但 Guava 默认的行为是缓存过期之后第一个请求线程触发加载其他线程等待这就是同步阻塞式的回源。看起来解决了击穿问题但它引入了新的延迟问题。1.2 从同步加载转向异步刷新的核心理由同步加载的本质问题是加载耗时直接叠加到每个触发加载的请求上。比如缓存有效期设为 30 分钟每次到 30 分钟边界第一个请求进来触发加载加载数据库可能需要 80ms 到 200ms。这个请求就会明显比别人慢出现偶发的延迟尖刺。如果加载过程中数据库慢查询可能直接拖到秒级线上表现就是“时不时的超时报警”。异步刷新的思路完全不同。它不等到缓存过期才加载而是提前刷新。Guava 的 refreshAfterWrite 就是专门干这个的写入之后经过指定时间下次请求会发现该缓存条目需要刷新于是触发一个异步加载任务不等加载完成就返回旧值新的加载完成后自动替换旧值。整个刷新过程对调用方几乎无感用户永远拿到的是“上一次加载好的数据”加载动作在后台完成。这正是我选异步刷新的核心原因把回源延迟从请求链路上摘掉用后台线程池去承担刷新开销。请求线程始终只做内存读取延迟曲线稳定。2. 代码骨架搭建自动加载与异步刷新的完整实现2.1 项目依赖与基础环境准备项目本身用的就是 Spring Boot 体系但 Guava Cache 并不依赖 Spring单独引入 Guava 依赖即可。我用的 Guava 版本是 32.1.3-jre这个版本对 CacheBuilder 的支持比较成熟接口稳定。dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependencySpring Boot 版本用的 2.7.xJDK 1.8。理论上这套代码不依赖 Spring 也能跑只是结合 Spring 的 Bean 管理会让线程池和缓存实例更干净后面可以直接注入使用。2.2 CacheBuilder 配置与 CacheLoader 加载器实现先看完整的实现代码我会逐步拆解每个配置项的含义和取舍。package com.example.cache; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class AsyncRefreshCacheK, V { private static final Logger log LoggerFactory.getLogger(AsyncRefreshCache.class); private final ListeningExecutorService refreshExecutor; private final LoadingCacheK, V cache; public AsyncRefreshCache(final LoaderK, V loader, int refreshThreadSize, long refreshDuration, TimeUnit timeUnit, long expireDuration, TimeUnit expireUnit) { // 异步刷新专用线程池必须是独立线程池避免和业务线程池互相干扰 this.refreshExecutor MoreExecutors.listeningDecorator( Executors.newFixedThreadPool(refreshThreadSize, new NamedThreadFactory(guava-async-refresh))); this.cache CacheBuilder.newBuilder() // 写入后多久触发刷新配合异步加载使用刷新期间返回旧值 .refreshAfterWrite(refreshDuration, timeUnit) // 写入后多久强制过期兜底策略防止刷新长期不成功时数据无限期陈旧 .expireAfterWrite(expireDuration, expireUnit) // 控制最大条数防止缓存无限膨胀 .maximumSize(10_000) // 记录命中率方便监控 .recordStats() .build(new CacheLoaderK, V() { Override public V load(K key) throws Exception { // 缓存完全miss时的加载逻辑 V value loader.load(key); log.info([GuavaAsyncCache] load key{}, valueSize{}, key, value null ? 0 : value.toString().length()); return value; } Override public ListenableFutureV reload(K key, V oldValue) throws Exception { // 刷新时走异步逻辑当前线程拿到的是旧值不会阻塞 ListenableFutureV future refreshExecutor.submit(() - { try { V newValue loader.load(key); log.info([GuavaAsyncCache] reload key{}, size{}, key, newValue null ? 0 : newValue.toString().length()); return newValue; } catch (Exception e) { log.error([GuavaAsyncCache] reload error key{}, key, e); throw e; } }); return future; } }); } public V get(K key) throws Exception { return cache.get(key); } public V getQuietly(K key) { try { return get(key); } catch (Exception e) { log.error([GuavaAsyncCache] get error key{}, key, e); return null; } } public void put(K key, V value) { cache.put(key, value); } public void invalidate(K key) { cache.invalidate(key); } public void invalidateAll() { cache.invalidateAll(); } public long size() { return cache.size(); } public com.google.common.cache.CacheStats stats() { return cache.stats(); } FunctionalInterface public interface LoaderK, V { V load(K key) throws Exception; } }再补一个线程工厂的工具类主要是给线程起名字排查问题时能直接定位到是哪个线程池在执行刷新任务。package com.example.cache; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class NamedThreadFactory implements ThreadFactory { private final String namePrefix; private final AtomicInteger counter new AtomicInteger(1); public NamedThreadFactory(String namePrefix) { this.namePrefix namePrefix; } Override public Thread newThread(Runnable r) { Thread thread new Thread(r, namePrefix - counter.getAndIncrement()); thread.setDaemon(true); return thread; } }这里有几个关键点必须说明白。第一reload 方法返回的是 ListenableFuture而不是直接返回 V。Guava 看到 reload 返回 Future就会把 refresh 的语义变成“触发异步刷新立即返回旧值”。如果你在 reload 里写同步逻辑那 refreshAfterWrite 就退化成同步加载了完全失去异步跑的意义。第二线程池必须独立。很多人图省事直接用 common pool 或者业务线程池风险很大。刷新任务在后台跑如果和业务线程池共用线程刷新任务多了会占业务线程名额反过来业务高峰期又会饿死刷新任务。独立线程池配合 daemon 线程既不会拖垮业务也不会阻止 JVM 退出。第三refreshAfterWrite 和 expireAfterWrite 同时设置是故意做的双保险。refresh 负责“温数据提前刷新”expire 负责“刷新失败的兜底”。如果只有 refreshAfterWrite刷新任务一直失败缓存里的旧值就永远不更新数据陈旧度会失去控制。加上 expireAfterWrite最坏情况下数据在 expire 时间后失效下次读取触发 load 同步加载保证新鲜度兜底。2.3 结合 Spring Boot 的缓存实例管理方式在 Spring Boot 中把这个缓存的创建封装成一个工厂类用 Bean 方式管理方便不同业务场景创建多个实例。package com.example.config; import com.example.cache.AsyncRefreshCache; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.concurrent.TimeUnit; Configuration public class CacheConfig { /** * 用户基础信息缓存刷新周期5分钟过期兜底30分钟 */ Bean public AsyncRefreshCacheString, UserInfo userInfoCache() { return new AsyncRefreshCache(userId - userInfoService.getById(userId), 4, 5, TimeUnit.MINUTES, 30, TimeUnit.MINUTES); } /** * 产品配置缓存刷新周期1分钟过期兜底10分钟 */ Bean public AsyncRefreshCacheString, ProductConfig productConfigCache() { return new AsyncRefreshCache(configId - configService.getConfig(configId), 2, 1, TimeUnit.MINUTES, 10, TimeUnit.MINUTES); } }这样每个业务域都有自己的缓存实例和独立的刷新线程池互不影响。某个缓存的刷新线程池满了也不会影响另一个缓存实例的回源加载。3. 核心机制拆解refreshAfterWrite 与 expireAfterWrite 的组合逻辑3.1 refreshAfterWrite 的触发时机与行为边界很多人第一次接触 refreshAfterWrite以为它是“时间到了自动后台刷”其实不是。Guava 的 refresh 不是在后台定时触发的它仍然是“惰性触发”——每次 get 的时候判断当前时间是否已经超过上次写入时间 refreshDuration如果超了就调用 reload 发起异步刷新然后立刻把旧值返回给调用线程。举个例子缓存是早上 10:00 写入的refreshAfterWrite 设为 5 分钟。10:01 来一个请求发现还差 4 分钟没到刷新窗口直接返回旧值。10:06 来一个请求发现已经超过 5 分钟于是触发 reload但这次立刻返回的是旧值不等待新值加载完成。到了 10:07 再请求如果异步刷新完成了拿到的就是 10:06 刷出来的新值。这个机制最优雅的地方在于刷新动作只在有请求访问该 key 时才发生。如果某个 key 一直没人读那就一直不刷新等到下次有人读时再触发。闲时数据不会做无意义的刷新省资源。但要注意refreshAfterWrite 对“完全 miss”的 key 无效。也就是说如果缓存里根本没有这个 keyget 进去会走 load这是同步的。比如一个冷门 key 第一次被请求那就是实实在在的同步加载该慢还是慢。这个问题后面讲冷数据穿透时专门处理。3.2 expireAfterWrite 兜底策略的必要性与取舍那为什么还要额外加一个 expireAfterWrite因为 refresh 依赖后台线程池执行线程池排队、数据库超时、线程池饱和这些都是真实的故障场景。假如刷新任务连续失败旧值会一直存在缓存里refreshAfterWrite 下这个旧值永远不会被移除用户看到的一直是几小时甚至几天前的数据这对于某些业务是不能接受的。有了 expireAfterWrite 兜底最坏情况就有边界了。假设 expire 设为 30 分钟即使刷新一直失败缓存也会在 30 分钟后强制淘汰。下一次 get 发现缓存 miss走同步 load重新拉数据。虽然这次会有一个同步加载的延迟但至少数据不会无限期陈旧下去。这里有个取舍问题expireAfterWrite 设得越长刷新失败的容忍度越高但数据陈旧风险越大设得越短同步回源的概率越高异步刷新的优势被削弱。我实际项目里一般取 refreshDuration 的 4 到 6 倍。刷新周期 5 分钟expire 就取 30 分钟刷新周期 1 分钟expire 取 10 分钟。这个比例既给了异步刷新充足的重试窗口又能兜住新鲜度底线。3.3 异步刷新与同步加载的协同场景实际运行中同一个缓存实例里会同时存在两种加载模式缓存 miss 时走 load同步阻塞加载。缓存命中但已到刷新窗口时走 reload异步刷新返回旧值。expire 兜底淘汰后走 load同步加载。这三种路径的协同方式一定要在设计阶段就想清楚。我的处理原则是把 load 作为最后防线把 reload 作为主要更新通道。日常运行时绝大多数更新都是 reload 异步完成的load 只负责第一次加载和异常兜底。这样接口的延迟曲线才能稳定。4. 关键设计细节与实测调优记录4.1 刷新线程池参数选择与隔离策略线程池大小不建议拍脑袋。刷新任务通常不是 CPU 密集而是 IO 密集查库、调用外部接口所以线程数可以大于 CPU 核数。我常用的配置是用户信息缓存刷新线程池 4 个线程产品配置缓存 2 个线程。为什么用户信息要给 4 个因为用户信息类 key 数量大、刷新频率高而且加载逻辑里可能涉及到数据库和缓存双查耗时更长线程多一点不容易排队。线程池的等待队列用默认的 LinkedBlockingQueue 就行但必须给线程工厂起有意义的名字。我之前遇到过线上问题线程 dump 后看不出是谁创建的任务排查效率极低。命名之后一眼就能定位guava-async-refresh-1 就是刷新线程池里的第一个线程。一个容易忽略的点Guava 的 reload 提交的是 Callable返回 ListenableFuture如果刷新线程池的队列积压了很多任务刷新延迟会增加这时候应该看线程池的活跃线程数和队列深度。我在监控里专门挂了这三个指标线程池任务积压数、刷新任务耗时、缓存命中率。4.2 缓存击穿防线load 路径的并发控制异步刷新解决了“过期瞬间的击穿”但同步 load 路径仍然存在并发问题。比如缓存整体清空后大量 key 同时 miss每个 key 都会有线程去 load。因为 Guava 的 load 对同一个 key 只有一个线程执行其他线程等待但如果是几十个不同 key 同时 miss每个 key 都会发起一个加载任务对数据库的冲击仍然是客观存在的。这种情况下我的做法是在 load 回源时加一个简单限流。比如用一个 Semaphore 限制并发回源数量超过阈值的请求直接降级用旧值或默认值返回同时记录告警。private final Semaphore loadLimiter new Semaphore(8); // 在 load 方法中包裹限流逻辑 public V load(K key) { if (!loadLimiter.tryAcquire()) { log.warn([GuavaAsyncCache] load limited key{}, key); return fallbackValue(key); } try { return doLoad(key); } finally { loadLimiter.release(); } }这里 tryAcquire 不阻塞是故意设计的。宁可少数请求拿到降级值也比全部线程堵在加载阶段强。降级值可以是空对象引用、默认配置或旧值具体看业务容忍度。灰度开关这类数据容忍用默认值顶几分钟用户信息就不能随便给默认值需要改成阻塞获取并配合超时。4.3 高并发实测同步加载与异步刷新的延迟对比为了验证异步刷新的实际效果我做了一组对比压测。模拟 500 并发连续查询同一个 key加载耗时基准为 100ms缓存有效期 30 秒分别用 expireAfterWrite 同步加载和 refreshAfterWrite 异步刷新两组方案跑。同步加载组的 P99 延迟在缓存刷新的瞬间明显恶化因为 500 个线程中第一个触发加载其余 499 个线程全部阻塞等待加载耗时 100ms 直接叠加到每个请求上。异步刷新组的表现完全不同触发刷新窗口后请求线程立即拿到旧值P99 只有本地内存读的耗时约 0.5ms 到 1ms后台线程用 100ms 慢慢加载新值加载完成后静默替换。数字最能说明问题同步组的 P99 在刷新窗口附近会从 1ms 突然飙升到 120ms 以上异步刷新的 P99 整条曲线都是平的几乎看不到波动。压测数据直接支撑了异步刷新方案在“读多写少、加载有耗时”场景下的绝对优势。指标expireAfterWrite 同步加载refreshAfterWrite 异步刷新刷新瞬间 P99 延迟120ms-150ms有明显尖刺1ms 以内无尖刺数据库回源并发数1个key回源但阻塞大量请求仅1个后台线程回源调用线程等待时间等待加载完成最长到秒级不等拿旧值返回数据新鲜度过期瞬间最新可能有几秒的延迟窗口实现复杂度最低中等需管理线程池4.4 缓存预加载与手动刷新机制异步刷新还有一个天然的问题首次启动时缓存是空的所有 key 都得走同步 load。如果启动后马上有大量流量进来和“预警后的缓存冷启动”类似第一次访问会很慢。为了避免这种冷启动冲击我会在应用启动完成后主动把核心 key 预先加载进缓存。实现方式很简单写一个 ApplicationRunner 或者监听 ApplicationReadyEvent启动后调用 cache.get(key) 预热。预热完成前建议在 load 路径上配合限流防止预热任务和业务请求打架。手动刷新也很常用。运营后台变更了配置通过 MQ 通知到应用此时需要把旧缓存立刻失效或直接刷新。Guava 的 invalidate(key) 是删除缓存下次 get 时重新 load简单可靠。但直接 invalidate 也会带来一个小窗口的空窗期如果对数据实时性要求特别高可以改成手动触发 refreshGuava 提供了 cache.refresh(key) 方法但这要求你必须知道 key 的旧值存在如果旧值本来就不在缓存里refresh 等同 load。我的做法是业务主动变更时直接 invalidateAll配合 refreshAfterWrite 的兜底窗口让数据在下一个刷新周期自然恢复同时主动发一次 get 触发刷新保证变更尽快生效。这样既不会脏读也不会阻塞请求。5. 踩过的坑与排查技巧实录5.1 刷新任务异常吞没与降级处理第一个坑reload 里如果忽略异常新值加载失败缓存旧值会被保留而且刷新窗口过了之后会反复触发刷新。结果就是数据一直旧的日志里全是刷新失败但调用方毫无感知。这个坑很隐蔽因为对调用方来说get 一直有返回值接口不报错线上一切正常。直到业务同事来问“为什么配置改了半小时还没生效”排查链路才走到缓存层。我的处理方案分两层。第一层reload 里必须把异常抛出去ListenableFuture 会捕获这个异常并把状态标记为失败Guava 内部知道刷新失败后不会把失败的 future 结果写回缓存下次 get 还会尝试重新刷新。第二层独立监控刷新的失败率如果连续 N 次刷新失败发送告警到运维群同时主动触发一次同步 load 兜底避免数据长期陈旧。5.2 刷新线程池饱和导致的新值延迟第二个坑线程池太小 刷新任务积压导致新值延迟替换。场景是这样的突然有大量 key 同时进入刷新窗口比如刷新周期 5 分钟刚好过了 5 分钟一个批次的 key 集体需要刷新线程池只有 2 个线程队列瞬间积压几百个任务。有些 key 的新值直到几十秒甚至几分钟后才替换上去业务看起来就是数据不是最新。排查方法很简单在监控面板上看刷新线程池的队列深度和任务平均等待时间如果积压明显就是线程池不够。我最终调整策略刷新线程池大小不再设置成固定值而是根据核心 key 数量动态计算同时在队列积压超过阈值时把刷新模式临时降级为同步牺牲一点点延迟换数据新鲜度。如果仍然担心峰值不要无限加大线程池那会打爆数据库。更优雅的方式是给刷新任务分优先级核心业务 key 走单独的高优线程池普通 key 走低优线程池运营变更后的手动刷新永远走高优通道。5.3 Null 值的缓存与加载异常语义第三个坑来自 Guava Cache 本身CacheLoader 不允许返回 null。如果数据里某个 key 查不到load 返回 null会抛异常Guava 会认为加载失败不会缓存这个空结果。很多新同学在这里会犯一个错误直接把 load 方法改为 catch 异常后返回 null。结果是每次请求都重复回源虽然不会缓存 null但数据库压力一点没减。更麻烦的是Guava 对 load 异常的处理是“不缓存失败结果”所以每次都会重试对于确实不存在的 key就形成了反复查库的死循环。我的方案是引入一个 Optional 包装或者用一个空对象占位。数据库查不到数据时load 返回一个空对象实例缓存里存的是“空对象”业务层取出来判断 isEmpty 即可。同时空对象的刷新周期可以拉长比如 10 分钟减少对数据库的空查询次数。这样既保留了 Guava 的加载语义又避免了空值打穿缓存。5.4 key 数量膨胀与容量规划最后一个坑跟容量有关。Guava Cache 默认是强引用存储如果 maximumSize 不设置缓存条数会无限增长直到内存溢出。设置了 maximumSize 后超过容量的条目会按 LRU 淘汰。但这里有个细节maximumSize 针对的是缓存条数不是内存大小所以如果 value 很大比如缓存进去一个 10MB 的对象1 万条就是接近 100GB肯定出事。我的规划方式很朴素先统计业务 key 总数乘以单条 value 的平均大小预留 30% 的 buffer得出期望内存占用再反推 maximumSize。同时缓存类型上尽量存轻量对象别把一个大 JSON 整包存进去真要存 JSON 就压缩字段。key 的规模预估过之后一定要设置 maximumSize 和 expireAfterWrite 的组合。没有兜底的缓存是不完整的总有一天会以最不优雅的方式给你上一课。6. 踩过一次之后这套方案的扩展思考我后来回顾整个实现过程觉得最值得沉淀的不是那几行 CacheBuilder 配置而是对“缓存生命周期”的理解。Guava Cache 提供的是本地进程内的缓存能力和分布式缓存有本质区别。异步刷新的使用场景一定要满足“允许短时间的数据不一致”这个前提。如果业务要求强一致用户在刷新动作完成后立刻读到新值那 local cache 方案本身就不合适应该用 Redis 双删或者分布式缓存加消息广播失效而不是在本地缓存上死磕。如果未来数据量被水平扩展到多台机器每台机器都有自己的本地缓存不同机器之间的刷新时机不同可能会出现 A 机器已经刷新、B 机器还是旧值的窗口。这个问题在单机场景不存在多机部署时必须引入“版本号 配置中心 手动失效广播”的组合方案。根据我个人的实际经验最稳妥的控制方式是本地缓存只存高频访问且允许短暂不一致的数据任何需要强一致的变更一律先发广播让所有节点失效对应 key再配合刷新窗口兜底。另外Guava Cache 的 recordStats 很好用我习惯在监控里展示三个指标命中率、加载耗时、刷新失败次数。命中率低说明缓存策略不合理加载耗时高说明后端加载链路是瓶颈刷新失败次数高说明后台线程池或者数据源要关注。这些指标在日常发布和告警中很有价值值得一开始就设计进去。最后分享一个小技巧是实际过程中摸索出来的如果你担心异步刷新发生数据回退也就是新值加载失败后直接丢掉了旧值可以在业务层加载器内部做一次对比版本号每次加载数据时把数据源返回的版本号一并存进缓存对象。刷新成功后在包装值外比对数据源版本号如果发现新版本的版本号倒退就丢弃新值保留旧值形成一个最低限度的数据保护层。虽然这会让缓存对象多一个字段但在数据源侧和缓存侧版本管理非常混乱的系统中这个成本花得值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑