ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Apache HttpClient NoHttpResponseException:连接池僵尸连接排查与解决

Apache HttpClient NoHttpResponseException:连接池僵尸连接排查与解决 如果你的服务在某个时间点突然刷出一片org.apache.http.NoHttpResponseException: ... failed to respond而服务端应用本身看起来一切正常CPU、内存、日志都没有明显异常那十有八九不是对端挂了而是 Apache HttpClient 连接池里的“僵尸连接”在作妖。这个异常在 HTTP 客户端开发中太常见了尤其出现在微服务调用、网关转发和外部接口对接的场景我排过不少次基本上思路清晰的话十分钟内就能定位到方向。这篇文章就围绕这个异常把原理、场景、排查方法和解法一次讲透适合正在被 NoHttpResponseException 困扰的后端开发、SRE 和中间件维护同学收藏参考。1. 异常的本质一个“陈旧连接”引发的悬案1.1 先看异常栈NoHttpResponseException 从哪里抛出先看一个典型栈。线上报错通常是这样的org.apache.http.NoHttpResponseException: The target server failed to respond at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:141) at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56) at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259) at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:139) at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153) at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:282) at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:140) ...注意抛出位置在DefaultHttpResponseParser.parseHead也就是 HTTP 客户端在等待读取响应头status line时对端没有任何数据返回或者说连接被直接关闭了。对 HTTP 层来说这就是“服务端没有响应”。但有意思的是这时候 TCP 连接本身并不是“连不上”的状态socket 也没有抛ConnectException它是已经建立好、被复用的连接却在发送请求后的读取阶段断掉了。很多人第一反应是服务端挂了其实未必。服务端可能活得好好的只是它认为这条连接已经空闲太久主动把它关闭了。而客户端连接池不知道下一次请求还是从池里取出这条“已经死亡”的连接发出请求后读不到任何响应于是抛出这个异常。简单说这不是服务端应用故障而是 HTTP 连接池复用陈旧连接导致的问题。1.2 Keep-Alive 与连接池为什么连接会“悄悄失效”要理解这个异常必须先理解 HTTP 持久连接Keep-Alive和连接池的关系。HTTP/1.1 默认支持 Keep-Alive即一次 TCP 连接可以承载多个 HTTP 请求减少频繁建连的握手开销。Apache HttpClient 为了最大化复用会创建连接池把用完的连接放回池里后续请求优先从池里取连接。问题就出在“池里的连接”是没有生命周期的。TCP 连接本质上由两端和中间网络设备共同维持任何一方都可以随时关闭它。服务端通常会配置空闲超时比如 Tomcat 的 keepAliveTimeout、Nginx 的 keepalive_timeout、云负载均衡的 idle timeout一旦连接空闲时间超过阈值服务端会执行 close。正常关闭会发送 FIN 包但客户端不会立刻感知只有在下次写入数据或读取数据时才会收到 EOF 或 RST。这里有一个时间错配的关键点客户端连接池不知道服务端已经关闭了连接仍然把这条连接视为“健康可用”。下次请求从池里取出连接写入 HTTP 请求服务端那边已经不存在这条连接了客户端等待响应头时读不到任何内容parseHead直接解析失败异常就产生了。如果客户端能在取连接前校验一次连接是否可用这个窗口就会小很多但校验本身也有开销所以必须平衡。1.3 先区分清楚它和 ConnectTimeout、SocketTimeout 不是一回事NoHttpResponseException经常和ConnectTimeoutException、SocketTimeoutException混在一起很多人排查时把它们当一类问题处理容易跑偏。我整理了一个区别表排障前先对照一下异常发生的阶段代表的问题常见原因ConnectTimeoutExceptionTCP 连接建立阶段连不上对端网络不通、防火墙丢弃 SYN、服务端口未监听SocketTimeoutException读写阶段连接建立了但对端迟迟不返回数据服务端处理慢、网络丢包、线程阻塞NoHttpResponseException读写阶段等待响应头连接建立过但读取时对端已关闭或无任何数据复用失效连接、中间设备断连、服务端主动断开一个简单类比ConnectTimeout是打电话没人接听或号码不存在SocketTimeout是电话接通了但对方一直不说话NoHttpResponseException是电话显示接通你刚说完话对方就挂断了你以为是信号问题其实是对方早就不想听了。定位方向完全不同所以第一步一定是先把异常类型分清楚。2. 哪些场景最容易触发一条链路三层超时在打架2.1 时间错配客户端、网关、服务端各有各的 idle 超时生产环境里一个请求链路往往经过三层以上客户端 → Nginx/负载均衡 → 应用服务端。每一层都有各自的空闲连接超时配置而且这些配置默认值差别很大。举个例子Tomcat 的 keepAliveTimeout 默认 20 秒左右Nginx 的 keepalive_timeout 默认 65 秒云厂商 SLB 的 idle timeout 通常是 60 秒而客户端连接池的空闲回收策略如果不设置可能几分钟甚至更久都不会主动回收。当客户端保持连接空闲超过服务端的 keepAliveTimeout 后服务端已经把连接关闭了但客户端仍然认为连接可用。这种“时间错配”是 NoHttpResponseException 出现的根本原因之一。链路中任意一层的空闲超时小于客户端连接的空闲生命周期那么连接被中间层或对端回收的概率就会很高。而且由于每层配置独立很多团队根本不知道全链路到底配了哪些超时参数。2.2 低频请求后的第一次请求我在实际排障中最常见的一种现象异常集中出现在低峰期后的第一次请求比如凌晨定时任务、早晨第一波流量、容器扩容后的首批调用。原因是低峰期流量少连接池里很多连接长时间处于空闲状态超过了服务端或网关的空闲超时。等高峰期或定时任务触发时这些连接已经全部失效客户端批量取出使用于是刷出一片 NoHttpResponseException。这种场景通常非常有规律一看日志时间点就能判断。有一种更隐蔽的情况服务端虽然有 keepAliveTimeout但客户端每个连接的空闲时间刚好卡在临界点附近导致异常不是每次都出现而是随机偶发。这种随机性对排障很不友好但核心逻辑一样还是连接池回收策略和服务端超时之间的时间窗口问题。2.3 链路经过负载均衡或网关时经过 Nginx、SLB、Kong、Spring Cloud Gateway 等中间层时连接生命周期变得更加复杂。客户端和网关之间是一条连接网关和服务端之间又是另一条连接中间层可以随时选择关闭其中任一条。很多负载均衡产品在空闲超时后不会发 RST而是直接静默丢弃连接这样客户端完全感知不到异常直到下一次请求写数据时才发现“没有对端”。如果偶然出现连接处于半开状态客户端发送数据后可能收不到任何 ACK最终表现为 NoHttpResponseException。这里有个容易被忽略的坑网关层的超时配置不一定能从应用代码里看到很多时候要翻运维配置、云控制台、Nginx 配置。我在查一个跨机房调用问题时最终定位到是中间防火墙的空闲会话超时只有 30 秒而全链路所有应用都以为自己的超时配置是合理的。2.4 服务端发布重启的“幽灵连接”服务端发布、重启、弹性扩容缩容也是重灾区。服务端重启后所有旧 TCP 连接在操作系统层面都被释放了但客户端连接池不知道池里仍然保留着这些已经失效的连接。下次请求取出连接连接不存在直接报 NoHttpResponseException。这个场景在 Kubernetes 环境尤其常见Pod 滚动发布、优雅下线不彻底时客户端池里的连接会指向已经销毁的 Pod。线上表现为发布窗口期突然出现一波异常过一会儿又自动恢复。如果监控没有按时间维度对齐发布事件很容易被误判成服务端启动慢或代码 bug。3. 动手排查日志对时间线、抓包看痕迹3.1 先说排查顺序面对大量 NoHttpResponseException我建议按这个顺序排查省时省力看异常时间点和流量曲线。如果异常集中在低峰后、发布窗口、定时任务触发时基本可以快速锁定是空闲连接或发布导致。看服务端访问日志和客户端日志的时间差。服务端是否真的收到了请求。如果服务端根本没有收到请求说明连接在到达服务端之前就断了。看连接池配置。客户端连接池是否启用了空闲回收、校验、重试连接池最大连接数是否被占满。抓包确认。这一步能直接看到 FIN、RST 的时序判断是哪一端主动断开的连接。检查链路所有中间层的 idle timeout 参数包括云 SLB、物理防火墙、Nginx 等。3.2 用抓包与连接状态验证真实原因抓包是判断连接关闭方的金标准。如果服务端地址已知可以在客户端或服务端机器上执行tcpdump -i eth0 host 192.168.1.10 and port 8080 -w http.pcap生产环境不一定方便在服务器上抓包但可以在压测环境复现。抓包后重点看三点谁先发了 FIN是否出现 RST从最后一次数据交换到 FIN 的时间间隔是多少。如果发现服务端在空闲一段时间后主动发 FIN而客户端要晚很久才复用这条连接那就验证了“服务端空闲超时关闭连接客户端不知情”的判断。如果看到中间网络设备直接丢弃连接客户端重传多次无响应则是中间层静默断连。另外可以用ss -tan看客户端本地的连接状态。如果大量连接处于CLOSE_WAIT或ESTABLISHED但长期无数据传输说明连接管理有问题。配合jstack看线程堆栈如果线程阻塞在SocketInputStream.socketRead0说明正在等待对端数据此时对端大概率已经没响应了。3.3 一个最小复现实验为了看清这个过程我写过一个小实验来模拟“连接空闲后被服务端关闭客户端复用连接报错”。服务端用一个简单的 Java ServerSocket 实现先返回一次响应然后休眠几秒主动关闭连接ServerSocket server new ServerSocket(8080); while (true) { Socket socket server.accept(); new Thread(() - { try { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); OutputStream out socket.getOutputStream(); String line in.readLine(); if (line ! null) { String body HTTP/1.1 200 OK\r\n Content-Length: 2\r\n\r\nok; out.write(body.getBytes()); out.flush(); } Thread.sleep(5000); socket.close(); } catch (Exception ignored) { } }).start(); }客户端使用连接池复用同一个 HttpClientPoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(10); cm.setDefaultMaxPerRoute(5); CloseableHttpClient client HttpClientBuilder.create() .setConnectionManager(cm) .build(); String url http://localhost:8080/; HttpGet first new HttpGet(url); client.execute(first).close(); Thread.sleep(6000); HttpGet second new HttpGet(url); try { client.execute(second).close(); } catch (NoHttpResponseException e) { System.out.println(no response after connection idle: e.getMessage()); }第一次请求建立连接并放回池中休眠 6 秒后服务端已经关闭连接第二次请求复用旧连接等待响应头时读到 EOF于是抛出 NoHttpResponseException。这个实验虽然简单但能非常直观地复现问题适合在排查前先验证客户端版本、连接池行为是否符合预期。4. 三个层面的解决方案4.1 客户端连接池校验与空闲回收双管齐下客户端侧最核心的两个手段是“取连接时校验”和“主动回收空闲连接”。Apache HttpClient 4.x 中通过PoolingHttpClientConnectionManager配置PoolingHttpClientConnectionManager cm PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(200) .setMaxConnPerRoute(50) .setValidateAfterInactivity(2000) .evictExpiredConnections() .evictIdleConnections(30, TimeUnit.SECONDS) .build();如果用的是 4.x 的老 API也可以手动设置并启动一个后台清理线程PoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50); cm.setValidateAfterInactivity(2000); new Timer(true).schedule(new TimerTask() { Override public void run() { cm.closeExpiredConnections(); cm.closeIdleConnections(30, TimeUnit.SECONDS); } }, 0, 30000);validateAfterInactivity的作用是当连接空闲时间超过设定阈值单位毫秒在下次取用时先做一次校验如果发现连接不可用就丢弃并新建。这能有效减少复用僵尸连接的窗口。evictIdleConnections是主动把空闲超过 30 秒的连接从池中清理掉。两者配合基本能从客户端侧解决大部分空闲连接问题。但要注意validateAfterInactivity不是银弹。校验通过后到真正发送请求之间连接仍有可能被对端关闭所以这个配置只能缩小问题窗口不能完全消除还必须有重试兜底。4.2 客户端兜底针对连接失效重试是安全的重试是解决偶发连接失效的兜底手段。Apache HttpClient 自带DefaultHttpRequestRetryHandlerCloseableHttpClient client HttpClientBuilder.create() .setConnectionManager(cm) .setRetryHandler( new DefaultHttpRequestRetryHandler(3, true)) .build();DefaultHttpRequestRetryHandler默认会对NoHttpResponseException这类连接异常进行重试。这里说实话我对重试的态度是NoHttpResponseException 这个场景下重试相对安全因为异常抛出的语义是“没有收到响应”服务端大概率没有处理这个请求重试不会造成重复业务操作但如果是 POST 这类非幂等请求仍然需要结合业务判断不能无脑重试。更好的做法是自定义重试策略只对连接类异常重试并且加上重试次数限制和退避间隔。比如第一次失败后等 200ms第二次等 500ms避免瞬间重试风暴。重试次数建议 2 到 3 次再多只会放大延迟不会提升成功率。4.3 服务端与网关把空闲超时调到“大家都接受”的值光改客户端不够服务端和网关的超时参数也要同步看。目标是让全链路的空闲超时形成一个“客户端回收时间 服务端/网关断开时间”的安全不等式。以 Spring Boot 内嵌 Tomcat 为例可以调大空闲超时server.tomcat.keep-alive-timeout30s server.tomcat.max-keep-alive-requests100Nginx 作为反向代理时keepalive_timeout 65s; proxy_read_timeout 60s;如果前面还有云负载均衡需要去控制台确认 idle timeout 的值常见的默认值是 60 秒或 300 秒。具体参数要根据业务请求频率来调整如果客户端可能 30 秒以上没有请求就把客户端空闲连接回收时间设置为 20 秒左右而服务端空闲超时设置为 60 秒以上这样连接不会在客户端还在持有期间就被服务端关掉。我见过一个比较合理的配置组合客户端 evictIdleConnections 15 秒validateAfterInactivity 2 秒Tomcat keepAliveTimeout 60 秒Nginx keepalive_timeout 75 秒云 SLB idle timeout 不小于 300 秒。这个组合能覆盖大多数常规业务的空闲场景。4.4 不同方案选型与适用场景对照方案生效层解决的问题代价推荐度连接空闲校验客户端减少复用失效连接的窗口校验带来少量 I/O 开销必选空闲连接回收客户端主动剔除超时空闲连接可能增加新连接建立延迟必选请求重试客户端兜底偶发连接失效多一次请求延迟、幂等风险建议调大服务端超时服务端延长连接被关闭的时间占用更多连接资源强烈建议网关超时调优网关与前后端超时对齐全局参数谨慎评估建议5. 生产问题速查与经验补刀5.1 常见问题速查表场景可能原因定位方向处理方案低峰期后第一次请求集中报错连接被服务端或网关空闲回收看异常时间点与流量低谷对齐情况客户端启用空闲回收和校验随机偶发、无规律中间设备静默丢弃半开连接tcpdump 看 FIN/RST 时序重试兜底拉长连接存活周期压测高并发时出现连接池连接不足或复用过度监控连接池 leased、pending 指标调大连接池、优化服务端并发参数服务端发布重启后报错客户端仍持有旧连接对比发布窗口与异常时间客户端连接校验 优雅下线调用第三方接口偶发对方服务端空闲超时短结合对方文档确认超时参数客户端主动控制连接空闲时间5.2 排障经验值得记的几条先说连接池监控。很多人等到线上报错了才看日志其实连接池本身就有健康度指标比如 available、leased、pending把这几个指标接进监控能提前发现连接复用问题。available 一直很高说明空闲连接多再叠加客户端服务端超时配置不合理NoHttpResponseException 就是迟早的事。再说对异常的分类处理。我建议把 NoHttpResponseException 单独归类不要和 ConnectTimeout、SocketTimeout 混在一个告警里。这三类问题的处理和责任方完全不同合并告警只会增加排查噪音。日志里可以打上连接池的空闲时间和目标地址方便快速判断是哪个目标、哪条链路的连接出了问题。还有一个容易被忽略的点使用 Spring Boot 的 RestTemplate 或 Feign 时一定要确认底层确实复用了同一个 HttpClient而不是每个请求新建一个连接、建一个连接池。很多封装库如果使用不当表面上配了连接池实际请求时根本没有复用连接就会出现明明连接池配置了却依然大量抛 NoHttpResponseException 的诡异情况。排查时可以先抓一下客户端上到服务端的端口数量如果每个请求都新建端口那连接池就没生效。升级版本也值得留意。Apache HttpClient 4.5.x 对连接管理做了不少优化4.2 及以下版本很多连接管理的坑要靠手工配置规避。如果项目还停留在老版本遇到这个异常时可以先考虑升级再上其他策略。4.5.x 默认版本也更稳定重试机制、连接校验的默认行为都更合理。最后提一个实际的小技巧如果服务端和客户端都是自己的团队维护最简单有效的方案是直接约定一个“连接最长空闲时间”写入两边的配置规范。比如约定连接空闲超过 10 秒就必须由客户端主动回收服务端这边保证 30 秒内不会主动关闭空闲连接。两边按照统一标准配置会比各自调各自的参数省事很多也避免以后换人维护时参数对不上。我在实际运维中还遇到过一种情况服务端设置的 keepAliveTimeout 很短但客户端连接池的空闲回收时间更长这时把客户端回收时间调短就能立竿见影反过来客户端回收时间已经很短了还报错就要回头看是不是中间防火墙或网关在搞鬼。说到底这异常的根因就是“连接生命周期管理不一致”只要把全链路的空闲超时、连接校验、重试兜底这三层都安排明白NoHttpResponseException 就能被控制在极低的概率下剩下的偶发情况交给重试机制兜底线上基本不会再被它困扰。
返回列表