不能直接用ScheduledExecutorService做重试,因其会丢失CompletableFuture上下文、破坏线程池绑定与MDC日志、无法感知任务取消状态;正确方式是在链内用exceptionally+thenCompose实现延迟重试闭环,并用delayedExecutor避免阻塞线程。

为什么不能直接用 scheduledExecutorService 做重试
很多人第一反应是:用 ScheduledExecutorService 延迟提交一个 CompletableFuture 就完事了。但这样会丢失异步链的上下文 —— 重试逻辑无法自然接入原任务的异常传播、线程池绑定、结果类型一致性等关键约束。
真正要做的,是在 CompletableFuture 链内部完成“失败 → 延迟 → 重试 → 再失败 → 指数退避”这个闭环,而不是靠外部调度器硬塞任务。
- 外部调度器无法感知上游
CompletableFuture的完成状态(比如是否已被 cancel) - 重试任务若用新线程池执行,可能破坏 Spring 的
@Async上下文或 MDC 日志传递 - 指数退避参数(如 base=100ms, factor=2)必须和重试次数联动,手动管理易出错
exceptionally + thenCompose 是重试链的核心骨架
重试不是“捕获异常后 sleep 再执行”,而是“捕获异常后返回一个新的 CompletableFuture,它自己负责重试逻辑”。这必须用 thenCompose,不能用 thenApply —— 后者会把 CompletableFuture 当作普通对象包裹进结果,导致类型嵌套(CompletableFuture<completablefuture>></completablefuture>)。
典型结构:
supplyAsync(task)
.exceptionally(ex -> {
if (shouldRetry(ex) && retryCount < MAX_RETRY) {
return delayAndRetry(task, ++retryCount); // 返回 CompletableFuture<T>
} else {
throw new CompletionException(ex);
}
})
.thenCompose(Function.identity()); // 展平嵌套
-
delayAndRetry必须返回CompletableFuture<t></t>,否则thenCompose无法识别为可扁平化的异步阶段 -
shouldRetry要过滤掉非重试类异常(如IllegalArgumentException),避免无限循环 - 重试次数必须作为闭包变量或封装进状态对象传递,不能靠
AtomicInteger全局共享(并发调用会错乱)
延迟执行必须用 CompletableFuture.delayedExecutor,别手写 Thread.sleep
在 exceptionally 回调里写 Thread.sleep(200) 是反模式:它会阻塞当前线程(通常是 ForkJoinPool 的 worker 线程),严重拖慢整个线程池吞吐量。
正确做法是用 JDK 自带的 CompletableFuture.delayedExecutor 构造一个延迟执行器,再传给新的 supplyAsync:
private static CompletableFuture<String> delayAndRetry(
Supplier<String> task, int attempt) {
long delayMs = (long) Math.pow(2, attempt - 1) * 100; // 100, 200, 400...
return CompletableFuture.supplyAsync(task,
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS));
}
-
delayedExecutor底层用的是ScheduledThreadPoolExecutor,不占用业务线程 - 延迟时间单位必须是
TimeUnit.MILLISECONDS,传SECONDS容易误算成毫秒级延迟 - 如果需要统一管理重试线程池(比如限流),应提前创建好
ScheduledExecutorService实例,传给delayedExecutor(Runnable, long, TimeUnit, ScheduledExecutorService)
分布式场景下必须处理节点失效与幂等性
在多实例部署中,“重试”可能被不同节点同时触发,尤其当原始任务已成功但响应丢失(网络分区)时。此时单纯靠客户端重试会引发重复操作。
关键控制点:
- 所有重试任务必须携带唯一
traceId或taskId,服务端做幂等校验(例如 Redis SETNX + TTL) - 指数退避的 base delay 建议设为 100–500ms,避免大量实例在相同时间窗口密集重试,冲击下游
- 重试上限建议设为 3–5 次,再高大概率是服务不可用,应快速熔断而非死磕
- 若任务本身有外部依赖(如 HTTP 调用),需确保重试时 headers(如
X-Retry-Count)透传,便于链路追踪
最常被忽略的是:重试逻辑本身不能有状态残留。比如用静态变量存 retryCount,会在多线程下污染其他请求;或者用 ConcurrentHashMap 缓存 task → count 映射却忘了清理,最终 OOM。

















