博客主页:https://tomcat.blog.csdn.net
博主昵称:农民工老王
主要领域:Java、Linux、K8S
期待大家的关注💖点赞👍收藏⭐留言💬
目录
-
- 一、问题现象
- 二、排查思路:从"差异对比"入手
- 三、关键转折:把怀疑从"业务头"转向"隐藏头"
- [四、最小复现(Spring Boot 示例)](#四、最小复现(Spring Boot 示例))
-
- [4.1 模拟后端(被代理的服务)](#4.1 模拟后端(被代理的服务))
- [4.2 模拟反向代理(用 HttpClient 转发)](#4.2 模拟反向代理(用 HttpClient 转发))
- [4.3 验证 JDK 自动追加 Content-Length:0](#4.3 验证 JDK 自动追加 Content-Length:0)
- 五、根因分析
- 六、修复方案
- [七、延伸讨论:升级 JDK 能否"免改代码"解决?](#七、延伸讨论:升级 JDK 能否"免改代码"解决?)
- 八、经验总结
- [九、回显探针:看清 JDK 自动追加的"隐藏头"](#九、回显探针:看清 JDK 自动追加的"隐藏头")
-
- [9.1 它是什么,不是什么](#9.1 它是什么,不是什么)
- [9.2 最小实现](#9.2 最小实现)
- [9.3 为什么这个 bug 非它不可](#9.3 为什么这个 bug 非它不可)
- [9.4 请求流转与回显探针的位置](#9.4 请求流转与回显探针的位置)
- 附:复现步骤速查
一、问题现象
一个部署在反向代理(Proxy)之后的后端服务,大部分接口工作正常,但某类"查询自身信息"的接口(下文以 /api/wjatest/self 代称)频繁报错:
- 绕过代理、直连后端 :
GET http://backend:8081/api/wjatest/self?f=json→ 返回200,正常。 - 经 Proxy 访问 :
GET http://proxy.example.com/proxy/wjatest/self?f=json→ 返回500 {"message":"Unknown error","status":500}。
这个 500 的 body 不是后端标准的 REST 错误格式({"error":{...}}),而是应用层未捕获异常的兜底输出,说明后端侧一定抛了异常,且日志里能查到堆栈。
现象里的两个特征很关键:"大部分接口正常、仅个别接口偶发 500",以及**"直连正常、经代理就挂"**。这从一开始就框定了排查方向------问题大概率出在"代理对请求的加工"上,而不是后端业务逻辑。
二、排查思路:从"差异对比"入手
代理类故障,第一步永远是逐字节对比"代理请求"与"直连请求"到底差了什么。
我们把三个请求放在一起比对(浏览器原始请求、Proxy 实际发出的代理请求、直连后端的请求),得到四组差异:
| # | 差异项 | 代理请求(失败) | 直连请求(成功) | 初步判断 |
|---|---|---|---|---|
| 1 | Host 头 | 全大写 BACKEND.EXAMPLE.COM:8081 |
小写 backend.example.com:8081 |
可疑 |
| 2 | 附加头 | 多出 X-Forwarded-*、X-Proxy-Request-Url 等 |
无 | 可疑 |
| 3 | 是否带 cookie / referer | 带 | 不带 | 中性 |
| 4 | token | 两个不同的 token | 另一个 token | 对照不严格 |
此时最大的坑:直连验证用的是"另一个 token",它只能证明"后端健康",不能证明"同一个 token 直连也成功"。所以一开始所有结论都是"疑似",需要闭环验证。
三、关键转折:把怀疑从"业务头"转向"隐藏头"
我们做了几个对照实验(用 Python 脚本直连后端端口,精确控制每个头):
- 复刻代理请求的全部显式头 (大写 Host、若干
X-Forwarded-*、cookie、referer)→ 后端返回200。 - 经真实代理链路 访问同一接口 → 稳定
500。 - 经代理注入"补发
X-Proxy-Request-Url/ 补发X-Forwarded-Host/ 去掉 cookie / 去掉 referer" → 仍然500。
结论浮现:分水岭不在任何"被日志记录到的头"。所有被我们显式设置、能在审计日志里看到的头,单拆出来都不会触发故障。
那还有谁?------JDK 自动附加、不出现在 request.headers() 里的头。
我们转向验证 Content-Length。经验假设:用 HttpRequest.BodyPublishers.noBody() 转发 GET 时,JDK 会补 Content-Length: 0。于是做最小复现:
bash
# 直连后端,仅加这两个头,不带任何代理的 X-Forwarded-*:
curl "http://backend:8081/api/wjatest/self?f=json&token=xxx" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Content-Length: 0"
# -> 返回 500 {"message":"Unknown error","status":500}
再拆变量:
- 仅
Content-Length: 0、无Content-Type→200 - 仅
Content-Type、无Content-Length: 0→200 - 两者同时出现 →
500✅
触发条件精确定义为:GET + Content-Length: 0 + Content-Type 三者同时成立。
最后用本地回显服务器(请求回显探针,注意:它并非网卡抓包工具 ,只是被动接收并打印客户端真正发出的请求头)抓住 JDK 17 HttpClient 实际发出的报文,实锤:
GET /test/path?f=json HTTP/1.1
Content-Length: 0 <-- JDK 自动追加,且不出现在 request.headers()
Host: 127.0.0.1:18099
Accept: */*
Content-Type: application/x-www-form-urlencoded <-- 原样转发的浏览器头
至此证据链闭合:故障是"JDK 自动加的 Content-Length: 0" + "前端带来的 Content-Type"组合所致,与域名、Host 大小写、cookie、referer、token 绑定全部无关。
四、最小复现(Spring Boot 示例)
为了把问题脱敏、可复现地讲清楚,下面用 Spring Boot 写两个最小应用:后端 (被代理服务,含缺陷接口)和代理 (用 HttpClient 转发)。所有 URI 均为自拟示例。
4.1 模拟后端(被代理的服务)
对应真实场景里被代理的后端服务。这里在 /api/wjatest/self 上复现"GET 同时携带 Content-Length:0 与 Content-Type 时返回 500"的缺陷。
java
package com.example.demoapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
@RestController
@RequestMapping("/api")
class WjatestController {
@GetMapping("/wjatest/self")
public ResponseEntity<Map<String, Object>> wjatestSelf(HttpServletRequest request) {
String contentLength = request.getHeader("Content-Length");
String contentType = request.getHeader("Content-Type");
// 复现后端缺陷:GET 请求同时携带 Content-Length:0 与 Content-Type 时返回 500
if ("0".equals(contentLength) && contentType != null && !contentType.isEmpty()) {
Map<String, Object> err = new LinkedHashMap<>();
err.put("message", "Unknown error");
err.put("status", 500);
return ResponseEntity.status(500).body(err);
}
Map<String, Object> ok = new LinkedHashMap<>();
ok.put("id", "self");
ok.put("name", "Demo Backend");
ok.put("url", "https://demo.example.com");
return ResponseEntity.ok(ok);
}
}
4.2 模拟反向代理(用 HttpClient 转发)
对应 Proxy 组件。把 /proxy/** 转发到后端 /api/**。注意代码中修复点已标注 :无实体请求不转发 Content-Type。
java
package com.example.proxy;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Set;
@SpringBootApplication
public class ProxyApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyApplication.class, args);
}
}
@RestController
class ProxyController {
private final HttpClient client = HttpClient.newHttpClient();
private static final Set<String> HOP_BY_HOP = Set.of(
"host", "connection", "content-length", "transfer-encoding",
"keep-alive", "te", "trailer", "upgrade");
@RequestMapping(value = "/proxy/**", method = {
org.springframework.web.bind.annotation.RequestMethod.GET,
org.springframework.web.bind.annotation.RequestMethod.POST,
org.springframework.web.bind.annotation.RequestMethod.HEAD,
org.springframework.web.bind.annotation.RequestMethod.DELETE})
public ResponseEntity<String> proxy(HttpServletRequest request) throws Exception {
String uri = request.getRequestURI();
String target = "http://localhost:8081" + uri.replaceFirst("/proxy", "/api");
String method = request.getMethod();
boolean hasBody = request.getContentLength() > 0
&& ("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method));
HttpRequest.BodyPublisher body = hasBody
? HttpRequest.BodyPublishers.ofByteArray(readBody(request))
: HttpRequest.BodyPublishers.noBody(); // 无实体请求:JDK 自动追加 Content-Length:0
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
.method(method, body);
// ===== 修复点:无实体请求不转发 Content-Type =====
boolean bodyless = !hasBody;
Enumeration<String> names = request.getHeaderNames();
while (names.hasMoreElements()) {
String h = names.nextElement();
if (h == null || HOP_BY_HOP.contains(h.toLowerCase(Locale.ROOT))) {
continue;
}
// 无 body 时 Content-Type 无语义;转发它会与 JDK 自动加的 Content-Length:0 组合,触发后端缺陷
if (bodyless && "content-type".equalsIgnoreCase(h)) {
continue;
}
builder.header(h, request.getHeader(h));
}
HttpRequest req = builder.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
return ResponseEntity.status(resp.statusCode()).body(resp.body());
}
private byte[] readBody(HttpServletRequest request) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = request.getInputStream().read(buf)) != -1) {
bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}
4.3 验证 JDK 自动追加 Content-Length:0
一行 Java 即可确认(用本地回显服务器(请求回显探针)抓取实际发出报文):
java
import com.sun.net.httpserver.*;
import java.net.*;
import java.net.http.*;
import java.util.concurrent.Executors;
public class JdkHeaderProbe {
public static void main(String[] args) throws Exception {
var server = HttpServer.create(new InetSocketAddress(18099), 0);
server.createContext("/", ex -> {
System.out.println(ex.getRequestMethod() + " " + ex.getRequestURI());
ex.getRequestHeaders().forEach((k, v) -> System.out.println(k + ": " + v));
ex.sendResponseHeaders(200, -1);
});
server.setExecutor(Executors.newSingleThreadExecutor());
server.start();
HttpClient.newHttpClient()
.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:18099/test?f=json"))
.method("GET", HttpRequest.BodyPublishers.noBody())
.header("Content-Type", "application/x-www-form-urlencoded")
.build(),
HttpResponse.BodyHandlers.discarding());
server.stop(0);
}
}
输出第一行就是 Content-Length: 0------这正是"日志里看不到、但真实存在"的元凶。
五、根因分析
把整条链路串起来:
- 前端 SPA(如某些低代码页面搭建器)发出的
GET /proxy/wjatest/self带着Content-Type: application/x-www-form-urlencoded(XHR 的默认行为)。 - Proxy 用
java.net.http.HttpClient转发,对无实体的 GET 使用BodyPublishers.noBody()。 - JDK 自动为这个请求追加
Content-Length: 0(HTTP/1.1 规范允许,但多数客户端不会给 GET 加)。 Content-Type被原样转发,于是后端收到GET + Content-Length: 0 + Content-Type的非常规组合。- 后端在这类"查询自身信息"的接口上解析请求上下文时,对该组合处理不当,抛出未捕获异常 →
500。 - 由于
Content-Length: 0是 JDK 底层自动添加、不在request.headers()中,审计日志只记录"显式设置的头",所以排查初期完全看不到它,绕了大弯。
为什么只特定接口挂:这类接口会真正去解析/重建请求上下文(公网 URL、token 绑定等),恰好命中缺陷路径;绝大多数接口不读请求体,所以表现正常------这也解释了"大部分接口正常、仅个别接口偶发 500"的现象。
六、修复方案
代理侧(外科手术式,立即修复)
在头转发循环中,无实体请求跳过 Content-Type 。Content-Length 是 JDK 受限头无法手工抑制,只能从 Content-Type 侧规避:
java
boolean bodyless = !hasBody; // GET/HEAD/DELETE 且无内容
while (names.hasMoreElements()) {
String h = names.nextElement();
if (h == null || HOP_BY_HOP.contains(h.toLowerCase(Locale.ROOT))) continue;
if (bodyless && "content-type".equalsIgnoreCase(h)) continue; // 无 body 的 CT 无语义,剥离以规避 CL:0+CT 组合
builder.header(h, request.getHeader(h));
}
bodyless的判定必须与下游noBody()的实际使用条件严格对齐:POST/PUT 带实体时Content-Type照常转发,不影响上传、表单等请求。
后端侧(建议同步提单)
GET + Content-Length: 0 + Content-Type 是合法 HTTP 请求,后端应优雅处理而非 500。最小复现脚本(见 4.1/4.3)可直接作为 Bug 复现附件提交给后端团队。
七、延伸讨论:升级 JDK 能否"免改代码"解决?
排查过程中有智能体反馈:JDK 在 17.0.18 之前存在两个相关 Bug------JDK-8283544 与 JDK-8358942,升级到 17.0.18 及之后即可,不需要改代码。 但实测升级后问题依旧。原因在于这两个 Bug 修的是完全不同的 API 写法:
| Bug | 描述 | 修复的写法 | 修复版本 |
|---|---|---|---|
| JDK-8283544 | HttpClient 给 GET 请求加了 Content-Length: 0 |
仅 .GET() 便捷方法 |
JDK 19,并回移植到 17.0.18 |
| JDK-8358942 | .method("GET", BodyPublishers.noBody()) 仍被加 Content-Length: 0 |
.method("GET", noBody()) 这种写法 |
仅 JDK 26 |
关键差异在于:我们的代理代码用的是 reqBuilder.method(request.getMethod(), publisher),无实体时 publisher = noBody(),即 .method("GET", noBody())------这恰好是 JDK-8358942 的命中路径,而不是 .GET()。
证据来自 OpenJDK 官方记录:
- JDK-8283544 的官方描述明确写着 "Using
HttpRequest.newBuilder()to create a simpleGET()request",且它的回归测试在改成.method("GET", BodyPublishers.noBody())后仍会复现原问题------这正是 JDK-8358942 被单独提出来的原因。 - JDK-8358942 的发布说明(JDK-8369981)写明:仅当使用
method且方法非 POST/PUT、且BodyPublisher报告长度为 0 时才不再发送Content-Length,且该修复的 Fix Version 是 26。
换句话说,那位同事说的"17.0.18 修复"对应的是 8283544(.GET() 便捷方法),与本项目代码路径不是一回事 。要把本项目这个写法修掉,得升到 JDK 26,而不是 17.0.18。
本地实测佐证(用本地回显服务器(请求回显探针)抓取 .method("GET", noBody()) 实际发出的报文):
# 本地 JDK 17 与 JDK 25 实测:Content-length: [0] 仍然存在
GET /test?f=json HTTP/1.1
Content-type: [application/x-www-form-urlencoded]
Content-length: [0] <-- 17 和 25 都没修
JDK 25 都还带 Content-length: 0,而 8358942 只在 26 修复,那么 17.0.18(只含 8283544)自然也不会修这个路径------升级到 17.0.18 后仍然 500,完全符合预期。
结论:
- 想"只升级 JDK 不改代码",得升到 JDK 26,但把生产代理组件从 17 跳到 26 版本跨度大、回归风险高;
- 更重要的是,依赖"JDK 恰好不发 CL:0"很脆弱:一旦换回老 JDK、或容器/网关别处又写入
Content-Length,故障立刻复现; - 因此,第六节落地的代码修复(无实体请求不转发
Content-Type)才是版本无关、最稳妥的解法 :即便 JDK 仍发Content-Length: 0,只要没有Content-Type,后端就返回 200(我们已验证"仅 CL:0、无 CT → 200")。它与 JDK 的修复"正交",无论跑在 JDK 17、25 还是 26 都生效。
补充:即便在 JDK 26 上,如果你主动用
builder.header("Content-Length", "0")显式设置该头,由于content-length默认属于受限头,仍需通过系统属性jdk.httpclient.allowRestrictedHeaders显式放行------这也侧面说明 JDK 对该头的处理是刻意收敛的。
八、经验总结
- 代理类 500,先逐字节对比"代理请求 vs 直连请求",再谈其他。差异即嫌疑。
- 日志里的"无"不等于"真的无" 。
Content-Length: 0由 JDK 自动添加、不出现在request.headers(),审计日志天然遗漏------这是本次排查最大的盲区。抓包工具 / 本地回显服务器(请求回显探针)是验证"真实线上报文"的利器。 - 孤立变量、最小复现 胜过反复猜。把"域名、Host 大小写、cookie、referer、token、各
X-Forwarded-*"逐个拆开,最后锁定到CL:0 + CT这一个组合。 - 假设要闭环。同事提出的"漏发某自定义头"等假设,用注入实验即可证伪;不闭环的结论不要急着下。
- JDK
HttpClient的noBody()会给 GET 加Content-Length: 0------这是写反向代理/网关时极易踩的坑,建议在团队内沉淀为规范。 - 升级 JDK 不是银弹 。同一现象背后可能是不同的底层 Bug(
.GET()vs.method(noBody())),务必先确认代码实际使用的 API 写法,再判断是否真的被修复。
九、回显探针:看清 JDK 自动追加的"隐藏头"
排查代理类故障时,最大的盲区往往是**"应用层日志里看不到、却真实存在于线路上的头"**。本文根因 Content-Length: 0 就是典型:它由 JDK 的 HTTP 栈在"把字节写进 socket 之前"自动注入,根本不进入 HttpRequest.headers() 这个 Java API ,因此任何读取 request.headers() 的审计日志都会永久遗漏它。要拿到真相,必须有一个"能读到 socket 上真实字节"的第三方见证者。
9.1 它是什么,不是什么
回显探针(本地请求回显探针)本质上是一个极简的本地 HTTP 服务器 :被测客户端把请求发给它,它把"自己从 socket 上实际读到的请求行 + 请求头"原样打印出来,再回 200。它是 HTTP 应用层的"请求反射器",而不是抓包工具:
| 手段 | 工作层 | 看到的内容 |
|---|---|---|
| 本地回显探针(本文) | HTTP 应用层 | 客户端真实发到 socket 的请求行 + 头 |
| Wireshark / tcpdump | 网络层 | 原始 TCP/IP 包 |
| mitmproxy / Fiddler / Charles | HTTP 应用层 | 同回显探针,但带解码、重放等完整功能 |
它不嗅探网卡、不截获全局流量,只是被动接收并回显;作用等价于 mitmproxy,但轻量到连依赖都不需要------用 JDK 内置的 com.sun.net.httpserver.HttpServer 即可。
9.2 最小实现
完整可运行代码见 4.3 节的 JdkHeaderProbe(含发起请求的 HttpClient 客户端部分)。其核心只有几行------起一个本地回显服务器,把收到的请求头打印出来:
java
var server = HttpServer.create(new InetSocketAddress(18099), 0);
server.createContext("/", ex -> {
System.out.println("=== 服务器实际收到的请求头(含 JDK 自动附加的) ===");
ex.getRequestHeaders().forEach((k, v) -> System.out.println(k + ": " + v));
ex.sendResponseHeaders(200, -1);
});
server.start();
// 再用 HttpClient.method("GET", noBody()) 发一个请求给它即可
运行 java JdkHeaderProbe.java(JDK 11+ 支持单文件源码直接运行),输出第一行往往就是:
Content-length: [0] <-- JDK 自动追加,request.headers() 里看不到
9.3 为什么这个 bug 非它不可
本文的转机正是它:审计日志、curl 直连复现都只能证明"CL:0 + CT 组合会触发 500",却无法解释"为什么经代理就必然带上 CL:0"。只有回显探针站在接收方视角,把 JDK 真正写到线路上的 Content-Length: 0 亮出来,才把"JDK 自动追加 → 应用层不可见 → 日志盲区"这条因果链彻底闭合。
经验法则:凡遇到"日志显示没带某头、但行为却像带了"的代理/网关故障,第一时间起一个本地回显探针或 mitmproxy 看真实报文,比反复翻代码快得多。
9.4 请求流转与回显探针的位置
下图对比"真实故障链路"与"取证链路",标出 Content-Length: 0 的注入点,以及回显探针作为"替代接收方"所处的取证位置:
#mermaid-svg-6Vdt1nVAi4h4ecbm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6Vdt1nVAi4h4ecbm .error-icon{fill:#552222;}#mermaid-svg-6Vdt1nVAi4h4ecbm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6Vdt1nVAi4h4ecbm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .marker.cross{stroke:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6Vdt1nVAi4h4ecbm p{margin:0;}#mermaid-svg-6Vdt1nVAi4h4ecbm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster-label text{fill:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster-label span{color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster-label span p{background-color:transparent;}#mermaid-svg-6Vdt1nVAi4h4ecbm .label text,#mermaid-svg-6Vdt1nVAi4h4ecbm span{fill:#333;color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node rect,#mermaid-svg-6Vdt1nVAi4h4ecbm .node circle,#mermaid-svg-6Vdt1nVAi4h4ecbm .node ellipse,#mermaid-svg-6Vdt1nVAi4h4ecbm .node polygon,#mermaid-svg-6Vdt1nVAi4h4ecbm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .rough-node .label text,#mermaid-svg-6Vdt1nVAi4h4ecbm .node .label text,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape .label{text-anchor:middle;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .rough-node .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .node .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape .label{text-align:center;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node.clickable{cursor:pointer;}#mermaid-svg-6Vdt1nVAi4h4ecbm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .arrowheadPath{fill:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Vdt1nVAi4h4ecbm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster text{fill:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster span{color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6Vdt1nVAi4h4ecbm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm rect.text{fill:none;stroke-width:0;}#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape p,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape .label rect,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Vdt1nVAi4h4ecbm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6Vdt1nVAi4h4ecbm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JDK 自动追加
Content-Length: 0
真实链路
触发解析缺陷
取证链路
打印真实报文
前端 XHR
GET + Content-Type
Proxy / HttpClient
.method GET, noBody
请求分叉
后端 /api/wjatest/self
返回 500
本地回显探针
echo server
看到 Content-Length: 0
- Content-Type
一句话:Content-Length: 0 是 JDK 在 Proxy 转发时"偷偷"加上的;想看见它,就让 Proxy 的客户端把请求发到一个回显探针而不是真实后端------探针会如实打印出线路上的全部头。
附:复现步骤速查
bash
# 1. 启动后端(8081)与代理(8080)
# 2. 直连后端 ------ 正常
curl "http://localhost:8081/api/wjatest/self?f=json"
# 3. 经代理、且前端带 Content-Type ------ 复现 500
curl "http://localhost:8080/proxy/wjatest/self?f=json" \
-H "Content-Type: application/x-www-form-urlencoded"
# 4. 直连后端、手动加两个头 ------ 同样复现 500(证明根因在组合,不在代理)
curl "http://localhost:8081/api/wjatest/self?f=json" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Content-Length: 0"
# 5. 仅去掉 Content-Type(或仅去掉 Content-Length:0)------ 恢复 200
(本文所有 URI、域名、接口名均为示例,已脱敏处理。)
如需转载,请注明本文的出处:农民工老王的CSDN博客https://blog.csdn.net/monarch91 。
