1. 概述
在日常开发中,我们发起 HTTP 请求时通常采用串行方式。但在某些场景下,我们更希望并行发起多个请求,以提升系统性能或缩短整体响应时间。
比如:
- 需要从多个微服务获取用户相关信息
- 聚合多个独立数据源的结果
- 提升高延迟接口的整体吞吐
本文将聚焦于 Spring 5 引入的响应式 WebClient,展示几种实用的并行调用方案。✅ 掌握这些技巧,能让你的响应式服务更高效。
📌 提示:本文假设你已具备 Reactor 基础知识(如
Mono
/Flux
),若不熟悉建议先阅读 Spring WebFlux 指南
2. 响应式编程回顾
WebClient
是 Spring 5 中推出的响应式 HTTP 客户端,属于 Spring WebFlux 模块的一部分。它基于非阻塞 I/O,底层使用 Netty 或其他支持 Reactive Streams 的客户端,能以极小的线程开销处理大量并发请求。
关键优势:
- ✅ 非阻塞,资源利用率高
- ✅ 天然支持背压(Backpressure)
- ✅ 与 Project Reactor 深度集成
⚠️ 注意:使用
WebClient
时无需手动配置线程池或Scheduler
—— 它内部已自动管理事件循环和线程切换。
3. 示例用户服务接口
为便于演示,我们假设有如下 REST 接口:
GET /user/{id} → 返回 User 对象
对应的 WebClient
调用封装如下:
WebClient webClient = WebClient.create("http://localhost:8080");
public Mono<User> getUser(int id) {
LOG.info("Calling getUser({})", id);
return webClient.get()
.uri("/user/{id}", id)
.retrieve()
.bodyToMono(User.class);
}
接下来,我们将基于此方法实现多种并发调用模式。
4. 实现 WebClient 并发调用
4.1 同一服务的批量并行调用
场景:需要根据多个用户 ID 并行查询用户信息,并返回用户列表。
public Flux<User> fetchUsers(List<Integer> userIds) {
return Flux.fromIterable(userIds)
.flatMap(this::getUser);
}
关键点解析:
Flux.fromIterable()
:将 List 转为反应式流flatMap()
:并发执行每个getUser
调用,默认并发度为 256- 可通过
.flatMap(this::getUser, concurrency)
自定义并发数
- 可通过
- ⚠️ 结果顺序不保证与输入一致!若需保序,请使用
flatMapSequential
💡 踩坑提醒:
flatMap
的默认并发限制是 256,如果处理上千个 ID,建议显式设置更高值,否则会变成“伪并行”。
4.2 多个服务返回相同类型
场景:两个不同接口都返回 User
类型,需并行调用并合并结果。
新增一个接口:
public Mono<User> getOtherUser(int id) {
return webClient.get()
.uri("/otheruser/{id}", id)
.retrieve()
.bodyToMono(User.class);
}
并行调用实现:
public Flux<User> fetchUserAndOtherUser(int id) {
return Flux.merge(getUser(id), getOtherUser(id));
}
对比说明:
方法 | 适用场景 |
---|---|
Flux.merge() |
合并多个 Flux 或 Mono ,不保证顺序 |
Flux.concat() |
串行合并,保持顺序 |
mergeSequential() |
并行执行但按订阅顺序发布结果 |
✅ 推荐使用 merge
实现真正意义上的并行拉取。
4.3 多个服务返回不同类型(聚合场景)
这才是最常见的真实业务场景:从不同服务获取不同数据,最终组装成一个复合对象。
例如:
- 用户服务 →
User
- 商品服务 →
Item
- 目标:返回
UserWithItem
实现方式:使用 Mono.zip
public Mono<UserWithItem> fetchUserAndItem(int userId, int itemId) {
Mono<User> user = getUser(userId);
Mono<Item> item = getItem(itemId);
return Mono.zip(user, item, UserWithItem::new);
}
核心机制:
zip
会等待所有Mono
完成后再触发组合- 支持 2~8 个
Mono
的组合(超限可用Tuple
扩展) - 组合函数(如
UserWithItem::new
)运行在最后一个完成的线程上下文中
✅ 简单粗暴的理解:
zip = 并行发起 + 全部完成后再处理
5. 测试验证并发行为
如何证明我们的调用确实是并行而非串行?最直接的方式是通过耗时断言。
我们使用 WireMock 模拟延迟接口:
@Test
public void givenClient_whenFetchingUsers_thenExecutionTimeIsLessThanDouble() {
int requestsNumber = 5;
int singleRequestTime = 1000; // 每个请求延迟 1s
// 模拟 5 个 /user/{id} 接口,每个响应耗时 1s
for (int i = 1; i <= requestsNumber; i++) {
stubFor(get(urlEqualTo("/user/" + i))
.willReturn(aResponse()
.withFixedDelay(singleRequestTime)
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(String.format("{ \"id\": %d }", i))));
}
List<Integer> userIds = IntStream.rangeClosed(1, requestsNumber)
.boxed()
.collect(Collectors.toList());
Client client = new Client("http://localhost:8089");
long start = System.currentTimeMillis();
List<User> users = client.fetchUsers(userIds).collectList().block();
long end = System.currentTimeMillis();
long totalExecutionTime = end - start;
assertEquals("用户数量不符", requestsNumber, users.size());
assertTrue("总耗时过长,疑似串行执行", 2 * singleRequestTime > totalExecutionTime);
}
断言逻辑:
- 若串行执行:总耗时 ≈ 5s
- 若完全并行:总耗时 ≈ 1s
- 我们断言:
总耗时 < 2s
→ 基本能确认是并发执行 ✅
🔍 提示:
.block()
仅用于测试,在生产代码中应避免阻塞操作。
6. 总结
本文系统介绍了使用 Spring WebClient 实现并发 HTTP 调用的三种典型模式:
场景 | 核心操作符 | 说明 |
---|---|---|
批量 ID 查询同一服务 | flatMap |
注意并发度和顺序问题 |
多个同类型服务聚合 | Flux.merge |
真并行,结果无序 |
多个异构服务聚合 | Mono.zip |
等待所有完成,适合组装 |
✅ 掌握这些模式后,你可以轻松应对大多数微服务聚合场景,显著提升接口响应速度。
🌐 示例代码已托管至 GitHub:https://github.com/eugenp/tutorials/tree/master/spring-reactive-modules/spring-reactive-client