线上接口突然开始报 400,日志里只打印了一行Bad Request,没有堆栈、没有字段名、没有任何线索——这种场景我遇到过不止一次。第一次碰上时我盯着代码看了两个小时,最后发现问题出在请求头里少了一个Content-Type,服务端拿不到正确的类型,参数绑定直接失败。从那以后我就明白一件事:Java 开发里调用接口谁都会,但真要出了问题还能快速定位,靠的不是熟练背 API,而是对 HTTP 协议本身的理解深度。这篇内容聊的就是 Java 开发中的 HTTP 协议,从报文结构、客户端选型、服务端处理链路,一直到实际排查手法,把这条链路上容易踩的坑一次性摊开讲。不管你是刚写第一个接口的新手,还是天天跟超时和连接池打交道的老手,都能在里面找到能直接抄走的东西。
1. 先把 HTTP 当成一门手艺,而不是一门课
1.1 一次接口调用的真实链路
很多人学 HTTP 的方式是打开一份文档,从上往下读方法、状态码、头部字段,读完觉得懂了,一写代码还是懵。问题在于这种学法把协议当成了一堆定义,而实际开发中它是一条完整的时空链路。你在 Java 里写下一行restTemplate.getForObject(...),这行代码往后要经过这些环节:应用层的 HTTP 客户端组装报文,连接池里取一条已经建立好的 TCP 连接(或者新建一条),TLS 握手(如果是 HTTPS),把字节写进 socket,经过本机网络栈、可能存在的代理、负载均衡、网关,最终到达服务端的监听端口,服务端容器解析出请求对象,交给框架分发到具体方法,方法执行完再按原路返回。
链路上任何一环出问题,你看到的都是同一个现象:调用失败。所以真正决定排查效率的,是你知不知道这条链路上每一环分别负责什么、各自会留下什么痕迹。比如超时这件事,连接超时和读取超时是两回事,连接超时说明 TCP 层面就没握上手,多半是网络不通、端口不对、服务没起;读取超时说明连接通了但对面没在约定时间内返回,多半是服务端处理慢、锁竞争、下游依赖卡住。分不清这两个,你就只能靠重启试试。
我一直建议团队里新来的同学做一件事:找个下午,用最原始的方式手写一次 HTTP 请求,别用任何框架。写完那几十行代码,你对"报文"这个词的理解会完全不一样,后面再看各种客户端配置项,脑子里是有画面的。
1.2 为什么 Java 开发者绕不开协议层
Java 生态把 HTTP 封装得太好了。注解一加,方法一写,参数自动绑定,返回值自动序列化,整个过程行云流水。这种便利的代价是抽象层变厚,一旦行为不符合预期,你连该看哪儿都不知道。我见过不止一个同学在@RequestBody上加了个required = false却依然报 400,最后发现是前端根本没发请求体,而服务端在读流的时候就已经决定要解析了。
协议层的知识在几个具体场景里会直接变成生产力。第一个是跨语言协作,前端、客户端、服务端三方联调,出了分歧谁的实现对、谁的不对,只能拿协议说话。第二个是网关与中间件配置,Nginx 的client_max_body_size、proxy_read_timeout、proxy_buffering,每一个都对应协议里的具体行为,不懂协议就只能照着别人的配置抄,抄错了也不知道错在哪。第三个是性能优化,长连接复用、压缩、缓存协商、分块传输,这些都是协议层的能力,不是框架的魔法。第四个是排查线上问题,抓包工具里那一坨字节,能读懂和读不懂,是完全两种工作状态。
说得再直白一点:框架帮你处理了 90% 的常规情况,剩下 10% 的异常情况才是你的价值所在,而那 10% 几乎全部落在协议层。
1.3 新手最容易带偏的五个认知
下面这张表是我这些年被问得最多、也是误解最集中的几个点。每一条我都见过有人在实际项目里栽跟头。
| 常见认知 | 实际情况 | 会引发的问题 |
|---|---|---|
| GET 不能带请求体 | 协议没禁止,但语义上不建议,很多服务端会直接丢弃 | 前端发了 body,后端读不到,排查半天 |
| POST 一定比 GET 安全 | 安全性取决于是否加密传输,跟方法无关 | 把敏感数据放 body 却走明文 HTTP |
| 状态码 200 就是成功 | 业务失败也常返回 200,靠响应体里的 code 区分 | 客户端只看状态码,误判成功 |
| 请求头大小无所谓 | 多数网关对头部总大小有上限,常见是 8KB 左右 | 塞了超长 token 直接 431 或 400 |
| 长连接一定更快 | 长连接有闲置回收和并发占用问题,滥用反而拖垮 | 连接池配置不当导致请求排队 |
第五条值得多说两句。长连接的本质是复用 TCP 连接,省掉握手开销,这在 QPS 高、请求频繁的场景下收益明显。但如果连接池上限设得很小,而下游响应又慢,请求就会在池子里排队等连接,表现出来的现象是"偶发超时",而且越压测越严重。这时候你去看日志,每条请求的处理时间都不长,但端到端时间很长,差的那部分全耗在等连接上了。
提示:判断是不是连接池排队导致的慢,最直接的办法是看客户端连接池的等待指标(比如活跃连接数、等待获取连接的线程数)。如果没有这个指标,临时把池子调大观察响应时间是否下降,也是一种验证手段,但别把它当长期方案。
2. 报文结构拆解:抓一次包胜过读十页文档
2.1 请求报文的三段式
一个 HTTP 请求报文在字节层面就是三段:请求行、头部、空行之后的请求体。请求行长这样:POST /api/user/query?from=web HTTP/1.1,依次是方法、请求目标、协议版本。头部是一堆Key: Value,每行一个,大小写不敏感。然后是一个空行,空行之后是请求体,长度由Content-Length或者Transfer-Encoding: chunked决定。
这个结构看着简单,但实操中有几个细节值得记住。请求目标不一定是路径,如果经过代理并且方法是 CONNECT,或者请求发往源站,格式会不一样,不过日常开发基本用不到。头部字段名大小写不敏感,但值通常敏感,比如Content-Type的值里charset=UTF-8的大小写一般无所谓,但某些自研服务端可能严格匹配。Content-Length和Transfer-Encoding不能同时出现,出现了就有请求走私的风险,正规服务端会直接拒绝。
还有个容易忽略的点:请求行和头部之间有严格的 CRLF 换行,不是\n而是\r\n。手写客户端时如果只写\n,有些严格实现的服务端会直接解析失败。我第一次手写的时候就栽在这儿,服务端返回 400,抓包看字节才发现问题。
2.2 响应报文的拆解与状态码语义
响应报文同样是三段:状态行、头部、响应体。状态行是HTTP/1.1 200 OK,版本、状态码、原因短语。原因短语是给人看的,程序判断只看状态码。
状态码的五段划分大家都背过,但实际用起来,真正需要精确掌握的是这几个:
- 200 / 201 / 204:成功。201 表示创建成功,通常配合
Location头;204 表示成功但无内容,响应体必须为空,这点很多接口写错,返回了 204 却带了 body,某些客户端会报错。 - 301 / 302 / 307 / 308:重定向。301 是永久,302 是临时,307 和 308 的区别在于是否保持原方法。POST 请求遇到 302,很多客户端会自动改成 GET,这是历史遗留行为,如果你的接口依赖重定向后仍用 POST,就得用 307。
- 400 / 415 / 422:客户端错误三兄弟。400 是请求本身有问题,415 是
Content-Type不被支持,422 是格式对但语义校验不过。这三个的区分度直接决定了联调效率。 - 401 / 403:认证与授权。401 是"你是谁我不知道",403 是"我知道你是谁但你不能干这个"。混用会让人抓狂。
- 429:限流。配合
Retry-After头告诉客户端多久后重试,这是很实用的一个约定。 - 502 / 503 / 504:网关类错误。502 是上游返回了无效响应,503 是服务不可用,504 是上游超时。这三个在微服务架构下出现频率极高,区分清楚能直接定位到是哪一层的问题。
注意:业务系统里用 200 包一切的做法很常见,但至少在网关层和基础设施层要保留真实状态码,否则监控和告警就没法基于状态码做聚合,出问题时你连"错误率上升"这个信号都拿不到。
2.3 用 Socket 手写一个 HTTP 客户端
光说不练没意义,下面这段代码用最原始的 Socket 发一个 HTTP/1.1 请求,不依赖任何 HTTP 库,你能清楚看到报文是怎么拼出来的。
import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets; public class RawHttpClient { public static void main(String[] args) throws IOException { String host = "example.internal"; int port = 80; String path = "/api/ping"; try (Socket socket = new Socket(host, port)) { socket.setSoTimeout(3000); // 读取超时,单位毫秒 String request = "GET " + path + " HTTP/1.1\r\n" + "Host: " + host + "\r\n" + "User-Agent: raw-client/1.0\r\n" + "Accept: application/json\r\n" + "Connection: close\r\n" + "\r\n"; // 空行标志头部结束 OutputStream out = socket.getOutputStream(); out.write(request.getBytes(StandardCharsets.US_ASCII)); out.flush(); BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } } }这段代码有几个点值得留意。Host头在 HTTP/1.1 里是必须的,缺了服务端会返回 400,因为虚拟主机要靠它区分站点。Connection: close告诉服务端这次用完就关,简化了读取逻辑,否则你得自己处理分块编码和连接复用。setSoTimeout设的是读取超时,不是连接超时,连接超时在new Socket()时通过带超时的构造重载指定。
跑通这段代码之后,你会对"HTTP 建立在 TCP 之上"这件事有非常具体的感受。后面再看到各种客户端库的配置项,比如connectTimeout、readTimeout、connectionRequestTimeout,就能一一对应到这三件事上:建连接、读数据、从池子里拿连接。
2.4 关键 Header 的实战清单
头部字段成百上千,日常开发真正高频用到的就那么十几个。下面这张表是按使用场景整理的,我把踩过坑的都标了出来。
| 头部字段 | 作用 | 实战要点 |
|---|---|---|
| Host | 指定目标主机 | HTTP/1.1 必填,手写客户端容易漏 |
| Content-Type | 请求体类型 | 决定服务端怎么解析,application/json和application/json;charset=UTF-8在多数框架下等价,但个别老框架严格匹配 |
| Accept | 期望的响应类型 | 内容协商用,服务端可能据此返回不同格式 |
| Content-Length | 请求体字节长度 | 按字节算不是按字符算,中文一个字符占三字节,算错会截断 |
| Transfer-Encoding | 分块传输 | 值为chunked时不能同时有 Content-Length |
| Authorization | 认证信息 | Bearer Token 场景下长度可能很大,注意网关的头部大小限制 |
| Cookie | 会话标识 | 数量多时总大小会超限,分布式场景更推荐 Token |
| Accept-Encoding | 支持的压缩算法 | 服务端压缩能显著减小响应体,但客户端必须支持解压 |
| Cache-Control | 缓存策略 | no-cache和no-store的区别很多人搞混,前者可缓存但需验证,后者完全禁止 |
| X-Request-Id | 链路追踪 | 强烈建议全链路透传,排查问题时它是最好用的线索 |
Content-Length按字节计算这一条值得单独强调。我见过有人用str.length()算长度,英文没事,中文直接出问题。正确做法是str.getBytes(StandardCharsets.UTF_8).length。更隐蔽的是,某些客户端库在你设置了Content-Length之后又自动算一遍,两边不一致时服务端读到一半就卡住,表现为请求挂起而不是报错,排查难度直接翻倍。
3. Java 里的 HTTP 客户端选型与参数调优
3.1 HttpURLConnection:能用但别滥用
JDK 自带的HttpURLConnection是很多老项目的默认选择,好处是零依赖,坏处是 API 设计停留在上个时代。它有几个必须知道的行为:默认会缓存响应,在某些场景下第二次请求拿到的还是旧数据,需要显式setUseCaches(false);默认对 POST 会做重定向转换;连接复用依赖于Connection: keep-alive和读取完响应体,如果只读了部分流就关闭,连接不会被复用反而会被销毁。
更麻烦的是它的超时只有两个:setConnectTimeout和setReadTimeout,没有从连接池获取连接的超时概念,因为它的连接池是内部实现,不对外暴露。在高并发场景下,如果所有连接都在用,你的线程会阻塞在建连接这一步,而这一步的超时上限是 connectTimeout,会造成大面积的请求堆积。
HttpURLConnection conn = (HttpURLConnection) new URL("http://example.internal/api/ping").openConnection(); conn.setRequestMethod("GET"); conn.setConnectTimeout(1000); conn.setReadTimeout(3000); conn.setUseCaches(false); conn.setRequestProperty("Accept", "application/json"); int code = conn.getResponseCode(); try (InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) { // 必须读完,否则连接无法复用 } conn.disconnect();我的建议是:新项目不要再用它,维护老代码时知道它的坑在哪就行。
3.2 Apache HttpClient 与 OkHttp 的取舍
Apache HttpClient 是老牌选择,功能全,配置项多,对连接池、路由、重试的控制粒度很细,适合需要精细控制的企业内部系统。它的学习曲线偏陡,一个完整的CloseableHttpClient配置能写几十行。OkHttp 更偏现代风格,API 简洁,默认配置就够用,拦截器机制很灵活,做日志、签名、加解密都很方便,缺点是它原本是移动端场景设计的,某些服务端场景需要额外注意配置。
两者的核心差异在连接池策略。HttpClient 的PoolingHttpClientConnectionManager可以按路由(目标主机)设置最大连接数,总共一个全局上限;OkHttp 的ConnectionPool是按地址维护的,参数是最大空闲连接数和空闲存活时间。选型时我的判断依据通常是这三点:需要按目标主机精细限流选 HttpClient;需要灵活扩展请求拦截选 OkHttp;团队已有沉淀就跟着走,别为了技术偏好引入第二套。
3.3 JDK 11 HttpClient 的异步写法
JDK 11 引入的java.net.http.HttpClient算是标准库的正经补课,同步异步都支持,还能直接用 HTTP/2。日常用同步写法就够:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(2)) .version(HttpClient.Version.HTTP_1_1) // 明确降级,避免协商开销 .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://example.internal/api/user?from=web")) .timeout(Duration.ofSeconds(3)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString("{\"id\":1001}")) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.statusCode() + " " + response.body());这里的version值得说一句。默认是HTTP_2,会尝试协议升级,如果你的服务端或者中间网关对 h2c 支持不完整,升级过程可能带来额外延迟甚至失败。内部系统里显式设成 1.1 是更稳妥的选择,除非你确认整条链路都支持 HTTP/2 并且确实需要多路复用带来的收益。
timeout设的是整个请求的超时,包括建连、发送、等待响应。它和connectTimeout的关系是包含关系,所以connectTimeout必须小于timeout,否则那个更细粒度的设置根本不会生效。
3.4 连接池与超时参数的算法推导
参数不能拍脑袋定,至少要有推导过程。以连接池为例,核心公式是:
最大连接数 ≈ 峰值 QPS × 平均响应时间(秒)
假设某个下游接口峰值 QPS 是 200,平均响应时间 50ms,那么瞬时并发约是 200 × 0.05 = 10 个连接。这是理论最小值。实际配置要考虑毛刺,通常乘以 2 到 3,配 20 到 30 比较合理。如果配成 5,那超过 5 个并发请求就要排队,排队时间会直接加到响应时间上,形成正反馈——越慢越排队,越排队越慢。
超时的推导同理。读取超时应该基于 P99 响应时间加上合理余量。比如 P99 是 800ms,那读超时设 2 秒是合理的,设成 10 秒就意味着下游真出问题时你要等 10 秒才失败,这段时间线程一直被占着。连接超时一般设得短,1 到 2 秒,因为同机房建连失败基本是秒级的,等更久没有意义。
下面这组配置是我在一个中等规模服务里用过的,供参考:
| 参数 | 取值 | 推导依据 |
|---|---|---|
| 最大连接数 | 50 | 峰值 600 QPS × 平均 60ms ≈ 36,留 40% 余量 |
| 每路由最大连接 | 20 | 避免单一慢下游占满整个池 |
| 连接超时 | 1000ms | 同机房建连通常在 10ms 内,超 1 秒基本是故障 |
| 读取超时 | 2000ms | P99 约 700ms,留约 2.8 倍余量 |
| 获取连接超时 | 500ms | 拿不到连接就快速失败,避免线程堆积 |
| 空闲连接存活 | 60s | 小于网关的闲置断开时间,防止拿到已被对端关闭的连接 |
最后一行是重点。很多"偶发连接重置"的问题根源就在这里:客户端认为连接还活着,实际对端网关已经因为空闲超时把连接关了,你复用了这条连接发请求,就收到 RST。解决办法是客户端空闲回收时间设得比网关短,或者开启连接的存活检测。
提示:判断是不是空闲连接被对端关闭导致的偶发失败,看失败的时间分布。如果集中在低峰期或者请求间隔较长之后,基本可以确定是这个原因。
4. 服务端视角:请求是怎么变成你的 Controller 方法的
4.1 从 TCP 字节流到 HttpServletRequest
服务端的处理链路,从最底下说起。网卡收到数据,内核协议栈组装成 TCP 流,应用层的容器(比如内嵌的 Tomcat)在监听的端口上 accept 连接,然后由连接器负责解析字节流,按 HTTP 协议切分出请求行、头部、请求体,封装成一个请求对象。这一步叫协议解析,是容器的职责,跟你的业务代码没关系。
解析完成之后,请求被交给 Servlet 容器,经过一系列 Filter,然后进入 Spring 的DispatcherServlet。它做三件事:根据请求路径找到对应的处理器映射,调用处理器适配器执行目标方法,处理返回值。中间穿插参数解析、数据绑定、类型转换、校验、异常处理等等。你在方法签名上写的@RequestParam、@PathVariable、@RequestBody,背后都对应着一个具体的参数解析器。
理解这条链路的价值在于,当出现 400 的时候你能快速判断是哪一环出的问题。请求在容器解析阶段失败,说明报文本身不合法,比如头部格式错误、Content-Length与实际不符;在参数绑定阶段失败,说明报文合法但语义不符合方法签名,比如该传 JSON 却传了表单;在校验阶段失败,说明数据格式没问题但业务规则不满足。这三种 400 的原因完全不同,处理方式也完全不同。
4.2 Content-Type 决定参数能不能绑上
这是我在实际项目里见得最多的问题,没有之一。服务端方法上写了@RequestBody UserDTO user,前端发过来的请求头是Content-Type: application/x-www-form-urlencoded,结果就是 415 或者 400,前端同学还坚称"我传了参数啊"。
原因很简单:@RequestBody依赖HttpMessageConverter把请求体反序列化成对象,而不同的Content-Type走不同的转换器。application/json走 Jackson,application/x-www-form-urlencoded走表单解析,两者完全不通用。你发了表单格式,JSON 转换器就找不到可用的消息转换器,直接抛HttpMediaTypeNotSupportedException。
反过来也一样。接口定义成表单接收(方法参数直接写字段名,不加@RequestBody),前端却发了 JSON,那服务端拿到的就是一堆 null,因为它去请求参数里找,而参数在请求体里。
| 服务端写法 | 需要的 Content-Type | 传错时的表现 |
|---|---|---|
@RequestBody+ 对象 | application/json | 415 或 400 |
| 直接写字段参数 | application/x-www-form-urlencoded 或 query | 字段全为 null |
@RequestParam+ MultipartFile | multipart/form-data | 415 |
@RequestHeader | 任意 | 拿不到值返回 null 或 400 |
这里有个实践建议:内部接口统一用 JSON,接口文档里把Content-Type明确写出来,前端生成代码时就不会错。对外接口如果必须兼容表单,就在 Controller 里写两个方法映射同一个路径,用consumes区分,别在一个方法里兼容所有格式,那样代码会变得很难维护。
4.3 幂等、重试与超时的服务端配合
客户端设了重试,服务端就必须考虑幂等。这个道理大家都懂,但落地时经常漏。举个典型场景:客户端读超时设 2 秒,服务端某个操作要 3 秒,客户端超时后重试,服务端此时收到第二个请求,第一个还在跑,两个请求并发执行同一份业务,数据就出问题了。
服务端的配合手段有这么几个。第一,对写操作引入幂等键,客户端生成一个唯一 ID 放在头部,服务端用它做去重,重复请求直接返回第一次的结果。第二,把耗时操作改成异步,接口快速返回一个任务 ID,客户端轮询查结果,从根上避免长事务占用连接。第三,合理设置服务端的处理超时,让服务端先于客户端超时,这样客户端超时的时候服务端已经放弃了,不会出现"客户端认为失败、服务端实际成功"的尴尬局面。
关于第三点,经验值是服务端超时略小于客户端读超时,比如客户端 2000ms,服务端 1800ms。这样绝大多数情况下是服务端先超时并返回一个明确的错误,客户端拿到的是可解释的响应,而不是一个干巴巴的读超时异常。
4.4 Keep-Alive 与网关层的隐性损耗
HTTP/1.1 默认长连接,一个 TCP 连接上可以串行发多个请求。这个机制在浏览器场景下很有效,但在服务间调用场景下有几个坑。
第一个坑是队头阻塞。同一个连接上请求是串行的,前一个响应没回来,后一个就得等。如果连接池配得太小,或者某个慢请求占着连接不放,后面的请求全被堵住。这也是为什么 HTTP/2 要搞多路复用——它把请求拆成帧,在一条连接上并发传输,从根本上解决了队头阻塞。
第二个坑是中间设备的空闲超时。负载均衡、网关、防火墙对空闲连接都有回收策略,常见的是 60 秒、300 秒。客户端如果不知道,会复用到已经被关闭的连接,收到 RST。处理办法前面提过,让客户端的空闲回收时间短于链路中最小的那个超时值。
第三个坑是连接的"半关闭"状态。某些情况下连接的一端关闭了写方向但还能读,客户端如果没正确处理,会一直等一个永远不会来的响应,直到应用层超时。这类问题的特征是耗时刚好等于超时时间,非常规律,看到这种规律性的慢请求,优先怀疑连接状态问题。
5. 踩坑实录与排查手册
5.1 状态码与异常的对应速查表
排查问题最快的方式是对号入座。下面这张表是我整理的高频对应关系,左边是你看到的现象,右边是最可能的原因和第一步该查什么。
| 现象 | 最可能原因 | 第一步排查动作 |
|---|---|---|
| 400 且无详细信息 | 报文格式错误、Content-Type 不匹配、必填头缺失 | 抓包看原始请求报文 |
| 401 反复出现 | Token 过期、头部未正确传递、时钟偏差 | 检查 Token 有效期和服务端时间 |
| 403 | 权限不足、IP 白名单、签名校验失败 | 核对账号权限和签名算法 |
| 404 | 路径错误、网关路由未配置、上下文路径差异 | 对比注册中心的服务路径 |
| 415 | Content-Type 与服务端不匹配 | 对齐接口定义的媒体类型 |
| 429 | 触发限流 | 查限流规则和当前 QPS |
| 500 | 服务端未捕获异常 | 查服务端日志的完整堆栈 |
| 502 | 上游服务返回非法响应或进程挂了 | 查上游服务健康状态 |
| 504 | 上游超时 | 对比客户端与服务端超时配置 |
| 连接重置 | 复用了已关闭的连接、对端主动关闭 | 检查空闲连接回收配置 |
| 偶发读超时 | 连接池排队、下游慢、GC 停顿 | 看连接池指标和下游响应时间分布 |
这张表不是万能的,但能覆盖八成以上的日常问题。用它的前提是你能拿到真实的状态码,所以第一件事永远是:确保日志里打印了状态码和关键响应头,别只打印一个异常信息。
5.2 连接泄漏与端口耗尽
连接泄漏是线上事故的高发区,特征是运行一段时间后突然大面积超时,重启就恢复,过一阵again。根因通常是响应体没读完、流没关闭、连接没归还。
具体有几种典型写法。一种是拿到InputStream之后只读了几个字节就关闭,连接无法复用,被标记为不可用。一种是用了try-finally但 finally 里只关了输入流没关输出流。还有一种是异常路径下直接抛出,跳过了关闭逻辑。这三种在代码 review 时很难看出来,得靠工具。
检测手段有两个方向。应用层看连接池的活跃连接数,如果只增不减,基本可以确定泄漏。系统层看netstat或者ss输出的连接状态分布,如果大量连接停在CLOSE_WAIT,说明应用层没主动关闭;如果大量TIME_WAIT,说明是主动关闭方,属于正常现象但数量过多也要关注。
# 按状态统计连接数,快速定位异常状态堆积 ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rnCLOSE_WAIT数量持续增长是最危险的信号,它意味着对端已经发了 FIN,你的应用还没调用 close。这种连接会一直占着文件描述符,直到进程重启或者达到上限。
注意:文件描述符耗尽的表现不一定是报错,可能是 accept 失败、日志写不进去、甚至整个进程无响应。如果看到这类"莫名其妙什么都干不了"的现象,先看下进程的 fd 数量。
5.3 抓包、日志与复现三步法
排查 HTTP 问题的顺序,我的习惯是先看日志、再抓包、最后模拟复现,这三步能解决绝大多数问题。
第一步看日志,重点是客户端和服务端两边的日志都要有,且能通过一个请求 ID 关联起来。日志里至少要包含:请求方法、路径、状态码、耗时、请求 ID。有条件的话把关键响应头也打出来,比如Content-Type、Retry-After、X-Request-Id。很多时候只看两边耗时对不上,就能判断问题出在中间链路上。
第二步抓包,用tcpdump在服务器上抓,或者本地用抓包工具。抓包的价值在于看到未经任何加工的真实报文,这是唯一能确定"到底发出去了什么、收到的是什么"的手段。
# 抓取指定端口上的 HTTP 流量,边抓边看 sudo tcpdump -i any -A -s 0 'tcp port 8080 and (((ip[2:2] - ((ip[0]&0xf)<<2)) - ((tcp[12]&0xf0)>>2)) != 0)'这条命令里的过滤表达式是关键,排除了没有负载的纯 ACK 包,让输出干净很多。-A表示以 ASCII 显示内容,看明文 HTTP 足够用了,HTTPS 就得换思路。
第三步模拟复现,用curl精确构造请求,把怀疑的变量逐个试。curl -v的输出包含了完整的请求和响应头,是最高效的验证工具。
curl -v -X POST 'http://example.internal/api/user/query' \ -H 'Content-Type: application/json' \ -H 'X-Request-Id: debug-0001' \ -d '{"id":1001}' \ --max-time 3复现时有个技巧:从"能通"的最小请求开始,逐步加回怀疑的头部或参数,直到失败出现。这样能精确定位到是哪个因素导致的。反过来从复杂请求删减也行,看个人习惯。我一般用加的,因为加的过程中每一步都是可验证的。
5.4 几个容易被忽略的细节
最后补几个零散但很实用的点。
关于中文乱码。乱码的本质是编码和解码用了不同的字符集,跟 HTTP 本身没关系,但 HTTP 是传递环节,所以经常背锅。定位方法是:确认发送端的字节长度,比如一个中文字符在 UTF-8 下是 3 个字节,如果在Content-Length里看到的是 1,说明客户端用的是单字节编码。确认方法很简单,用curl发同样的内容加--data-binary,对比服务端收到的字节。
关于时间。Date头的格式是 RFC 1123,GMT结尾,很多服务端会拿它做签名校验或者缓存判断。如果客户端和服务端的系统时间偏差太大,会出现"请求已过期"这类看起来莫名其妙的错误。容器环境里这个问题的发生率比物理机高,因为容器迁移后时间同步可能滞后。
关于响应体的读取。不管是 OkHttp 还是 HttpClient,拿到响应之后都应该确保响应体被完整读取或显式关闭,否则连接不会被归还到池里。这块在try-with-resources里比较容易保证,但如果你在响应处理过程中抛了异常,就得确保close一定会执行。
关于网关的请求体大小限制。默认值通常不大,上传文件或者提交大 JSON 时会直接返回 413。这个限制在应用层、网关层、负载均衡层可能各有各的配置,改的时候要三处一起确认,只改一处往往不生效。
关于并发压测时的 keep-alive。压测工具默认可能会关闭长连接,或者每个线程独占一条连接,跟生产环境的实际行为差别很大。压测前一定要确认连接复用策略与实际一致,否则压出来的数据和线上对不上。
这些细节单拎出来都不复杂,但它们组合起来,就是"为什么我的请求偶尔失败"这类问题的全部来源。我个人的体会是,HTTP 这门东西该懂的部分其实不多,但每一条都要懂到能对应到具体现象的程度,光知道定义没用。真正让你在凌晨三点还能淡定定位问题的,是那些你亲手抓过的包、亲手复现过的 400、亲手调到过的连接池参数。