一次由 RestTemplate 引发的 Connection reset:被 XML 截胡的请求体

一次由 RestTemplate 引发的 Connection reset:被 XML 截胡的请求体

一行 balancedRestTemplateSwitch=false 把开关切到直连模式后,OR-Tools 服务立刻 Connection reset。排查绕了 Content-Type、转换器、拦截器、自动装配一圈,最后发现根因藏在 Spring RestTemplate 默认转换器列表的排序里。本文完整记录这次定位过程。

一、现象

切换到开发环境直连模式(balancedRestTemplateSwitch=false)后,调用 OR-Tools /solve 接口立即抛异常:

复制代码
2026-07-20 11:06:24.075 [Add-Async-Thread-9] ERROR ... 调用ORTools异常:
org.springframework.web.client.ResourceAccessException: I/O error on POST request for
"http://xxx:30080/solve": Connection reset; nested exception is
java.net.SocketException: Connection reset

关键线索藏在请求日志里:

复制代码
Http Request | URL = http://xxx:30080/solve, ReqMethod = POST,
Headers = {
  Accept=[application/xml, text/xml, application/json, application/cbor,
          application/*+xml, application/*+json],
  Content-Length=[133857],
  Content-Type=[application/xml;charset=UTF-8]
}

两个异常点:

  1. Content-Type 居然是 application/xml ------ 我们明明想发 JSON
  2. Content-Length 133857 ------ 比正常 JSON 请求大了一倍多
  3. Connection reset ,不是 SocketTimeoutException,也不是 4xx ------ 说明连接在协议层就被断开了,没走到业务逻辑

二、调用链路定位

先看代码结构。调用 OR-Tools 的入口是:

java 复制代码
// RestTemplateUtils.java
public static Object postForORModel(RestTemplate restTemplate, String url,
                                    Object requestEntity) throws Exception {
    ResponseEntity<RespOutput> resp =
        restTemplate.postForEntity(url, requestEntity, RespOutput.class);
    ...
}

注意:requestEntity 是裸的 ReqInput POJO,没有被 HttpEntity 包裹,没有显式设置 Content-Type

RestTemplate 的来源:

java 复制代码
// ScoreToolMethod.java
@Value("${balancedRestTemplateSwitch:true}")
private boolean balancedRestTemplateSwitch;

public RestTemplate getActiveRestTemplate() {
    return balancedRestTemplateSwitch ? balancedRestTemplate : restTemplate;
}

两个 Bean 的定义:

java 复制代码
// RestTemplateConfig.java
@Bean("balancedRestTemplate")
@LoadBalanced
public RestTemplate loadBalancedRestTemplate() {
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.setMessageConverters(buildMessageConverters());  // ← 配了 JSON 转换器
    restTemplate.setInterceptors(Collections.singletonList(new RequestHeaderInterceptor()));
    restTemplate.setRequestFactory(buildRequestFactory());
    return restTemplate;
}

@Bean("plainRestTemplate")
public RestTemplate plainRestTemplate() {
    return new RestTemplate(buildRequestFactory());  // ← 一行流,啥都没配
}

问题已经浮现:plainRestTemplate 只传了 RequestFactory没有配 messageConverters,也没有配拦截器

三、为什么请求体被发成了 XML

这是整件事的核心。

3.1 Spring 默认转换器列表的"排序陷阱"

new RestTemplate() 无参构造器装配转换器时,顺序是固定的:

复制代码
ByteArrayHttpMessageConverter
StringHttpMessageConverter
ResourceHttpMessageConverter
SourceHttpMessageConverter
AllEncompassingFormHttpMessageConverter
MappingJackson2XmlHttpMessageConverter   ← classpath 有 jackson-dataformat-xml 就加
MappingJackson2HttpMessageConverter       ← jackson-databind,永远在 XML 之后
MappingJackson2CborHttpMessageConverter   ← classpath 有 jackson-dataformat-cbor 就加
...

XML 转换器排在 JSON 转换器前面。 这是一个很容易被忽略的默认行为。

3.2 转换器选择逻辑

RestTemplate.postForEntity 内部会创建 HttpEntityRequestCallback,在 doWithRequest 里选转换器:

java 复制代码
// `AbstractHttpMessageConverter.canWrite`
protected boolean canWrite(MediaType mediaType) {
    if (mediaType == null || getSupportedMediaTypes().isEmpty()) {
        return true;   // ← 关键分支
    }
    for (MediaType supported : getSupportedMediaTypes()) {
        if (supported.isCompatibleWith(mediaType)) {
            return true;
        }
    }
    return false;
}

当请求体是 POJO 且没有显式声明 Content-Type 时,mediaType == nullcanWrite 直接返回 true。于是 HttpEntityRequestCallback 按列表顺序遍历,第一个能处理 POJO 的转换器胜出

由于 XML 转换器排在前面,且 MappingJackson2XmlHttpMessageConverter 用 Jackson 的 XML 序列化器,能处理任意 POJO,所以它"截胡"了:

  • ReqInput 序列化成 XML 字节流写入 body
  • 顺手把自己的默认 MediaType 写进 Content-Type 头 → application/xml;charset=UTF-8

3.3 为什么 classpath 里会有 jackson-dataformat-xml

日志的 Accept 头是铁证:

复制代码
Accept=[application/xml, text/xml, application/json, application/cbor,
        application/*+xml, application/*+json]

出现 application/xmltext/xmlapplication/*+xml 证明 MappingJackson2XmlHttpMessageConverter 已注册;出现 application/cbor 证明 MappingJackson2CborHttpMessageConverter 也在。这两个依赖是通过公司内部 BOM yumchina-spring-boot-dependencies 传递进来的,开发者在写 plainRestTemplate 时根本没意识到它们的存在。

3.4 对比 balancedRestTemplate 为什么是 JSON

java 复制代码
restTemplate.setMessageConverters(buildMessageConverters());

buildMessageConverters() 只返回 [StringHttpMessageConverter, MappingJackson2HttpMessageConverter]直接替换掉了默认列表 。XML/CBOR 转换器都不存在 → 只能选 JSON → Content-Type: application/json

这就是为什么生产环境(balancedRestTemplateSwitch=true)一直没事,一切到直连模式就炸。

四、为什么拦截器没救场

项目里有个 RequestHeaderInterceptor,第 27 行明明强制设置了 Content-Type:

java 复制代码
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                    ClientHttpRequestExecution execution) {
    HttpHeaders headers = request.getHeaders();
    headers.set("X-Request-Id", UUID.randomUUID().toString());
    headers.set("Content-Type", "application/json;charset=UTF-8");
    ...
}

plainRestTemplate 压根没注册这个拦截器,所以它不生效。即便注册了,也救不了------核心是执行时序:

复制代码
postForEntity
  → HttpEntityRequestCallback.doWithRequest()   ① 选转换器 + 序列化 body + 写 Content-Type
  → request.execute()                            ② 进入 InterceptingClientHttpRequest
  → RequestHeaderInterceptor.intercept()         ③ 此时 body 已是字节,只能改 header 值
  → 真正发送

① 这一步 在拦截器之前执行。body 已经被 XML 转换器序列化成字节流了。③ 这一步 即便把 Content-Type 改成 application/json,body 里那串 XML 字节也不会变成 JSON------只会造成"Header 说是 JSON,body 实际是 XML"的更隐蔽 bug。

结论:拦截器无法影响"用哪个转换器序列化 body",它来得太晚。

五、那个我没注册的拦截器,哪来的

排查过程中发现一条不是我写的日志:

复制代码
com.yumchina.architecture.framework.starter.web.interceptor.client
  .PrintReqResponseLog4ClientInterceptor handlerRequest 84 - Http Request | ...

项目代码里全局搜索 PrintReqResponseLog4ClientInterceptor,零匹配。它是公司框架 starter 自动注册的。链路如下:

1. Starter 的 META-INF/spring.factories

复制代码
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.yumchina.architecture.framework.starter.web.config.YumWebAutoConfiguration,\
  com.yumchina.architecture.framework.starter.web.config.YumRestTemplateConfiguration,\
  ...

Spring Boot 启动时自动加载 YumRestTemplateConfiguration,无需手动注册。

2. YumRestTemplateConfiguration 注册了一个 BeanPostProcessor

java 复制代码
@Bean
public YumRestTemplateBeanPostProcessor yumRestTemplateBeanPostProcessor() { ... }

3. YumRestTemplateBeanPostProcessor 拦截所有 RestTemplate Bean

它的 postProcessAfterInitialization 会判断 bean instanceof RestTemplate,然后统一增强------典型做法就是往 restTemplate.getInterceptors() 里追加 PrintReqResponseLog4ClientInterceptor

也就是说,容器里每一个 RestTemplate 类型的 Bean(包括 plainRestTemplatebalancedRestTemplate)在初始化完成后,都会被框架统一挂上日志拦截器。这是 Spring Boot 生态里"框架统一增强所有 RestTemplate"的标准套路。

这条拦截器打印的日志,反而成了我们验证修复是否生效的关键证据。

六、服务端为什么是 Connection reset 而不是 400

OR-Tools 的 /solve 服务默认只声明收 JSON。收到 133KB 的 application/xml body 时:

  • 要么解析层在读完 header 后就拒绝,直接 close socket
  • 要么网关/反向代理(nginx/ingress)在 Content-Type 校验阶段就 reset

这两种都不会走完 HTTP 协议正常返回 4xx ,而是直接断 TCP。客户端看到的就是 java.net.SocketException: Connection reset,而不是 400 Bad Request

这也解释了为什么请求只发了 80ms 就被 reset------根本没到业务逻辑,是入口层就拒了。

七、根因复盘

整个事故是三个因素叠加的结果:

原因
编码 plainRestTemplate 偷懒省了 setMessageConverters,与 balancedRestTemplate 配置不对称
框架 Spring 默认转换器列表里 XML 排在 JSON 前,且 classpath 误带了 jackson-dataformat-xml
触发 balancedRestTemplateSwitch=false 切到直连分支,潜伏 bug 被激活

RestTemplateConfig.java 是 5 天前一次"代码重构"提交里新建的,plainRestTemplate 的写法从创建那一刻起就是一行 return new RestTemplate(buildRequestFactory());。平时走负载均衡分支(balancedRestTemplateSwitch=true)一直没事,bug 潜伏了 5 天,直到开发联调切到直连模式才复现。

这是典型的"配置不对称"缺陷 :两个做同一类事情的 Bean,一个严一个松。开发者把 plainRestTemplate 当成"开发环境凑合用的简化版",以为用默认配置就够了,于是省掉了转换器和拦截器。

八、修复方案与验证

8.1 根因修复:给 plainRestTemplate 补齐配置

java 复制代码
@Bean("plainRestTemplate")
public RestTemplate plainRestTemplate() {
    RestTemplate restTemplate = new RestTemplate(buildRequestFactory());
    // 配置消息转换器:支持 JSON(与 balancedRestTemplate 保持一致)
    restTemplate.setMessageConverters(buildMessageConverters());
    // 配置请求拦截器(统一添加 Header,强制 Content-Type 为 application/json)
    restTemplate.setInterceptors(Collections.singletonList(new RequestHeaderInterceptor()));
    return restTemplate;
}

8.2 纵深防御:postForORModel 显式声明 Content-Type

java 复制代码
public static Object postForORModel(RestTemplate restTemplate, String url,
                                    Object requestEntity) throws Exception {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
    HttpEntity<Object> httpEntity = new HttpEntity<>(requestEntity, headers);
    ResponseEntity<RespOutput> resp =
        restTemplate.postForEntity(url, httpEntity, RespOutput.class);
    ...
}

这是在转换器选择阶段就告诉 RestTemplate "我要 JSON",让 canWrite(type, application/json) 的兼容性检查把 XML 转换器筛掉。即便以后有人新建 RestTemplate 忘了配转换器,也不会再误发 XML。

8.3 验证

修复后同一拦截器打印的日志:

复制代码
Headers = {
  Accept=[application/json, application/*+json],
  X-Request-Id=[a61cf377-6a35-42e4-95e4-d4c540436743],
  Content-Length=[66438],
  Content-Type=[application/json;charset=UTF-8]
}
字段 修复前 修复后
Content-Type application/xml;charset=UTF-8 application/json;charset=UTF-8
Accept application/xml, text/xml, application/json, application/cbor, ... application/json, application/*+json
Content-Length 133857 66438
X-Request-Id a61cf377-...
  • Accept 只剩 JSON → XML/CBOR 转换器已被裁掉
  • Content-Type 是 JSON → 转换器选择阶段命中了 Jackson
  • X-Request-Id 出现 → RequestHeaderInterceptor 也已挂上
  • Content-Length 从 133KB 降到 66KB → 正是 XML→JSON 的体积差

修复完全生效。

九、补充:buildMessageConverters 的作用

修复后有人问:既然显式声明 Content-Type 就能解决 reset,为什么还要补 buildMessageConverters()?它解决的不是这次事故,而是衍生问题:

9.1 防止其他调用点裸传 POJO

postForORModel 现在显式声明了 Content-Type,但 plainRestTemplate 可能被其他地方复用。如果某个调用点像原来的 postForORModel 那样裸传 POJO(没 HttpEntity、没 Content-Type),mediaType == null 分支又会触发 XML 截胡。裁掉 XML 转换器后,无论调用点是否声明 Content-Type,都不可能命中 XML

9.2 清理 Accept 头

默认列表里所有转换器的 supportedMediaTypes 都会被拼进 Accept 头:

复制代码
修复前  Accept=[application/xml, text/xml, application/json, application/cbor, ...]
修复后  Accept=[application/json, application/*+json]
  • 严格的服务端会按 Accept 做 content negotiation,可能返回 406
  • 日志暴露内部依赖(能看到 classpath 里有 jackson-dataformat-xml/cbor)
  • 网关/WAF 可能对异常 Accept 做拦截

9.3 配置对称

避免"一个 Bean 严、一个 Bean 松"的认知陷阱。配置对称后,两个 Bean 行为只差 @LoadBalanced,维护时心智负担小。

9.4 Accept 的拼接机制

RestTemplate 内部走 AcceptHeaderRequestCallback.doWithRequest

复制代码
针对响应类型 RespOutput.class:
  遍历 messageConverters 列表
    对每个 converter 调 canRead(RespOutput.class, null)
      返回 true 的,把它的 getSupportedMediaTypes() 累加进 Accept
  最后 setAccept(累加结果)

buildMessageConverters() 返回的两个转换器:

转换器 supportedMediaTypes supports(RespOutput.class) 是否进 Accept
StringHttpMessageConverter [text/plain, */*] clazz == String.class → false
MappingJackson2HttpMessageConverter [application/json, application/*+json] true

String 转换器只认 String.classRespOutput 不是 String,被跳过 → text/plain*/* 不进 Accept。Jackson JSON 转换器对任意 POJO 返回 true → 它的 [application/json, application/*+json] 进 Accept。

XML/CBOR 转换器根本不在列表里,所以它们的 MediaType 自然消失。

一句话:Accept 头 = 所有"能反序列化响应类型的转换器"的 supportedMediaTypes 之和。

十一、彩蛋:为什么旧分支 feature/qianwen-x-K 没出问题

排查到根因后,一个自然的疑问是:postForORModel 裸传 POJO 的写法在重构前就存在了,为什么旧分支 feature/qianwen-x-K 一直没事?

对比两个分支的代码:

  • feature/qianwen-x-KScoreToolMethod@Autowired private RestTemplate restTemplate;(按类型注入,无 @Qualifier,无开关),Bean 定义在 HttpClientConfig.java:50
  • 重构后@Qualifier("plainRestTemplate") + balancedRestTemplateSwitch 开关,Bean 定义在 RestTemplateConfig.java

两个分支的 postForORModel 写法完全一致,都是裸传 POJO、没声明 Content-Type。差异在 RestTemplate Bean 的创建方式上。

11.1 旧分支的 RestTemplate 创建方式

java 复制代码
// feature/qianwen-x-K: HttpClientConfig.java
@Component
public class HttpClientConfig {

    @Autowired
    RestTemplateBuilder restTemplateBuilder;   // ← Spring Boot 注入

    @Bean
    public RestTemplate restTemplate() {
        RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(socketTimeout)
                .setConnectionRequestTimeout(connectionRequestTimeout)
                .setConnectTimeout(connectTimeout)
                .build();

        CloseableHttpClient httpClient = HttpClientBuilder.create()
                .setDefaultRequestConfig(requestConfig)
                .setMaxConnTotal(maxConnTotal)
                .setMaxConnPerRoute(maxConnTotal)
                .build();
        ClientHttpRequestFactory clientHttpRequestFactory =
                new HttpComponentsClientHttpRequestFactory(httpClient);

        return restTemplateBuilder.requestFactory(() -> clientHttpRequestFactory)
                .build();   // ← 关键:走 RestTemplateBuilder
    }
}

11.2 重构后的 RestTemplate 创建方式

java 复制代码
// 重构后: RestTemplateConfig.java
@Bean("plainRestTemplate")
public RestTemplate plainRestTemplate() {
    return new RestTemplate(buildRequestFactory());  // ← 直接 new,绕开 Builder
}

11.3 决定性差异:转换器来源不同

feature/qianwen-x-K(没出问题) 重构后 plainRestTemplate(出问题)
创建方式 restTemplateBuilder.build() new RestTemplate(buildRequestFactory())
转换器来源 Spring Boot 的 HttpMessageConverters Bean RestTemplate 构造器硬编码列表
XML 转换器 不存在 存在,且排在 JSON 前

11.4 为什么 RestTemplateBuilder.build() 不会出问题

RestTemplateBuilder.build() 内部会去拿 Spring Boot 自动装配的 HttpMessageConverters Bean。这个 Bean 由 HttpMessageConvertersAutoConfiguration 创建,它只显式注册这些转换器

  • ByteArrayHttpMessageConverter
  • StringHttpMessageConverter
  • ResourceHttpMessageConverter
  • MappingJackson2HttpMessageConverter(JSON)
  • (Gson / JSON-B,视依赖而定)

关键:Spring Boot 的自动装配从不注册 MappingJackson2XmlHttpMessageConverter 这个 Bean ,哪怕 classpath 上有 jackson-dataformat-xml。所以 RestTemplateBuilder 组装出来的 RestTemplate,转换器列表里根本没有 XML 转换器。POJO 裸传进去,只能命中 Jackson JSON 转换器 → 序列化成 JSON。

11.5 为什么 new RestTemplate() 会出问题

new RestTemplate() 无参构造器绕开 Spring Boot 的 HttpMessageConverters,自己硬编码装配转换器,直接探测 classpath:

java 复制代码
// RestTemplate 源码(Spring Framework)
public RestTemplate() {
    this.messageConverters = new ArrayList<>();
    this.messageConverters.add(new ByteArrayHttpMessageConverter());
    this.messageConverters.add(new StringHttpMessageConverter());
    this.messageConverters.add(new ResourceHttpMessageConverter());
    ...
    if (jackson2XmlPresent) {
        this.messageConverters.add(
            new MappingJackson2XmlHttpMessageConverter());   // XML 先加
    }
    if (jackson2Present) {
        this.messageConverters.add(
            new MappingJackson2HttpMessageConverter());      // JSON 后加
    }
    ...
}

classpath 有 jackson-dataformat-xml(通过 BOM 传递进来)→ jackson2XmlPresent = trueXML 转换器被加入列表,且排在 JSON 前面 。POJO 裸传进去,canWrite(type, null) 命中第一个 → XML 转换器截胡。

11.6 一句话总结

feature/qianwen-x-KRestTemplateBuilder.build(),走 Spring Boot 自动装配的 HttpMessageConverters不含 XML 转换器 ,所以裸传 POJO 也只能序列化成 JSON;重构后的 plainRestTemplate 改用 new RestTemplate(),绕开了自动装配,构造器直接探测 classpath 把 XML 转换器加进列表且排在 JSON 前,裸传 POJO 就被 XML 截胡。

11.7 这是一次典型的"重构引入回归"

重构提交(569c3a5,7-15 18:11)把 RestTemplate 的创建方式从 RestTemplateBuilder.build() 改成了 new RestTemplate(),意图是手动控制配置(转换器、拦截器、超时):

  • balancedRestTemplate 改完后显式调了 setMessageConverters(buildMessageConverters()) 把 XML 转换器裁掉 → 没事
  • plainRestTemplate 漏了这一步 → new RestTemplate() 的默认列表里 XML 转换器就冒出来了 → 埋雷

隐含的教训RestTemplateBuilder.build()new RestTemplate() 不是等价的------

  • 前者 受 Spring Boot 自动装配庇护,HttpMessageConverters Bean 默认不含 XML 转换器
  • 后者暴露 Spring Framework 的原生默认行为,构造器直接探测 classpath,XML 转换器会被加入

从前者迁到后者时,必须显式处理转换器列表(调 setMessageConverters(...) 裁剪),否则会引入"原来没有的新行为"。更稳妥的做法是重构时保留 RestTemplateBuilder 的使用 ,只在它的基础上 .requestFactory(...).setConnectTimeout(...) 增量配置,而不是推倒重来用 new RestTemplate()


十二、总结与启示

技术启示

  1. new RestTemplate() 的默认转换器列表有坑 :XML 排在 JSON 前面,classpath 有 jackson-dataformat-xml 就会触发 POJO 被序列化成 XML。
  2. 拦截器无法影响转换器选择:它在 body 序列化之后才执行,改 header 救不了 body。
  3. 显式声明 Content-Type 是最可靠的兜底 :它让 canWrite 走兼容性检查分支,把不兼容的转换器筛掉。
  4. 框架的自动装配会统一增强所有 RestTemplate:日志拦截器、指标埋点都是这么挂上的,不需要手动注册。

工程启示

  1. 配置要对称 :两个做同一类事情的 Bean,应该共享同一套基础配置,只差必要的差异(如 @LoadBalanced)。
  2. 开发环境的"简化版"最容易埋雷:生产环境走完整配置一直没事,一切到简化配置就炸。bug 不会因为"这是开发环境"就消失,只会延迟爆发。
  3. 潜伏期 = 切换条件触发时间:这次 bug 潜伏了 5 天,直到切到直连模式才复现。任何有开关的代码路径,都需要在两条路径上都验证过。
  4. 日志是最好的验证工具 :框架自动装配的 PrintReqResponseLog4ClientInterceptor 帮我们直接看到了请求头,对比修复前后的 Header 变化,修复是否生效一目了然。

一行话根因

plainRestTemplate 没配 JSON 转换器,用了 Spring 默认列表;默认列表里 XML 转换器排在 JSON 前面;postForORModel 裸传 POJO 没声明 Content-Type;于是请求体被 XML 转换器截胡,服务端拒收 XML 直接 reset 连接。


相关推荐
云烟成雨TD1 小时前
Micrometer 系列【2】可观测性:指标(Metrics)
java·云原生·micrometer
2501_914245931 小时前
Java应用性能调优与生产监控部署:2026年实战手册
java·开发语言
吴声子夜歌1 小时前
MongoDB 4.x——微服务入门
java·mongodb·微服务
好好沉淀1 小时前
主键选择(自增 vs UUID vs 雪花算法)
java·算法
带刺的坐椅2 小时前
Solon AI:Tool 与 Talent 怎么选?从函数到领域专家
java·ai·llm·agent·solon
小白说大模型2 小时前
AI Agent 调试实战:链路追踪、Prompt 可视化与异常定位的系统方法
java·人工智能·python·算法·prompt
静水楼台x2 小时前
spring security
java·数据库·spring
重生之我是Java开发战士2 小时前
【Java EE】Spring Boot配置文件
java·spring boot·java-ee
Tim_102 小时前
【C++】019、内存泄漏
java·开发语言