从JDK 9开始,Java官方终于带来了一个像样的HTTP客户端——java.net.http.HttpClient,到了JDK 17,这个模块已经相当成熟,接口稳定,性能也够看。我这两年用它在生产环境处理批量数据同步、批量状态查询这类场景,踩了不少坑,也总结出了一套还算顺手的写法。这篇文章就把JDK 17下用HttpClient批量发送请求、并行请求的具体实践掰开揉碎讲清楚,适合正在用Java 17做接口调用、微服务聚合、数据采集的开发者参考。
1. 为什么批量请求在业务里绕不开
1.1 从一次“慢得像蜗牛”的批量查询说起
先还原一个我遇到的场景:业务方要查一批订单的物流状态,订单量不大,也就两千条。第一版我写的是for循环里挨个调用查询接口,每次请求耗时大约150毫秒,两千个请求串行跑下来,总耗时接近300秒。这个数字一出来,业务方当场摇头,我也被拉去“复盘”。其实问题不在接口本身慢,而在串行这个思路本身就不适合批量场景。
批量发送请求的本质,是把“一个线程老老实实排队”变成“多个线程同时干活”。假设单次请求耗时是T,并发数是N,那么理想情况下总耗时约等于T + (N-1) * T / N,当N足够大时,总耗时趋近于T。当然,实际还要考虑CPU核数、网络带宽、对端服务的吞吐上限,但整体思路是没错的:把独立的请求并行化,是批量操作最直接有效的优化手段。
1.2 JDK 17的HttpClient到底能不能打
很多人问我,为什么不继续用RestTemplate或者OkHttp?JDK 17的HttpClient已经支持HTTP/1.1和HTTP/2,自带连接池,支持异步发送和WebSocket,最关键的是它是JDK官方维护的,不存在第三方依赖的兼容性问题。官方实现从JDK 11开始引入,到JDK 17已经相当成熟,直接在java.net.http包下,不需要额外引包。
用它在生产环境跑批量请求,最大的优势就是它和CompletableFuture结合得非常紧密。sendAsync返回的就是一个CompletableFuture,配合allOf、join、orTimeout这些方法,可以很优雅地实现并行请求的编排、超时控制和结果聚合。比起多线程手动管理线程池、用Future去get来说,代码量更少,出错率也更低。
1.3 这篇博文会覆盖哪些内容
后面的篇幅里,我会先讲清楚串行和并行的底层差异,然后给出一个完整的批量并行请求示例,包括线程池怎么配、超时怎么设、异常怎么兜底,再重点讲几个日常开发里最容易踩的坑——比如重定向导致认证信息丢失、响应体忘记关闭、并发数控制不当,最后附上一份常见问题排查表。你可以把它当作一份可以直接“抄作业”的实战手册。
2. 串行请求和并行请求的差距有多大
2.1 串行请求代码演示
先看一眼最朴素的串行写法:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); List<String> urls = List.of("https://api.example.com/order/1001", "https://api.example.com/order/1002"); long start = System.currentTimeMillis(); for (String url : urls) { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .GET() .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("订单状态: " + response.body()); } System.out.println("总耗时: " + (System.currentTimeMillis() - start) + "ms");这段代码逻辑没问题,性能问题很大。client.send是同步阻塞的,每次调用都要等响应完全返回之后才能继续下一次。如果接口本身要查数据库、调下游服务,单次耗时150ms,2000个请求就是300秒,这个时间成本在绝大多数业务场景里都是不可接受的。
2.2 并行请求代码演示
再来看并行的写法:
List<CompletableFuture<HttpResponse<String>>> futures = urls.stream() .map(url -> HttpRequest.newBuilder().uri(URI.create(url)).GET().build()) .map(client::sendAsync) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); for (CompletableFuture<HttpResponse<String>> future : futures) { HttpResponse<String> response = future.join(); System.out.println("订单状态: " + response.body()); }核心变化在于用sendAsync替代了send,然后把每个请求返回的CompletableFuture收集起来,再用CompletableFuture.allOf(...).join()等待所有请求完成。这样做的好处是:多个请求在底层会并行执行,等到最慢的那个返回后,再统一处理结果。2000个请求,如果并发拉满,总耗时可能就几百毫秒到几秒,完全不是一个量级。
2.3 并行请求的底层原理
sendAsync之所以能并行,是因为它内部并不是在调用线程里直接发请求,而是把任务提交给了自己的执行器。如果不额外指定,HttpClient内部默认使用一个基于ForkJoinPool.commonPool()的线程池来执行异步任务。这意味着,你的业务线程把任务丢出去之后可以立刻返回,真正干活的线程是JVM公共线程池里的线程。
这里有个容易忽略的点:如果你的业务代码本身跑在ForkJoinPool的工作线程上,再用默认的commonPool去发异步请求,可能会发生线程饥饿。所以在压测环境里,我强烈建议显式指定一个独立的线程池给HttpClient用,后面第4节会专门讲线程池怎么配。
3. 并行请求的三种常见编排方式
3.1 全部请求一起发,统一等待
上面第2节里的写法就是这种方式。它适合请求量不大、对端服务没有明显限流的场景。allOf的语义是等所有future都完成,如果其中一个异常完成,allOf返回的future会以异常结束,但注意join会抛出CompletionException,你需要决定是遇到一个失败就整体失败,还是尽量拿到其它成功的结果。
如果业务允许部分失败,我更推荐逐个future单独处理异常:
futures.forEach(future -> future.whenComplete((resp, ex) -> { if (ex != null) { System.err.println("请求异常: " + ex.getMessage()); } else { System.out.println("响应: " + resp.statusCode() + ", body: " + resp.body()); } }));这样哪怕某个请求超时或断连,其它请求的结果也不会被丢弃。
3.2 限制并发数,分批发送
对端服务往往有并发限制,或者你不想把下游打挂。这种情况就不能无脑把2000个请求全部丢出去了,而是要用有界线程池或信号量来控制同时运行的请求数。
用Semaphore控制并发是个轻量级方案:
Semaphore semaphore = new Semaphore(20); List<CompletableFuture<HttpResponse<String>>> futures = urls.stream() .map(url -> { try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } return CompletableFuture.supplyAsync(() -> { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .GET() .build(); return client.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { throw new RuntimeException(e); } finally { semaphore.release(); } }, executor); }) .toList();这段代码的思路是:最多允许20个请求同时执行,剩下的请求在acquire处等待。不过要注意,这种写法里semaphore.acquire()本身是阻塞的,如果请求列表特别长,创建future的过程也会被拖慢,最好提前把请求对象构造好再统一分发。
另外一种更受控制的方式是把请求列表按固定大小切片,一批一批发,每批用allOf等待完成后再发下一批:
int batchSize = 50; for (int i = 0; i < urls.size(); i += batchSize) { List<String> batchUrls = urls.subList(i, Math.min(i + batchSize, urls.size())); List<CompletableFuture<HttpResponse<String>>> batchFutures = batchUrls.stream() .map(url -> createRequest(url)) .map(client::sendAsync) .toList(); CompletableFuture.allOf(batchFutures.toArray(new CompletableFuture[0])).join(); }分批的方式更简单,但缺点是前一批必须等最后一批完成后才会发起下一批,会造成一定的空窗期。如果你的业务能接受这个空窗期,那分批就是最稳定、最容易理解的做法。
3.3 谁先完成先处理谁,提升响应速度
有些场景不需要等所有请求都完成,而是希望哪个先返回就先处理哪个,比如做实时聚合、数据预热。这时候用CompletableFuture的anyOf或者直接对每个future注册whenComplete回调会更合适:
urls.stream() .map(url -> client.sendAsync(createRequest(url), HttpResponse.BodyHandlers.ofString())) .forEach(future -> future.thenAccept(response -> { // 谁先完成就先处理谁 System.out.println("收到响应: " + response.statusCode()); }));这种方式在UI类应用或流式处理场景里很常见。不过要注意,thenAccept回调里的代码是在哪个线程执行是不确定的,如果你要在回调里操作共享状态,记得加锁或者用线程安全的容器。
4. 线程池、超时和HTTP版本,怎么配才最优
4.1 HttpClient的线程池选择
如果不给HttpClient指定Executor,它会用公共的ForkJoinPool。但生产环境里,我建议每个HttpClient实例都独立指定一个线程池,理由有两点:一是公共池会被其它并行任务挤占,可能饿死HTTP请求;二是独立线程池的线程数可以根据业务调整,做到隔离。
我的推荐配置是这样:
ExecutorService executor = new ThreadPoolExecutor( 10, // 核心线程数 20, // 最大线程数 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "http-client-" + counter.getAndIncrement()); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .executor(executor) .version(HttpClient.Version.HTTP_1_1) .build();核心线程数和最大线程数怎么定?我一般按“目标并发数 = 服务接口支持的最大并发 * 0.7”来粗算,比如接口压测过单实例支持300并发,那客户端线程池的并发就控制在200左右,留一点缓冲。线程池太大反而会因线程上下文切换带来额外开销,并不是线程越多越快。
4.2 超时设置和批量请求的配合
connectTimeout负责建立连接的超时,请求处理超时要用HttpRequest.timeout:
HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) .GET() .build();但这里有个坑:HttpRequest.timeout是从请求发送开始到响应完成的总超时,如果用了sendAsync,超时之后future会以HttpTimeoutException异常结束。可如果你同时用了Connection: keep-alive和连接池,真正等待建立连接的时间由connectTimeout控制,而总超时则由timeout控制,两者要配合设置。
处理批量请求时,我还会额外叠加一层orTimeout,防止极端情况下future迟迟不结束:
CompletableFuture<HttpResponse<String>> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); future.orTimeout(15, TimeUnit.SECONDS);orTimeout在超时时会把这个future异常完成,配合exceptionally或whenComplete可以做兜底处理。
4.3 HTTP/1.1还是HTTP/2
JDK 17的HttpClient默认会尝试HTTP/2,如果服务端不支持就自动回退到HTTP/1.1。HTTP/2支持多路复用,多个请求可以共享一个TCP连接,省去了频繁建连的开销,对批量请求场景特别有意义。
但要注意,如果服务端是老旧Nginx配置或者不支持h2,客户端手写日志里会频繁出现“HTTP/2 not supported by server”之类的调试信息。我在生产里遇到过,解决方法是根据服务端能力测试后,直接在客户端指定HTTP_1_1,减少协商开销:
.version(HttpClient.Version.HTTP_1_1)如果服务端支持HTTP/2且你又希望降低TCP连接数,那就保持默认的HTTP_2即可。无论哪一种,连接池默认都开启,HTTP/1.1下默认连接池大小是keep-alive不关闭条件下的空闲连接数,能复用就复用。
5. 完整案例:用JDK 17批量查询订单状态
5.1 场景设定与代码结构
假设我们现在要实现一个功能:输入一个订单号列表,批量调用第三方接口查询订单状态,接口返回JSON字符串,需要把状态提取出来再聚合。单次请求响应格式如下:
{ "orderId": "1001", "status": "SHIPPED", "updateTime": "2025-01-15 10:30:00" }我们需要把2000个请求并行发出,并限制最大并发数为50,全部完成后打印每个订单的状态。
5.2 完整代码示例
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; public class BatchOrderQuery { public static void main(String[] args) { List<String> orderIds = new CopyOnWriteArrayList<>(); for (int i = 1001; i <= 3000; i++) { orderIds.add(String.valueOf(i)); } // 构建线程池 ExecutorService executor = new ThreadPoolExecutor( 20, 20, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(5000), new ThreadFactory() { private final AtomicInteger cnt = new AtomicInteger(); @Override public Thread newThread(Runnable r) { return new Thread(r, "order-query-" + cnt.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() ); // 构建HttpClient HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .executor(executor) .version(HttpClient.Version.HTTP_1_1) .build(); // 控制并发:信号量限流 Semaphore semaphore = new Semaphore(50); long start = System.currentTimeMillis(); List<CompletableFuture<Map.Entry<String, String>>> futures = orderIds.stream() .map(orderId -> { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/order/" + orderId)) .timeout(Duration.ofSeconds(10)) .header("Accept", "application/json") .GET() .build(); try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } return client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApplyAsync(response -> { String body = response.body(); String status = extractStatus(body); return Map.entry(orderId, status); }, executor) .whenComplete((entry, ex) -> semaphore.release()); }) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); for (CompletableFuture<Map.Entry<String, String>> future : futures) { try { Map.Entry<String, String> entry = future.join(); System.out.println("订单 " + entry.getKey() + " 状态: " + entry.getValue()); } catch (CompletionException e) { System.err.println("订单查询失败: " + e.getMessage()); } } System.out.println("总耗时: " + (System.currentTimeMillis() - start) + "ms"); executor.shutdown(); } private static String extractStatus(String json) { // 生产环境建议用JSON库解析,这里用简单字符串截取做演示 int idx = json.indexOf("\"status\""); if (idx < 0) return "UNKNOWN"; int startIdx = json.indexOf(':', idx) + 2; int endIdx = json.indexOf('"', startIdx); return json.substring(startIdx, endIdx); } }这段代码有几个关键点:
- 信号量限制并发为50,防止一次性发起过多请求打爆下游。
thenApplyAsync指定了用同一个executor解析响应,避免回调跑到公共池里。whenComplete里释放信号量,确保每个请求无论成功还是失败都会释放。- 最后用
allOf().join()等待所有请求结束,再逐个读取结果。
5.3 实测对比结果
我在本机测试环境(8核16G,服务端在同一内网)里,用这个方案跑了1000个请求,单次接口耗时约80ms,串行总耗时约80秒,用上面的并行方案(并发50)总耗时约3.2秒,提升了25倍左右。再把并发提到100,总耗时能压到2秒以内,但再往上加并发,效果就不明显了,因为服务端自身的处理能力开始成为瓶颈。
所以建议:不要盲目追求高并发,先搞清楚对端服务的压测上限,再倒推客户端的并发数。
6. 重定向、认证信息丢失与其它常见坑
6.1 HTTP重定向导致认证信息丢失
这个坑在标题热词里出现了,说明踩的人不少。JDK的HttpClient默认重定向策略是NEVER,也就是不自动跟随重定向。如果你手动设置成ALWAYS,在重定向到新的URL时,默认情况下Authorization头会被移除,这就导致重定向后的请求丢失认证信息,返回401。
解决办法有两个方向。方案一:不要依赖自动跟随,自己处理重定向逻辑:
HttpClient client = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 301 || response.statusCode() == 302) { String newLocation = response.headers().firstValue("Location").orElseThrow(); HttpRequest newRequest = HttpRequest.newBuilder() .uri(URI.create(newLocation)) .header("Authorization", yourToken) .GET() .build(); response = client.send(newRequest, HttpResponse.BodyHandlers.ofString()); }方案二:如果必须用ALWAYS,那就给HttpClient配一个Authenticator,但这种方式不是所有场景都好使,我实际经验是权宜之计。最稳妥的还是方案一,自己控制重定向并在新请求里带上认证头。
6.2 响应体忘记关闭,连接不释放
用HttpResponse.BodyHandlers.ofString()不会有问题,因为字符串已经一次性读到内存里了。但如果你用BodyHandlers.ofInputStream(),那就必须记住关闭InputStream,否则TCP连接不会释放,连接池很快被占满:
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); try (InputStream in = response.body()) { // 读取数据 }批量请求场景里,这个坑会导致大量连接处于CLOSE_WAIT状态,最终表现为“请求越来越慢”甚至“端口耗尽”。
6.3 并发数与连接池不匹配
HTTP/1.1下,一个连接同一时间只能处理一个请求。如果并发数是100,但HttpClient连接池还没有建好这么多连接,请求就会排队等待空闲连接。JDK的连接池默认是keep-alive,但并不是无限连接。当你的并发数明显超过连接池上限时,性能反而会下降。
我用一个简单经验值:HTTP/1.1下,客户端线程池的核心线程数就是最大并发数,连接池一般会在并发升上来后自动建立对应数量的连接。如果发现大量请求排队,优先调大executor里的最大线程数,或者限制并发数到连接池可控的范围内。
6.4 批量重试导致服务端压力爆炸
批量请求一旦有失败,很多人第一反应就是“重试”。但如果所有失败请求在同一个时间点重试,瞬间又会产生一个巨大的流量高峰。我建议重试时加一个随机退避,比如:
int retryCount = 3; for (int i = 0; i < retryCount; i++) { try { response = client.send(request, HttpResponse.BodyHandlers.ofString()); break; } catch (IOException e) { if (i == retryCount - 1) throw e; Thread.sleep(ThreadLocalRandom.current().nextLong(100, 500) * (i + 1)); } }随机退避能把重试请求在时间轴上限开,避免形成重试风暴。
7. 常见问题排查表
下面这个表格是实际排查时比较高频的几个问题,按“现象—原因—方案”整理:
| 现象 | 可能原因 | 排查/解决方案 |
|---|---|---|
| 批量请求耗时忽高忽低 | 并发数过大,服务端限流或线程池排队 | 降低并发数,观察服务端日志,增加随机退避 |
| 发送大量请求后程序挂起 | 默认ForkJoinPool线程饥饿 | 显式指定独立的线程池给HttpClient |
| 重定向后返回401 | 跟随重定向时鉴权头被移除 | 关闭自动重定向,手动处理并带上Authorization |
| 连接不释放,CLOSE_WAIT增多 | 响应体为InputStream未关闭 | 用try-with-resources关闭InputStream |
| 超时设置不生效 | 只设置了connectTimeout,没设request timeout | 给每个request设置.timeout() |
| allOf().join()抛出异常但不知道哪个请求失败 | 没有对单个future做异常捕获 | 每个future单独注册whenComplete处理异常 |
| HTTP/2协商日志刷屏 | 服务端不支持HTTP/2 | 客户端显式指定HTTP_1_1 |
这个表不能解决所有问题,但覆盖了九成的批量请求场景。如果你还遇到其它奇葩问题,优先看两点:线程池有没有被打满、连接池有没有泄漏。
8. 关于并行请求,我最后想说的话
我在生产环境里用这套方案跑了快两年,最大的体会是:不要把“并行”想得太神秘,它的核心无非是线程池、连接池和异步编排这三件事。JDK 17的HttpClient已经帮你把底层协议处理好了,你真正要花心思的是并发数的控制、超时的兜底和异常的处理。
如果你用的正好是JDK 21,那虚拟线程可以进一步简化这个模型——用Thread.ofVirtual().start()配合同步的send方法,代码写起来更像串行,但底层是虚拟线程调度,天然适合高并发IO场景。不过JDK 17作为LTS版本,阵地还在,用CompletableFuture这套方案也足够应付绝大多数批量请求需求。
最后分享一个小技巧:批量请求前,先做一次小规模压测,比如分别测并发10、50、100、200的耗时曲线,找出拐点,再按拐点附近的并发数来设置信号量或线程池参数。这个拐点每套环境都不一样,别指望一套配置走天下。