一次由 RestTemplate 引发的 Connection reset:被 XML 截胡的请求体
一行
balancedRestTemplateSwitch=false把开关切到直连模式后,OR-Tools 服务立刻Connection reset。排查绕了 Content-Type、转换器、拦截器、自动装配一圈,最后发现根因藏在 SpringRestTemplate默认转换器列表的排序里。本文完整记录这次定位过程。
一、现象
切换到开发环境直连模式(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] }两个异常点:
- Content-Type 居然是
application/xml—— 我们明明想发 JSON - Content-Length 133857—— 比正常 JSON 请求大了一倍多
- Connection reset,不是
SocketTimeoutException,也不是 4xx —— 说明连接在协议层就被断开了,没走到业务逻辑
二、调用链路定位
先看代码结构。调用 OR-Tools 的入口是:
// RestTemplateUtils.javapublicstaticObjectpostForORModel(RestTemplaterestTemplate,Stringurl,ObjectrequestEntity)throwsException{ResponseEntity<RespOutput>resp=restTemplate.postForEntity(url,requestEntity,RespOutput.class);...}注意:requestEntity是裸的ReqInputPOJO,没有被HttpEntity包裹,没有显式设置 Content-Type。
RestTemplate 的来源:
// ScoreToolMethod.java@Value("${balancedRestTemplateSwitch:true}")privatebooleanbalancedRestTemplateSwitch;publicRestTemplategetActiveRestTemplate(){returnbalancedRestTemplateSwitch?balancedRestTemplate:restTemplate;}两个 Bean 的定义:
// RestTemplateConfig.java@Bean("balancedRestTemplate")@LoadBalancedpublicRestTemplateloadBalancedRestTemplate(){RestTemplaterestTemplate=newRestTemplate();restTemplate.setMessageConverters(buildMessageConverters());// ← 配了 JSON 转换器restTemplate.setInterceptors(Collections.singletonList(newRequestHeaderInterceptor()));restTemplate.setRequestFactory(buildRequestFactory());returnrestTemplate;}@Bean("plainRestTemplate")publicRestTemplateplainRestTemplate(){returnnewRestTemplate(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里选转换器:
// `AbstractHttpMessageConverter.canWrite`protectedbooleancanWrite(MediaTypemediaType){if(mediaType==null||getSupportedMediaTypes().isEmpty()){returntrue;// ← 关键分支}for(MediaTypesupported:getSupportedMediaTypes()){if(supported.isCompatibleWith(mediaType)){returntrue;}}returnfalse;}当请求体是 POJO 且没有显式声明 Content-Type时,mediaType == null,canWrite直接返回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/xml、text/xml、application/*+xml证明MappingJackson2XmlHttpMessageConverter已注册;出现application/cbor证明MappingJackson2CborHttpMessageConverter也在。这两个依赖是通过公司内部 BOMyumchina-spring-boot-dependencies传递进来的,开发者在写plainRestTemplate时根本没意识到它们的存在。
3.4 对比 balancedRestTemplate 为什么是 JSON
restTemplate.setMessageConverters(buildMessageConverters());buildMessageConverters()只返回[StringHttpMessageConverter, MappingJackson2HttpMessageConverter],直接替换掉了默认列表。XML/CBOR 转换器都不存在 → 只能选 JSON →Content-Type: application/json。
这就是为什么生产环境(balancedRestTemplateSwitch=true)一直没事,一切到直连模式就炸。
四、为什么拦截器没救场
项目里有个RequestHeaderInterceptor,第 27 行明明强制设置了 Content-Type:
publicClientHttpResponseintercept(HttpRequestrequest,byte[]body,ClientHttpRequestExecutionexecution){HttpHeadersheaders=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
@BeanpublicYumRestTemplateBeanPostProcessoryumRestTemplateBeanPostProcessor(){...}3.YumRestTemplateBeanPostProcessor拦截所有RestTemplateBean
它的postProcessAfterInitialization会判断bean instanceof RestTemplate,然后统一增强——典型做法就是往restTemplate.getInterceptors()里追加PrintReqResponseLog4ClientInterceptor。
也就是说,容器里每一个RestTemplate类型的 Bean(包括plainRestTemplate和balancedRestTemplate)在初始化完成后,都会被框架统一挂上日志拦截器。这是 Spring Boot 生态里“框架统一增强所有 RestTemplate”的标准套路。
这条拦截器打印的日志,反而成了我们验证修复是否生效的关键证据。
六、服务端为什么是 Connection reset 而不是 400
OR-Tools 的/solve服务默认只声明收 JSON。收到 133KB 的application/xmlbody 时:
- 要么解析层在读完 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 补齐配置
@Bean("plainRestTemplate")publicRestTemplateplainRestTemplate(){RestTemplaterestTemplate=newRestTemplate(buildRequestFactory());// 配置消息转换器:支持 JSON(与 balancedRestTemplate 保持一致)restTemplate.setMessageConverters(buildMessageConverters());// 配置请求拦截器(统一添加 Header,强制 Content-Type 为 application/json)restTemplate.setInterceptors(Collections.singletonList(newRequestHeaderInterceptor()));returnrestTemplate;}8.2 纵深防御:postForORModel 显式声明 Content-Type
publicstaticObjectpostForORModel(RestTemplaterestTemplate,Stringurl,ObjectrequestEntity)throwsException{HttpHeadersheaders=newHttpHeaders();headers.setContentType(MediaType.APPLICATION_JSON_UTF8);HttpEntity<Object>httpEntity=newHttpEntity<>(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.class,RespOutput不是 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-K:ScoreToolMethod里@Autowired private RestTemplate restTemplate;(按类型注入,无@Qualifier,无开关),Bean 定义在HttpClientConfig.java:50- 重构后:
@Qualifier("plainRestTemplate")+balancedRestTemplateSwitch开关,Bean 定义在RestTemplateConfig.java
两个分支的postForORModel写法完全一致,都是裸传 POJO、没声明 Content-Type。差异在 RestTemplate Bean 的创建方式上。
11.1 旧分支的 RestTemplate 创建方式
// feature/qianwen-x-K: HttpClientConfig.java@ComponentpublicclassHttpClientConfig{@AutowiredRestTemplateBuilderrestTemplateBuilder;// ← Spring Boot 注入@BeanpublicRestTemplaterestTemplate(){RequestConfigrequestConfig=RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectionRequestTimeout(connectionRequestTimeout).setConnectTimeout(connectTimeout).build();CloseableHttpClienthttpClient=HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).setMaxConnTotal(maxConnTotal).setMaxConnPerRoute(maxConnTotal).build();ClientHttpRequestFactoryclientHttpRequestFactory=newHttpComponentsClientHttpRequestFactory(httpClient);returnrestTemplateBuilder.requestFactory(()->clientHttpRequestFactory).build();// ← 关键:走 RestTemplateBuilder}}11.2 重构后的 RestTemplate 创建方式
// 重构后: RestTemplateConfig.java@Bean("plainRestTemplate")publicRestTemplateplainRestTemplate(){returnnewRestTemplate(buildRequestFactory());// ← 直接 new,绕开 Builder}11.3 决定性差异:转换器来源不同
feature/qianwen-x-K(没出问题) | 重构后plainRestTemplate(出问题) | |
|---|---|---|
| 创建方式 | restTemplateBuilder.build() | new RestTemplate(buildRequestFactory()) |
| 转换器来源 | Spring Boot 的HttpMessageConvertersBean | RestTemplate构造器硬编码列表 |
| XML 转换器 | 不存在 | 存在,且排在 JSON 前 |
11.4 为什么RestTemplateBuilder.build()不会出问题
RestTemplateBuilder.build()内部会去拿 Spring Boot 自动装配的HttpMessageConvertersBean。这个 Bean 由HttpMessageConvertersAutoConfiguration创建,它只显式注册这些转换器:
ByteArrayHttpMessageConverterStringHttpMessageConverterResourceHttpMessageConverterMappingJackson2HttpMessageConverter(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:
// RestTemplate 源码(Spring Framework)publicRestTemplate(){this.messageConverters=newArrayList<>();this.messageConverters.add(newByteArrayHttpMessageConverter());this.messageConverters.add(newStringHttpMessageConverter());this.messageConverters.add(newResourceHttpMessageConverter());...if(jackson2XmlPresent){this.messageConverters.add(newMappingJackson2XmlHttpMessageConverter());// XML 先加}if(jackson2Present){this.messageConverters.add(newMappingJackson2HttpMessageConverter());// JSON 后加}...}classpath 有jackson-dataformat-xml(通过 BOM 传递进来)→jackson2XmlPresent = true→XML 转换器被加入列表,且排在 JSON 前面。POJO 裸传进去,canWrite(type, null)命中第一个 → XML 转换器截胡。
11.6 一句话总结
feature/qianwen-x-K用RestTemplateBuilder.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 自动装配庇护,
HttpMessageConvertersBean 默认不含 XML 转换器 - 后者暴露 Spring Framework 的原生默认行为,构造器直接探测 classpath,XML 转换器会被加入
从前者迁到后者时,必须显式处理转换器列表(调setMessageConverters(...)裁剪),否则会引入“原来没有的新行为”。更稳妥的做法是重构时保留RestTemplateBuilder的使用,只在它的基础上.requestFactory(...)、.setConnectTimeout(...)增量配置,而不是推倒重来用new RestTemplate()。
十二、总结与启示
技术启示
new RestTemplate()的默认转换器列表有坑:XML 排在 JSON 前面,classpath 有jackson-dataformat-xml就会触发 POJO 被序列化成 XML。- 拦截器无法影响转换器选择:它在 body 序列化之后才执行,改 header 救不了 body。
- 显式声明 Content-Type 是最可靠的兜底:它让
canWrite走兼容性检查分支,把不兼容的转换器筛掉。 - 框架的自动装配会统一增强所有 RestTemplate:日志拦截器、指标埋点都是这么挂上的,不需要手动注册。
工程启示
- 配置要对称:两个做同一类事情的 Bean,应该共享同一套基础配置,只差必要的差异(如
@LoadBalanced)。 - 开发环境的“简化版”最容易埋雷:生产环境走完整配置一直没事,一切到简化配置就炸。bug 不会因为“这是开发环境”就消失,只会延迟爆发。
- 潜伏期 = 切换条件触发时间:这次 bug 潜伏了 5 天,直到切到直连模式才复现。任何有开关的代码路径,都需要在两条路径上都验证过。
- 日志是最好的验证工具:框架自动装配的
PrintReqResponseLog4ClientInterceptor帮我们直接看到了请求头,对比修复前后的 Header 变化,修复是否生效一目了然。
一行话根因
plainRestTemplate没配 JSON 转换器,用了 Spring 默认列表;默认列表里 XML 转换器排在 JSON 前面;postForORModel裸传 POJO 没声明 Content-Type;于是请求体被 XML 转换器截胡,服务端拒收 XML 直接 reset 连接。