ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

实战干货:Spring 调用 MCP 服务响应慢?用 WebClient + WebFlux 重构 RestTemplate 调用链

2026/9/26 3:31:29 拓冰建站 浏览量
实战干货:Spring 调用 MCP 服务响应慢?用 WebClient + WebFlux 重构 RestTemplate 调用链 1. 从一次压测说起RestTemplate 调用 MCP 服务为什么这么慢如果你正在用 Spring Boot 对接 MCP 服务接口功能都通了但一上压测就发现响应时间飙到几百毫秒、P99 直接破两秒那这篇内容大概率能帮到你。MCP 服务本身是 HTTP JSON 的调用形态客户端封装层用得好不好直接决定了整条链路的吞吐上限。我这次遇到的场景很典型Spring Boot 2.7 项目用默认的 RestTemplate 同步调用下游 MCP 服务单机 100 并发压测平均响应 320msP99 达到 2100ms而服务端 CPU、内存、慢查询、GC 全部正常。问题出在客户端。线程大量处于 TIMED_WAITING说明它们不是在算而是在等。等什么等连接建立、等 IO 返回、等一个本可以并行却被迫串行的结果。这篇内容会从连接池、超时、序列化三个角度把瓶颈拆开然后给出可复制的 WebClient 配置类、WebFlux 异步调用骨架、压测对比脚本最后用 TaoToken 统一 Key/API 通道接入 MCP 服务做端到端验证。适合已经能跑通 MCP 调用、但被延迟和吞吐卡住的 Spring 开发者。先用 curl 直接打 MCP 服务耗时只有 45ms说明网络和服务端处理都没问题。再用 Wireshark 抓包看到大量 TCP 三次握手和四次挥手——每个请求都在重新建连接。默认的 SimpleClientHttpRequestFactory 不做连接复用HTTPS 场景下还要叠加 TLS 握手这部分开销全被算进了业务耗时里。更隐蔽的一处过滤器里做了同步的用户信息解析在 IO 线程上又阻塞了约 150ms。三个问题叠在一起慢是必然的。2. 动手前先把 TaoToken 通道准备好重构调用链之前建议先把 MCP 服务的访问入口统一掉否则你会在每个环境里维护一堆散落的 Key 和 BaseURL。我这次用 TaoToken 做统一通道一个 Key 管多个模型/服务入口BaseURL 固定切换环境只改配置不改代码。对后面做压测对比也友好因为客户端配置是同一份变量只剩调用方式。具体操作路径如下。先打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 了解通道能力然后进控制台创建 API Key控制台入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI Key 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基地址用 https://taotoken.net/api 注意这个地址不带任何查询参数直接作为 baseUrl 写进配置即可。Key 建议放环境变量不要硬编码进代码库# application.yml mcp: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} connect-timeout-ms: 3000 response-timeout-ms: 5000提示如果你只是想先验证模型通道是否通可以直接用模型对话页面发一条请求确认 Key 和网络都没问题再回到代码里做压测。模型对话入口https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content这一步做完你手里应该有一个可用的 Key、一个固定的 BaseURL以及一份超时参数。接下来所有客户端改造都围绕这三个东西展开。3. 可复制配置从 RestTemplate 连接池到 WebClient3.1 先给 RestTemplate 补上连接池和超时很多人以为 RestTemplate 慢是框架问题其实默认实现根本没给你配连接池。先用 HttpClient 连接池把它救回来作为对照基线Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { PoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50); RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(3000) .setSocketTimeout(5000) .setConnectionRequestTimeout(2000) .build(); CloseableHttpClient httpClient HttpClients.custom() .setConnectionManager(cm) .setDefaultRequestConfig(requestConfig) .setKeepAliveStrategy(DefaultConnectionKeepAliveStrategy.INSTANCE) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); } }这一步能把平均响应从 320ms 压到 150ms 左右但线程仍然是阻塞的吞吐上不去。连接池解决的是「复用」解决不了「等待」。3.2 WebClient 配置类连接池、超时、压缩一次配齐WebClient 基于 Reactor Netty天然非阻塞。下面这个配置类可以直接复制注意超时参数和连接池要跟你的 MCP 服务 QPS 匹配Configuration public class WebClientConfig { Value(${mcp.base-url}) private String baseUrl; Value(${mcp.api-key}) private String apiKey; Bean public WebClient mcpWebClient() { ConnectionProvider provider ConnectionProvider.builder(mcp-pool) .maxConnections(500) .pendingAcquireMaxCount(1000) .pendingAcquireTimeout(Duration.ofSeconds(3)) .maxIdleTime(Duration.ofSeconds(30)) .build(); HttpClient httpClient HttpClient.create(provider) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) .responseTimeout(Duration.ofSeconds(5)) .compress(true) .keepAlive(true) .doOnConnected(conn - conn .addHandlerLast(new ReadTimeoutHandler(5)) .addHandlerLast(new WriteTimeoutHandler(3))); return WebClient.builder() .baseUrl(baseUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer apiKey) .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); } }几个参数值得单独说。maxConnections 是总连接上限pendingAcquireMaxCount 是等待队列长度pendingAcquireTimeout 决定等不到连接时多久失败——这三个值配小了会在高并发下抛异常配大了会拖长尾延迟。responseTimeout 是整段响应超时ReadTimeoutHandler 是读超时两者叠加能防止慢响应把连接占死。compress(true) 开启 gzipJSON 体积大的 MCP 响应收益明显。3.3 WebFlux 异步调用骨架Service 层返回 MonoController 直接返回 Mono整条链路不阻塞容器线程Service public class McpService { private final WebClient webClient; public McpService(WebClient mcpWebClient) { this.webClient mcpWebClient; } public MonoMcpResponse callMcp(McpRequest request) { return webClient.post() .uri(/v1/mcp/invoke) .bodyValue(request) .retrieve() .onStatus(HttpStatusCode::is5xxServerError, resp - Mono.error(new IllegalStateException(MCP 5xx))) .bodyToMono(McpResponse.class) .timeout(Duration.ofSeconds(5)) .retryWhen(Retry.backoff(2, Duration.ofMillis(100)) .filter(ex - !(ex instanceof IllegalStateException))); } }RestController public class McpController { private final McpService mcpService; public McpController(McpService mcpService) { this.mcpService mcpService; } PostMapping(/mcp/call) public MonoMcpResponse call(RequestBody McpRequest request) { return mcpService.callMcp(request); } }注意 retryWhen 里过滤掉了业务异常只对网络类异常重试否则会把下游的 5xx 放大成重试风暴。如果你需要同时调多个 MCP 接口用 Mono.zip 并行合并比串行快一个数量级。3.4 序列化与缓存别让 JSON 和重复请求拖后腿Jackson 默认配置对深层嵌套对象不算快可以显式关掉不必要的特性并复用 ObjectMapperBean public ObjectMapper objectMapper() { return JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); }对于权限、配置这类低频变化的数据加一层 Caffeine 本地缓存能直接砍掉重复的 MCP 调用Bean public CacheString, McpResponse mcpCache() { return Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() .build(); }4. 验证请求压测脚本与成功结果配置改完必须用数据说话。下面是一个 JMeter 命令行压测脚本100 并发、持续 5 分钟直接对比优化前后jmeter -n -t mcp-test.jmx \ -Jthreads100 \ -Jduration300 \ -Jhostlocalhost \ -Jport8080 \ -Jpath/mcp/call \ -l result.jtl \ -e -o report/如果不想装 JMeter用 wrk 也能快速看吞吐wrk -t8 -c100 -d300s --latency \ -s post.lua \ http://localhost:8080/mcp/callpost.lua 里构造 MCP 请求体并设置 Content-Type 为 application/json。跑完后重点看三列平均延迟、P99、TPS。我实测下来的对比结果如下指标优化前优化后变化平均响应时间320 ms62 ms下降约 81%P99 响应时间2100 ms145 ms下降约 93%吞吐量 TPS2801520提升约 5.4 倍阻塞线程数经常打满 200小于 10显著改善服务端 CPU 也从 45% 降到 22%因为连接建立和销毁的开销被消掉了。端到端验证时把 baseUrl 指向 TaoToken 的 API 地址带上 Bearer Key请求能正常返回 MCP 结果说明通道和客户端改造都生效了。如果你还想确认模型侧通道是否正常可以在模型对话页面发一条测试请求交叉验证。5. 本篇常见错排查报错一连接池获取超时抛 PoolAcquireTimeoutException。说明 pendingAcquireTimeout 太短或 maxConnections 太小。先看压测并发数把 maxConnections 调到并发数的 2 到 3 倍pendingAcquireMaxCount 给足缓冲。报错二ReadTimeoutException 频繁出现。大概率是 responseTimeout 和 ReadTimeoutHandler 设得比 MCP 服务实际处理时间还短。先用 curl 测单次真实耗时再在此基础上留 2 倍余量。报错三WebClient 返回 Mono 但 Controller 拿不到数据。检查是否在阻塞方法里调用了 block()或者漏了 subscribe。WebFlux 链路里一旦混入阻塞调用非阻塞优势就没了用 block() 的地方要逐个排查。报错四401 或 403。检查 Authorization 头是否带上了 Bearer 前缀Key 是否从环境变量正确注入。TaoToken 的 Key 在 API Keys 页面可以重新生成接入文档里有完整的请求头示例。报错五gzip 开启后响应乱码。确认服务端也开启了压缩且客户端没有手动重复解压。WebClient 的 compress(true) 会自动处理 Accept-Encoding 和响应解压不需要额外代码。报错六重试导致下游压力翻倍。retryWhen 一定要过滤异常类型只对连接类、超时类异常重试业务异常和 4xx 不要重试。6. 把通道和调用链一起收口调用链重构完之后建议把 Key 和 BaseURL 的获取方式也固定下来避免后面换环境时又散落一地。长期做编码和 Agent 类任务的话可以看下 Coding Plan它把常用通道和额度做了打包省去反复配 Key 的麻烦https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content如果你更想先把当前这套 WebClient 方案跑通那就回到 API Keys 页面确认 Key 状态再对着接入文档核对请求头和路径https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 和 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content最后留一个我踩过的坑WebClient 的 baseUrl 末尾不要带斜杠uri() 里也不要以斜杠开头否则路径会拼接成双斜杠部分网关会直接返回 404。这个细节在压测时不容易发现但会让你误以为是通道问题。把 baseUrl 写成 https://taotoken.net/api uri 写成 /v1/mcp/invoke路径就是干净的。