公司动态

服务在但请求超时?连接池与线程池排查框架实战

📅 2026/8/31 7:00:54
服务在但请求超时?连接池与线程池排查框架实战
半夜被手机震醒打开监控一看某个核心接口的失败率在五分钟内从 0.2% 飙到了 17%。登录跳板机去查服务进程还在端口还在监听Java 进程的内存和 CPU 也没有异常。但客户端日志里整整齐齐写着connect timed out偶尔还夹杂几条Connection refused。这就是典型的“触不可及”故障服务端看起来一切正常客户端却死活连不上或者连上了但迟迟等不到响应。更难受的是这种故障往往不是持续宕机而是间歇性出现持续几分钟后又自己恢复像雾一样抓不住现场。我把这类问题归到“第二十八集雾版”里来写就是因为它的现象模糊、日志零散、定位过程特别容易白费力气。这篇文章的核心判断是遇到“服务在但请求触不可及”第一反应不应该是去改网络设备或调大超时时间而是要做分层排查。大多数情况下问题出在连接池耗尽、线程池阻塞、accept 队列溢出、健康检查误判或者超时参数自相矛盾这几类应用层因素上。读完这篇文章你会得到一套从客户端到服务端的排查框架、一组可以直接上机的排查命令、几个关键配置示例以及避免下次再踩坑的工程建议。1. 这一集真正要解决的问题服务存活不等于请求可达在分布式系统里“服务在运行”和“服务能接受新请求”是两件完全不同的事。Java 进程还在不代表 Tomcat 的线程池还有空闲线程端口还在监听不代表内核的 accept 队列没有被占满健康检查返回 UP不代表下游的数据库连接池还能拿出连接。这也是为什么很多同学在排查现场时会陷入误区先把网络工程师叫来把防火墙和负载均衡翻一遍甚至把机器重启了再说。但重启只能暂时掩盖问题等流量上来故障又会原样返回。这一集要解决的不是某种特定框架的 Bug而是一类高频故障模式。无论你是做 Spring Boot 微服务、Dubbo 接口还是自建网关只要服务之间存在远程调用就可能遇到“触不可及”的间歇性故障。你需要掌握的是当现象不清晰时如何快速判断故障边界、缩小排查范围然后定位到确切的资源瓶颈或配置问题。我把它称为“雾版”也是因为这类故障的取证难度比普通报错高得多。普通异常会打印堆栈告诉你哪一行出了问题而连接超时往往只在客户端留下一个模糊的SocketTimeoutException服务端甚至连错误日志都没有。所以这篇文章的另一个重点是如何在“没有充分日志”的情况下通过系统命令、连接状态和配置清单重建故障现场。2. 触不可及的本质一次远程请求的生命周期要理解“触不可及”先要理解一次请求从客户端到服务端到底经历了什么。假设你有一个订单服务A要调用用户服务B。请求从 A 发出到拿到响应大致经过以下阶段DNS 解析把 B 的域名解析成 IP。TCP 三次握手客户端 SYN服务端 SYN-ACK客户端 ACK。如果有 TLS再增加握手过程。HTTP/微服务协议层客户端把请求交给服务端。服务端接入层内核把连接放入 accept 队列应用服务器如 Tomcat从队列中取出连接。应用处理线程池分配线程执行业务逻辑期间可能还要调用数据库或其他服务。响应返回数据从服务端写回客户端。任何一个环节阻塞客户端看到的表现可能只有一个超时或连接失败。但“超时”和“连接失败”本身还能再拆。Java 网络编程里常见的异常分两类ConnectException/connect timed outTCP 连接根本没有建立成功。Read timed out/SocketTimeoutExceptionTCP 连接建立成功了但服务端在设定时间内没有返回数据。这两类问题的排查方向完全不同。前者要看网络连通性、监听队列、防火墙、负载均衡后者主要看服务端线程池、下游依赖、业务处理耗时和 GC 停顿。可以打一个比方你打电话给客服。connect timed out相当于电话一直占线根本没人接听read timed out相当于电话通了但客服让你听音乐等了 20 秒你等不了主动挂了。电信运营商可能没问题但客服中心的坐席安排可能已经出了问题。所以面对“触不可及”的请求第一步不是看复杂链路而是先区分清楚请求到底是“没接上”还是“接上了但没响应”。判断清楚这一步排查范围就缩小了一半。还有一个容易忽略的环节是连接池。很多同学以为网络超时必然是网络问题但实际案例里服务端连接池撑满导致新请求排队等待是超时的主要原因之一。连接池和线程池虽然是应用层组件但它们的行为最终会体现在 TCP 连接状态上这就是为什么我们需要同时看应用配置和操作系统的连接状态。3. 从雾区开始先整理一份有效的信息清单模糊故障最大的敌人是信息不全。很多人在故障发生后直接上服务器抓包结果流量已经恢复什么都没抓到。更合理的第一步是尽可能地收集现场信息把“雾”变薄一点。我建议在告警触发之后立刻记录以下内容信息项为什么重要故障时间段判断是持续故障还是瞬时抖动涉及客户端与服务端 IP判断是全局限流还是单个节点问题失败接口与报错类型区分连接失败、读超时、业务异常客户端日志时间戳与服务端日志做时间对齐服务端 GC 日志排除 Full GC 导致请求停顿流量峰值判断是否到达容量边界最近变更记录发布、配置变更、扩缩容都可能诱发问题这里尤其要提醒一点日志时间一定要对齐。分布式系统里各台机器的时间可能存在偏移如果不做校验你可能会根据错误的时间线得出完全错误的结论。可以先在客户端和服务端分别执行date命令确认时间偏差在合理范围内。date %Y-%m-%d %H:%M:%S.%N同时把现场进程信息留下来ps -ef | grep java uptime free -h iostat -x 1 5uptime和free可以快速判断机器是否发生了负载飙升或内存不足。iostat可以帮助排除磁盘 I/O 抖动——这个因素很容易被忽略因为磁盘慢会导致线程阻塞最终表现为请求超时。在这个阶段你不需要立刻定位根因只需要尽可能把故障边界画出来。比如是只有一台服务端实例异常还是所有实例都异常是只有某个调用方受影响还是所有调用方都受影响是只有 HTTP 接口失败还是数据库操作也变慢这些边界信息会直接决定你接下来去查哪一侧。4. 客户端视角排查建不上还是连上不响应拿到现场信息后先从客户端侧开始验证。目标是回答两个问题当前网络路径是否可达以及失败模式到底是什么。最简单的一组命令是 telnet 和 nc。假设服务端 IP 是10.10.20.30端口是8080telnet 10.10.20.30 8080 nc -vz -w 3 10.10.20.30 8080如果端口通了说明 TCP 层是可连接的。如果端口不通可能原因包括服务端没监听、防火墙拦截、负载均衡后端摘除、网络路由异常。但 telnet 只能验证端口不能模拟完整的 HTTP 请求。更推荐用 curl 带上明确的超时时间curl -v --connect-timeout 3 --max-time 10 http://10.10.20.30:8080/api/ping输出里如果出现Connection refused说明服务端主动拒绝了连接通常是端口未监听或 accept 队列已满。出现Operation timed out则说明 SYN 包发出后没有收到响应可能是网络不通、防火墙丢弃包或者服务端内核队列已满来不及处理。出现Empty reply from server说明连接建立了但服务端没有返回任何 HTTP 响应就关闭了连接通常指向应用层异常。这里要特别提醒在测试环境验证命令不要直接在生产环境灌压测流量。如果你需要在高负载的节点上执行探测务必确认不会影响线上业务并遵守公司的变更审批流程。判断连通性之后还要看客户端的连接池状态。如果是 Java 应用可以查看客户端日志里是否有类似HikariPool-1 - Connection is not available, request timed out的报错。这是数据源连接池耗尽不是网络问题。或者观察HttpClient的连接池等待时间指标确认服务端迟迟没有释放连接。还有一个需要注意的点是代理和负载均衡。如果客户端通过 Nginx 或 Spring Cloud Gateway 访问服务端还要确认负载均衡的健康检查是否把异常节点自动摘除。实际故障中健康检查超时配置过短会导致健康检查本身失败把好的节点误摘除或者健康检查太频繁把服务端连接队列打满影响正常业务。5. 服务端视角排查端口、队列与连接状态客户端验证完成后再到服务端机器上看。不管应用层用什么框架先从操作系统层面确认端口监听情况ss -lntp | grep 8080看到LISTEN状态说明端口有进程在监听。如果看不到说明应用没有正常启动或被整体阻塞到了无法 accept 新连接的程度。更常见的情况是虽然处于LISTEN但 accept 队列已经积压。Linux 内核中每个监听端口有两个队列半连接队列SYN Queue和全连接队列Accept Queue。当客户端与服务器完成三次握手后连接会进入全连接队列等应用调用accept()取走。如果应用处理不过来全连接队列会积压新连接就可能被丢弃或触发客户端超时。通过ss -lnt可以查看全连接队列的溢出情况ss -lnt | awk {print $1, $2, $3, $4, $5}注意ss输出的第二列Recv-Q和第三列Send-Q。对于处于LISTEN状态的套接字Recv-Q表示当前已建立但尚未被应用 accept 的连接数量Send-Q表示队列最大长度。如果Recv-Q接近Send-Q说明 accept 队列可能已经接近满。这一项数值在故障期和非故障期对比尤其有价值。再看已建立的连接状态。CLOSE_WAIT数量异常增多通常意味着应用程序没有正确关闭 socket服务端收到对端关闭连接后一直停留在关闭等待状态。TIME_WAIT大量出现则多见于高并发短连接场景属于正常现象不必过度紧张。ss -antp | grep 8080 | awk {print $1} | sort | uniq -c这条命令会统计当前与 8080 端口相关的连接状态分布。正常情况下应该是大量ESTABLISHED少量TIME_WAIT。如果出现大量CLOSE_WAIT请重点检查应用代码中的资源释放逻辑比如 try-with-resources 是否漏写或者连接池回收策略是否过期。操作系统层面还有一个常见参数是net.ipv4.tcp_max_syn_backlog和somaxconn。它们会影响高并发下 accept 队列的最大长度。但我不建议一来就调大系统参数因为很多“队列满了”其实是应用处理能力不够而不是内核参数太小。盲目调大 backlog 只会让更多请求排队并不会提高处理速度。6. 应用层才是高发区线程池与连接池配置操作系统看完了如果一切正常问题大概率在应用层的资源管理上。这里有两个最容易出问题的池HTTP 线程池和数据库/下游连接池。Spring Boot 内嵌 Tomcat 的线程池参数很典型。默认情况下server.tomcat.threads.max是 200server.tomcat.accept-count是 100。如果请求进入速度超过线程处理速度多余的请求会进入等待队列。队列也满了之后新请求会收到连接失败或拒绝。下面是一个常见的配置示例server: port: 8080 tomcat: threads: max: 200 min-spare: 20 accept-count: 200 max-connections: 10000 connection-timeout: 2000注意connection-timeout的单位是毫秒含义是连接建立后等待请求报文的时间。如果业务本身处理很慢增大这个值并不能解决问题反而会让客户端更快超时重试。另一个高发区是数据库或下游服务连接池。以 HikariCP 为例spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 max-lifetime: 1800000maximum-pool-size太小高峰流量下连接会被取完请求就会等待connection-timeout表示等待获取连接的最长毫秒数超过后直接抛异常。很多偶发失败就是因为连接池大小和并发根本不匹配平时没事一到高峰期就“恰好”差几个连接。实际工程里我最推荐的做法是把连接池监控指标完整接出来。至少要看这几个数当前活跃连接数、等待获取连接的线程数、连接池创建/销毁速率。如果活跃连接经常逼近最大值说明池子太小该扩容如果活跃连接很低但请求还是超时那问题大概率在下游而不是连接池本身。另外超时参数之间必须互相匹配。比如服务端处理接口平均耗时 800ms但客户端设置的readTimeout只有 500ms那会出现一个恶性循环客户端不断超时重试服务端不断重复处理同样的请求压力进一步放大最终服务端也可能被拖垮。超时时间不是越小越好应该基于性能测试得到的 P99 耗时再乘一个合理系数。7. 让模糊故障变清晰日志、Trace 与可控验证“雾版”故障最终还是靠体系化的可观测性来解决。推荐在项目中提前做好三件事统一 Trace ID、指标埋点、健康检查分层设计。统一 Trace ID 的作用是让客户端日志和服务端日志能串起来。如果某个请求客户端记录了自己的 Trace ID但服务端日志没有对应字段排查就会非常吃力。至少要做到网关或入口处生成 Trace ID通过 Header 传递日志输出时带上。下面是 Spring Boot 中一个非常简单的思路不一定直接拿来生产用但方便理解Component public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; String traceId req.getHeader(X-Trace-Id); if (traceId null || traceId.isEmpty()) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }然后在logback.xml的 pattern 中加入%X{traceId}日志中就会出现 traceId 字段。健康检查的设计也要重视。很多服务把健康检查做成简单的“进程活着”但进程活着不代表服务可用。更合理的健康检查接口应该同时检查关键下游依赖的状态比如数据库连接、缓存连接、消息队列。但要注意健康检查不能太重否则会导致网关反复探活给服务本身造成压力。如果你判断可能是某个节点异常但又不能直接摘除生产节点可以在测试环境或灰度集群中做可控验证将可疑节点从负载均衡列表临时摘除观察整体错误率是否下降再决定是否重启或扩容。这个操作必须走变更流程保留操作记录避免误伤正常节点。不要直接在故障高峰期大面积重启服务那只会制造新的故障。8. 常见问题与排查方法一张可直接对照的表结合前面讲的链路我把最常见的“服务在但请求触不可及”现象整理成了一张排查表。问题现象可能原因排查方式解决方案connect timed out网络不通或防火墙丢弃 SYNcurl/telnet 测试查看服务端 tcpdump检查安全组、防火墙、路由确认变更记录Connection refused端口未监听或 accept 队列满ss -lntp 查看监听状态观察 Recv-Q确认应用启动扩容实例或优化处理速度大量 CLOSE_WAIT应用未正确关闭 socketss -antp 统计状态修复连接释放逻辑检查连接池回收策略偶发 read timed out服务端线程池打满或下游慢查看 Tomcat 线程池指标和慢调用日志扩容线程池优化下游调整超时数据库正常但接口超时数据库连接池耗尽检查 HikariCP 指标增大连接池优化 SQL增加缓存重启后短暂恢复又复发流量超出容量对比故障期 QPS 与压测结果扩容或限流降级新旧实例混跑时失败发布过程中连接被重置检查发布平台摘流逻辑先摘流量再发布完成再挂回这张表的价值在于每一个现象背后都对应了一张可执行的排查路径。实际故障中不建议拿着表从上往下逐条猜而是根据现象二分定位。先判断是建连失败还是读写失败再判断是客户端、网络还是服务端最后看应用层资源。9. 最佳实践与工程建议让“触不可及”不再神秘经历过几次夜班告警之后你会慢慢发现大多数“触不可及”都不是什么神秘问题而是基础工程没做到位。以下几点建议是比一次排障更有价值的积累。第一超时配置分层管理。在一个项目中建议把连接超时、读取超时、写入超时分别配置不要混成一个笼统的 timeout。连接超时通常很短比如 1 到 3 秒读取超时需要根据业务耗时设置一般在压测 P99 耗时的 2 到 3 倍重试超时则要整体考虑避免客户端重试风暴。第二重试必须幂等且受限。当请求超时后很多人第一反应是加大重试次数但这在分布式系统里可能造成雪崩。如果接口本身不是幂等的重试会导致重复下单、重复扣款。如果一定要重试也建议使用指数退避并设置最大重试次数通常不超过 3 次。第三监控告警不能只看错误率。四大黄金信号是延迟、流量、错误和饱和度。连接池使用率、线程池活跃数、accept 队列长度这类饱和度指标往往比错误率更早暴露风险。不要等客户端开始报错了才去处理应该让监控在资源临界之前发出预警。第四变更管控和回滚预案比排查技巧更重要。绝大多数间歇性故障都和变更相关某个配置项修改、某个依赖升级、某个实例缩容。排障时第一时间确认最近变更列表往往比抓包更快。每次发布都要有回滚方案生产环境的任何参数调优都要在测试环境验证后执行并记录变更人、时间和原因。第五故障演练要常态化。每个季度选择一个相对低峰时段模拟一台实例故障、模拟下游延迟增大验证系统的限流、降级、熔断机制是否真的生效。没有演练过的熔断配置只能在真正发生故障时给你惊喜。10. 总结与后续学习方向这一集围绕“服务在但请求触不可及”的故障从一条请求的完整生命周期讲到了客户端排查、服务端排查、应用层资源再到日志与可观测性建设。核心思路可以浓缩成六个字先分层再定位。不要在下层问题还没排除时就去上层找原因也不要在一开始就怀疑网络设备。如果你现在正面对一个“触不可及”的线上问题第一步先对照第 3 节的信息清单把现场记录下来第二步用 curl 和 ss 判断故障边界第三步检查连接池和线程池指标。这三步做完大部分场景已经能锁定方向。后续如果想继续深入可以沿着三个方向学习一是 Linux 内核网络状态机把ss、tcpdump、iperf这些工具用熟二是连接池和线程池的实现源码理解不同框架的资源治理差异三是服务网格或可观测性平台让 Trace、Metrics、Logs 真正形成闭环。掌握这些之后你会发现“触不可及”的故障也会慢慢变得透明可见。