在最近的微服务排障过程中业务方反馈了一个诡异的问题客户端发起请求时明确携带了驼峰写法的请求头如appKey: asd但请求经过反向代理和网关到达后端具体的 Spring Boot 业务服务时业务代码里取出来的请求头全变成了小写appkey: asd。面对这种链路较长的问题最忌讳的就是靠猜。是 Nginx 做了转换是 Gateway 的某个 Filter 偷偷改了头还是 Spring Boot 本身的问题为了用事实说话我直接上tcpdump和Wireshark在链路的各个节点进行了分段抓包。测试环境拓扑与机器信息在开始抓包前我们需要明确当前测试环境的具体流量拓扑和节点的 IP、端口信息。根据排查梳理当前链路如下客户端 (Postman)IP10.20.4.84Nginx (反向代理)IP10.100.38.11对外暴露端口9009Spring Cloud Gateway (网关)IP10.100.22.48服务端口8081Spring Boot 业务服务IP10.100.22.48服务端口8071注意业务服务与网关部署在同一台物理机上完整的流量流向客户端(10.20.4.84) - Nginx(10.100.38.11:9009) - Gateway(10.100.22.48:8081) - Spring Boot(10.100.22.48:8071)第一阶段抓取 客户端 - Nginx入向请求首先确认客户端发出的报文到底对不对。在 Nginx 所在机器10.100.38.11上抓取目标端口为 9009 的入向流量Bashsudo tcpdump -i ens192 -s 0 -nn tcp dst port 9009 -w /tmp/nginx_before.pcap将文件导出到本地后通过 Wireshark 打开。为了快速定位可以使用包含接口特征的过滤条件例如tcp contains responseSpecialBufferSizeOfPost定位到目标数据包后右键点击该数据包 - 追踪流 (Follow) - TCP 流 (TCP Stream)即可看到直观的 HTTP 原始报文。还原出的原始 HTTP 请求如下HTTPPOST /api-gateway-dev/demo-business-service/sms/responseSpecialBufferSizeOfPost HTTP/1.1 appid: demo-business-service appKey: asd Content-Type: application/json User-Agent: PostmanRuntime/7.54.0 Host: 10.100.38.11:9009 Content-Length: 1868 {xn:0462add21538f...[报文过长此处省略]...1b4f7b1911}结论客户端确实发送了驼峰格式的appKey: asd源头没问题。第二阶段抓取 Nginx - Gateway出向请求接着排查是不是 Nginx 在转发时对 Header 做了手脚。依然在 Nginx 机器上抓取 Nginx 发往 Gateway10.100.22.48:8081的流量Bashsudo tcpdump -i ens192 -s 0 -nn tcp and dst host 10.100.22.48 and dst port 8081 -w /tmp/nginx_after.pcap查看报文内容HTTPPOST /demo-business-service/sms/responseSpecialBufferSizeOfPost HTTP/1.1 Host: 10.100.38.11 X-Real-IP: 10.20.4.84 X-Real-Port: 50649 X-Forwarded-For: 10.20.4.84 Content-Length: 1868 appid: demo-business-service appKey: asd结论Nginx 增加了几个X-开头的代理头但原封不动地保留了appKey的驼峰格式。Nginx 洗清嫌疑。第三阶段抓取 Nginx - Gateway入向请求确认为了严谨我们前往 Gateway 所在机器10.100.22.48确认网关网卡实际收到的报文内容。 技术要点为什么这里不筛选 Nginx 的源端口9009 因为 Nginx 在作为反向代理将请求转发给上游服务器时它自己扮演了“客户端”的角色。系统会为这个新的 TCP 连接随机分配一个临时的动态端口Ephemeral Port例如53971而不是复用外部客户端访问 Nginx 时的9009端口。如果强行加上src port 9009将抓不到任何转发包。因此过滤条件只需指定源 IP 和目标端口Bashsudo tcpdump -i ens33 -s 0 -nn src host 10.100.38.11 and dst port 8081 -w /tmp/gateway_before.pcap报文显示appKey: asd依然坚挺。网关入向流量正常。第四阶段抓取 Gateway - Spring Boot出向请求这是最关键的一环。Spring Cloud Gateway 底层基于 Netty 构建中间经过了一系列的 Routing Filter 配置它会修改 Header 吗由于 Gateway 和最终的下游 Spring Boot 业务服务部署在同一台机器10.100.22.48上我们需要在网关机器上抓取发往下游业务服务8071端口的流量。 技术要点为什么网卡参数使用的是-i any 当服务部署在同一台机器时它们之间的网络通信通常不会经过外部的物理网卡如ens33而是直接走系统的本地回环网卡Loopback interface即loIP 为127.0.0.1或是通过内网 IP 内部路由。使用-i any可以监听本机上所有网络接口的流量无论它们是走物理网卡出网还是在本地回环网络内通信都能做到“一网打尽”避免因选错网卡而抓不到包的情况。Bashsudo tcpdump -i any -s 0 -nn src host 10.100.22.48 and dst port 8071 and tcp -w /tmp/gateway_after.pcap通过 Wireshark 追踪这部分报文我们看到了网关发出的最终内容HTTPPOST /sms/responseSpecialBufferSizeOfPost HTTP/1.1 X-Real-IP: 10.20.4.84 X-Real-Port: 61676 X-Forwarded-For: 10.20.4.84,10.100.38.11 Content-Length: 720 appid: demo-business-service appKey: asd Content-Type: application/json Forwarded: protohttp;host10.100.38.11;for10.100.38.11:58748 host: 10.100.22.48:8071 sw8: 1-YWM4Nj...[链路追踪头信息省略]...MTAuMTAwLjIyLjQ4OjgwNzE结论网关追加了 SkyWalking 链路追踪相关的头sw8等以及Forwarded路由信息但依然完美透传了appKey: asd。网关也洗清了嫌疑峰回路转真相在最后一公里既然整个网络链路Nginx - Gateway - 本地网卡都没有修改请求头的大小写那问题必定出在 Spring Boot 服务内部。为了验证我直接使用 Postman 直连 Spring Boot 服务10.100.22.48:8071发起请求并在代码中断点调试以下获取请求头的方法JavaEnumerationString headerNames request.getHeaderNames(); while (headerNames.hasMoreElements()) { String headerName headerNames.nextElement(); System.out.println(headerName : request.getHeader(headerName)); }运行结果让人大跌眼镜遍历打印出来的headerName全是小写的appkey根因剖析RFC 规范与 Tomcat 源码实现为什么 Spring Boot 会把 Header 的名字变成小写实际上HTTP/1.1 规范RFC 2616 章节 4.2明确规定HTTP Header 的字段名称是大小写不敏感的case-insensitive。到了 HTTP/2规范更是直接强制要求所有的 Header 名称在传输层必须被转换为全小写。为了遵循这一规范并提高匹配效率Spring Boot 内嵌的 Tomcat 容器在解析 HTTP 协议的极底层代码中直接完成了大写到小写的转换。以当前项目的Spring Boot 2.7.18默认内嵌Tomcat 9.0.83为例核心转换逻辑发生在 Coyote HTTP/1.1 处理器的Http11InputBuffer类中。Tomcat 会逐字节读取 HTTP 报文。当它在parseHeader()方法中解析到 Header Name 时会直接在底层的ByteBuffer里进行原地替换In-place conversion。具体源码信息如下源码仓库: Apache Tomcat 9.0.83文件路径:java/org/apache/coyote/http11/Http11InputBuffer.javaGitHub 源码链接: Http11InputBuffer.java (Tomcat 9.0.83 分支)关键代码片段截取Java// 截取自 Http11InputBuffer#parseHeader() 读取 Header Name 字节流的逻辑 if (chr Constants.A chr Constants.Z) { byteBuffer.put(byteBuffer.position() - 1, (byte) (chr - Constants.LC_OFFSET)); }原理解释chr是当前读取到的单个字节。当判断chr为大写字母在A(65) 和Z(90) 的 ASCII 码之间时执行chr - Constants.LC_OFFSET。Constants.LC_OFFSET的定义是A - a即 65 - 97 -32。所以chr - (-32)实质上就是chr 32这正是 ASCII 码表中大写字母转换为小写字母的数学偏移量。经过Http11InputBuffer这样极其底层的按字节剥离处理后被强制转为小写的 Header Name 最终才会被封装成MessageBytes对象并存入org.apache.tomcat.util.http.MimeHeaders结构中供后续路由和业务使用。因此当你通过request.getHeaderNames()获取枚举迭代器时暴露出来的迭代值自然就是小写的形式了。但需要特别注意的是虽然遍历出来是小写由于规范要求“大小写不敏感”且 Tomcat 底层查找逻辑做了忽略大小写的兼容所以当你使用指定 key 去获取时request.getHeader(appKey)、request.getHeader(APPKEY)以及request.getHeader(appkey)都能正确获取到对应的值asd。总结与避坑指南排查思路遇到跨多节点的网络问题善用tcpdump结合 Wireshark 追踪 TCP 流是最直接高效的手段。不要过度依赖主观猜测。清楚流量拓扑、正确选择网卡同机用any或lo以及理解代理转发时的端口分配机制能避免在抓包时走弯路。开发规范永远不要依赖 HTTP 请求头的大小写来做业务逻辑判断比如if(name.equals(appKey))。如果必须遍历 Headers 并做精确匹配请使用equalsIgnoreCase进行比较。获取方式推荐直接通过RequestHeader(appKey)注解或request.getHeader(appKey)明确获取容器底层会帮我们处理好大小写兼容的问题。尽量避免通过request.getHeaderNames()遍历取 key 后再做强校验。