applyToEither本质是竞速而非容错,只取最先完成的结果,不处理双失败场景;必须为每个子任务单独配置orTimeout和exceptionally,并做线程池隔离与最终fallback校验。

applyToEither 本质是竞速,不是容错
applyToEither 只关心「谁先完成」,不关心「谁失败」。如果两个 CompletableFuture 都因超时或异常提前结束,它不会自动 fallback,而是直接抛出 CompletionException(包装了原始异常),调用 join() 或 get() 时就会炸。
常见错误现象:本地测试时一个快一个慢,看着正常;上线后网络抖动,两个任务都超时,接口直接 500,监控里报 java.util.concurrent.TimeoutException 或 java.util.concurrent.CompletableFuture$AltResult —— 这其实是异常被吞掉后的占位对象。
- 必须为每个参与
applyToEither的子任务单独加超时控制,不能只在最外层get(3, TimeUnit.SECONDS) - 不能依赖
applyToEither自身做兜底,它没有orTimeout或exceptionally的内置组合能力 - 若任一任务抛出未捕获异常(如 NPE、远程调用
IOException),applyToEither会立即传播该异常,不等另一个任务
给每个 supplyAsync 显式套上 orTimeout + exceptionally
正确做法是把超时和异常处理「下沉」到每个原子任务内部,确保无论哪个先返回,结果都是可用的(哪怕是兜底值)。
比如同时查 Redis 和 DB:
ExecutorService redisPool = Executors.newFixedThreadPool(4);
ExecutorService dbPool = Executors.newFixedThreadPool(2);
CompletableFuture<product> redisFuture = CompletableFuture
.supplyAsync(() -> redisService.get(productId), redisPool)
.orTimeout(100, TimeUnit.MILLISECONDS)
.exceptionally(t -> {
log.warn("Redis query timeout or failed", t);
return null; // 或返回缓存空对象
});
CompletableFuture<product> dbFuture = CompletableFuture
.supplyAsync(() -> dbService.selectById(productId), dbPool)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(t -> {
log.error("DB query failed", t);
return null;
});
// 此时两个 future 都已具备超时/异常防护
return redisFuture.applyToEither(dbFuture, result -> {
if (result == null) {
throw new RuntimeException("Both sources returned null");
}
return result;
});
</product></product>
-
orTimeout必须在supplyAsync之后链式调用,否则超时逻辑不生效 -
exceptionally返回的兜底值类型必须和原始 Supplier 一致,否则编译不过 - 两个任务的超时时间应有梯度(如 Redis 100ms、DB 800ms),避免 DB 刚启动就因超时返回 null,掩盖真实问题
竞速结果为空时,必须手动 fallback
applyToEither 不提供「两者都失败时的默认值」机制。当两个任务都返回 null 或都抛异常,你拿到的是一个已完成但结果为 null 的 future,或者直接抛异常。
安全写法是用 handle 或 whenComplete 做最终校验:
CompletableFuture<product> raceResult = redisFuture.applyToEither(dbFuture, Function.identity());
return raceResult.handle((product, throwable) -> {
if (product != null) return product;
if (throwable != null) {
log.warn("Race failed", throwable);
}
// 真正的兜底:降级静态商品页、返回 HTTP 404 对应的 Product.EMPTY
return Product.defaultFallback();
});
</product>
- 别用
exceptionally替代handle,因为applyToEither成功完成但结果为null时,exceptionally不触发 - fallback 逻辑必须幂等且极轻量,不能引发新 IO 或锁竞争,否则拖垮整个熔断机制
- 日志里要明确记录是「双源均不可用」,而不是笼统打 “race failed”,方便后续定位是网络问题还是服务崩溃
线程池隔离比超时更重要
很多团队加了超时却仍出现线程池耗尽,根本原因是没做线程池隔离。Redis 和 DB 查询的延迟特征完全不同:Redis 是微秒级响应但并发高,DB 是百毫秒级但连接数受限。共用一个线程池会导致快任务被慢任务饿死。
- Redis 用固定大小小线程池(如 4~8 线程),配合短超时(50~200ms)
- DB 必须用独立线程池(建议 2~4 线程),大小不超过数据库连接池 size,超时设为 DB SLA 的 1.5 倍
- 绝对不要用
ForkJoinPool.commonPool(),它是为 CPU 密集型设计的,IO 任务会把它拖垮 - 线程池拒绝策略推荐
CallerRunsPolicy,让调用方自己执行,避免请求堆积
真正难的不是写对 applyToEither,而是让两个异步任务在资源竞争、网络抖动、服务降级之间保持稳定输出——这取决于线程池配置、超时梯度和 fallback 的边界是否清晰。漏掉任意一环,竞速就变成雪崩的起点。










