Spring WebClient多并发请求失败(Too many open files)求助
问题场景
使用Spring WebClient执行460个并发HTTPS请求进行模拟测试,要求所有请求同时启动,通过JUnit实现。当并发数低于440-450时正常,设置为460时部分线程抛出java.io.IOException: Too many open files错误。已将macOS Sequoia 15.1.1的系统文件打开限制从256修改为120000,但问题依旧。
测试代码如下:
@Test void testConcurrentSimulations() throws InterruptedException { var simulationCount = 460; var latch = new CountDownLatch(simulationCount); var webClient = getWebClient(simulationCount); var params = getParams(); var apiCallSupplier = getApiCallSupplier(webClient, params); var simulationThreads = IntStream.range(0, simulationCount) .mapToObj(i -> new Simulation(apiCallSupplier, latch)) .toList(); //when simulationThreads.forEach(Simulation::start); //then latch.await(); } private WebClient getWebClient(int maxConnections) { var httpClient = HttpClient.create( ConnectionProvider.builder("myTestConnectionProvider") .maxConnections(maxConnections) .pendingAcquireTimeout(Duration.of(-1, ChronoUnit.SECONDS)) .build() ); return WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); } private Supplier<Mono<SomeResponse>> getApiCallSupplier(WebClient webClient, LinkedMultiValueMap<String, String> authParams) { return () -> webClient .post() .uri("https://someapi.com/endpoint") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(BodyInserters.fromFormData(authParams)) .retrieve() .bodyToMono(SomeResponse.class); }
Simulation类代码:
public class Simulation extends Thread { private final Supplier<Mono<SomeResponse>> apiCallSupplier; private final CountDownLatch latch; public Simulation(Supplier<Mono<SomeResponse>> apiCallSupplier, CountDownLatch latch) { this.apiCallSupplier = apiCallSupplier; this.latch = latch; } @Override public void run() { try { apiCallSupplier.get().subscribe(System.out::println); } finally { latch.countDown(); } }}
错误原因
- 线程模型不匹配导致资源浪费:WebClient基于Reactor异步非阻塞模型设计,但当前实现用了
Thread+CountDownLatch的同步线程方式,每个线程触发一个Mono订阅,会创建460个线程,每个HTTPS请求需要占用至少一个文件描述符(Socket连接),加上SSL握手的额外资源,瞬间占用的文件描述符数量超过了JVM进程的限制。 - JVM进程文件描述符限制未更新:虽然修改了系统级的文件打开限制,但JVM进程自身的文件描述符上限可能仍为默认值(比如1024),未同步调整。
- CountDownLatch时机错误:当前代码中
latch.countDown()在subscribe()后立即执行,而subscribe()是异步操作,主线程会提前完成等待,但请求可能还在处理中,资源无法及时回收,加剧了文件描述符的占用。 - 连接池配置不合理:
pendingAcquireTimeout设为-1会让请求无限等待连接,导致请求堆积,进一步占用更多资源;同时未针对单域名设置maxConnectionsPerRoute,可能导致同一域名下的并发连接超过合理上限。
解决方法
- 改用Reactor原生并发方式:抛弃手动创建线程的方式,利用Reactor的
Flux实现并发请求,让框架自动管理线程和资源,避免创建大量线程。修改测试代码如下:
@Test void testConcurrentSimulations() { var simulationCount = 460; var webClient = getWebClient(simulationCount); var params = getParams(); var apiCallSupplier = getApiCallSupplier(webClient, params); // 用Flux实现并发,指定并发数为simulationCount Flux.range(0, simulationCount) .flatMap(i -> apiCallSupplier.get(), simulationCount) .doOnNext(System.out::println) .blockLast(); // 等待所有请求完成 }
同时删除Simulation类,无需手动管理线程。
调整JVM进程文件描述符限制:
- 临时生效:在运行测试前,在终端执行
ulimit -n 120000,确保当前shell的文件描述符上限已修改。 - 永久生效:在JVM启动参数中添加
-XX:MaxFileDescriptorCount=120000,强制JVM使用更高的文件描述符上限。
- 临时生效:在运行测试前,在终端执行
修复CountDownLatch时机(如果保留线程方式):如果必须保留手动线程的方式,需要在请求完成后再执行
countDown(),修改Simulation类的run()方法:
@Override public void run() { apiCallSupplier.get() .subscribe( System.out::println, Throwable::printStackTrace, latch::countDown // 请求完成后再递减latch ); }
注意:这种方式仍不如Reactor原生并发高效,不推荐。
- 优化连接池配置:
- 设置
maxConnectionsPerRoute,针对同一域名限制并发连接数,避免单域名连接过多:
var httpClient = HttpClient.create( ConnectionProvider.builder("myTestConnectionProvider") .maxConnections(simulationCount) .maxConnectionsPerRoute(simulationCount) // 针对单域名设置最大连接数 .pendingAcquireTimeout(Duration.of(10, ChronoUnit.SECONDS)) // 设置合理的等待超时,避免无限堆积 .build() ); - 设置
内容的提问来源于stack exchange,提问作者uzarkov
相关产品推荐
相关产品推荐

