如何创建使用缓存值的CompletableFuture?基于Guava缓存经验的异步需求
嘿,这个问题我太有发言权了!之前用Guava处理异步缓存的时候踩过一模一样的坑——直接把CompletableFuture塞进Suppliers.memoize()里,结果并发请求一来,同一个key触发了N次异步任务,完全没起到缓存的作用。接下来我给你拆解问题,再给两个靠谱的解决方案:
先搞清楚你担心的核心问题
当你用Supplier<CompletableFuture<Integer>> cache = Suppliers.memoize(...)时,问题出在:如果多个线程同时调用cache.get(),而此时缓存还没初始化,Supplier的lambda会被多次执行,每次都创建一个新的CompletableFuture,相当于同时启动了多个异步计算任务,这显然不是我们要的缓存效果。而且如果其中一个Future失败了,后续所有调用都会拿到这个失败的Future,除非缓存过期。
方案一:用Guava LoadingCache(推荐)
LoadingCache天生就是为这种并发缓存场景设计的——同一个key下,只会有一个线程执行加载逻辑,其他线程会等待这个加载完成,完美解决重复异步任务的问题。而且它支持丰富的缓存配置(过期时间、最大容量、移除监听等),处理异常也更灵活。
代码示例:
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; public class AsyncCacheExample { private final LoadingCache<String, CompletableFuture<Integer>> asyncCache; public AsyncCacheExample() { asyncCache = CacheBuilder.newBuilder() // 缓存5分钟后过期 .expireAfterWrite(5, TimeUnit.MINUTES) // 可选:设置缓存最大容量,避免内存溢出 .maximumSize(1000) // 可选:移除缓存时取消未完成的异步任务 .removalListener(notification -> { CompletableFuture<Integer> future = notification.getValue(); if (!future.isDone()) { future.cancel(true); } }) .build(new CacheLoader<String, CompletableFuture<Integer>>() { @Override public CompletableFuture<Integer> load(String key) throws Exception { // 这里写你的异步计算逻辑,比如调用远程接口、数据库查询 return CompletableFuture.supplyAsync(() -> { // 模拟耗时操作 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return -1; } return Integer.parseInt(key); }); } }); } // 对外提供的获取方法 public CompletableFuture<Integer> getValue(String key) { try { return asyncCache.get(key); } catch (Exception e) { // 处理缓存加载异常,比如返回失败的Future return CompletableFuture.failedFuture(e); } } }
这个方案的优势:
- 并发安全:同一key只会触发一次异步加载,其他请求等待结果
- 异常处理灵活:可以在
load方法里给Future添加异常回调,或者在getValue里统一处理加载异常 - 缓存策略丰富:支持过期、容量限制、异步刷新(
refreshAfterWrite)等 - 资源清理:通过
removalListener可以取消未完成的异步任务,避免资源浪费
方案二:用Suppliers.memoizeWithExpiration(适合简单场景)
如果你坚持要用Suppliers,得确保Supplier只创建一次CompletableFuture——也就是memoize的是Future本身,而不是每次调用都新建。但要注意,这个方案的局限性在于:如果Future失败了,缓存会一直保留失败的Future,直到过期;而且没有内置的并发控制(不过memoize本身是线程安全的,所以同一时间只会有一个线程执行Supplier的逻辑)。
代码示例:
import com.google.common.base.Suppliers; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; public class AsyncSupplierExample { // 缓存5分钟,线程安全 private final Supplier<CompletableFuture<Integer>> asyncSupplier = Suppliers.memoizeWithExpiration(() -> { // 这个逻辑只会被执行一次(直到缓存过期) return CompletableFuture.supplyAsync(() -> { // 耗时计算 return 42; }).exceptionally(ex -> { // 可选:处理异步任务的异常 System.err.println("任务失败:" + ex.getMessage()); return -1; }); }, 5, TimeUnit.MINUTES); public CompletableFuture<Integer> getValue() { return asyncSupplier.get(); } }
注意事项:
- 这个方案适合单key的场景(比如全局配置),如果是多key的缓存,还是
LoadingCache更合适 - 若异步任务失败,后续调用会一直拿到失败的结果,直到缓存过期。如果需要失败后重试,得自己加逻辑(比如在Future里重试,或者手动清空缓存)
最后总结
如果你需要多key、灵活的缓存策略和完善的并发控制,优先选LoadingCache;如果是简单的单key异步缓存,用Suppliers.memoizeWithExpiration也可以,但要注意异常处理的坑。
内容的提问来源于stack exchange,提问作者Gili

