Spring RestTemplate结合CompletableFuture时HTTP调用阻塞问题排查
问题背景
开发了一个REST端点,通过Spring RestTemplate调用三个独立的REST服务(使用同一主键查询数据)。为提升效率,采用CompletableFuture结合自定义线程池并行发起API调用,最后通过get()等待所有响应返回。但日志显示:RestTemplate的exchange方法执行前,三个任务处于并行状态;exchange执行后,线程变为串行执行或出现等待其他线程完成的情况。
核心代码
CompletableFuture<TypeA> callA = CompletableFuture .supplyAsync(() -> getCallA(id), traceableExecutorService); CompletableFuture<TypeB> callB = CompletableFuture .supplyAsync(() -> getCallB(id), traceableExecutorService); CompletableFuture<TypeC> callC = CompletableFuture .supplyAsync(() -> getCallC(id), traceableExecutorService); try { TypeA = callA.get(); TypeB = callB.get(); TypeC = callC.get(); } catch (InterruptedException | ExecutionException e) { throw e; } // 组装响应并返回...
RestTemplate配置
@Bean public RestTemplate restTemplate() { HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); // 重试配置:最多重试3次,重试所有方法 httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(3, true)); // 请求超时配置 httpClientBuilder.setDefaultRequestConfig(RequestConfig.custom() .setConnectionRequestTimeout(10000) .setSocketTimeout(10000).build()); // 连接池配置 httpClientBuilder.setMaxConnTotal(2000); httpClientBuilder.setMaxConnPerRoute(100); HttpClient httpClient = httpClientBuilder.build(); HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); clientHttpRequestFactory.setConnectTimeout(10000); clientHttpRequestFactory.setReadTimeout(10000); RestTemplate restTemplate = new RestTemplate(); restTemplate.setRequestFactory(clientHttpRequestFactory); restTemplate.setInterceptors(Collections.singletonList(new RequestResponseLoggingInterceptor())); return restTemplate; }
线程池配置
new TraceableExecutorService(factory, Executors.newWorkStealingPool(20000))
可能原因及解决思路
1. 线程池选型不当
Executors.newWorkStealingPool(20000)创建的是ForkJoinPool,参数表示并行级别而非固定线程数。ForkJoinPool的工作窃取调度更适合CPU密集型任务,对于HTTP调用这类IO密集型任务,线程会频繁因IO阻塞挂起,工作窃取机制无法充分利用线程资源,可能导致任务排队串行。
解决方案:换成适配IO密集型场景的固定线程池:
new TraceableExecutorService(factory, new ThreadPoolExecutor( 10, // 核心线程数,可根据并发量调整,建议2*CPU核心数以上 50, // 最大线程数 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), new ThreadFactoryBuilder().setNamePrefix("rest-call-worker-").build() ))
2. CompletableFuture等待顺序导致的感知串行
当前使用callA.get(); callB.get(); callC.get();的方式,会先等待callA完成,再去获取callB的结果,即使callB已经先执行完成,也会等到callA结束后才处理。这种等待顺序会让日志看起来像是串行,但实际请求是并行发起的。
解决方案:使用CompletableFuture.allOf()统一等待所有任务完成,再获取结果:
try { // 等待所有任务完成 CompletableFuture.allOf(callA, callB, callC).join(); // 此时所有任务已完成,直接获取结果 TypeA = callA.get(); TypeB = callB.get(); TypeC = callC.get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException("并行调用外部服务失败", e); }
3. HttpClient连接池或重试机制瓶颈
虽然当前连接池配置(总连接2000,单路由100)足够支撑三个并行请求,但如果目标服务是同一域名,且出现连接耗尽的情况(比如之前的请求未及时释放连接),会导致后续请求等待连接。另外,3次重试机制如果遇到服务响应慢,会占用线程和连接资源,加剧阻塞。
解决方案:
- 检查连接是否正常释放:RestTemplate配合HttpComponentsClientHttpRequestFactory会自动释放连接,但若出现异常重试,需确认重试逻辑是否合理
- 调整重试策略:只对特定可重试异常(如IO异常、连接超时)重试,减少不必要的重试占用:
httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(3, true, Arrays.asList( IOException.class, SocketTimeoutException.class )));
4. 日志观测的误区
日志中看到的“串行”可能是打印时机导致的错觉:比如在getCallX方法开头打印线程ID,之后调用RestTemplate进入IO阻塞,当第一个请求的IO返回后,线程继续执行并打印结束日志,此时其他请求可能还在IO阻塞,导致日志顺序看起来像是串行。
解决方案:在每个getCallX方法的开始和结束都打印线程ID和时间戳,确认任务的实际执行时段是否重叠。
内容的提问来源于stack exchange,提问作者Srinivasa Moorthi V

