ARTICLE DETAIL

资讯详情

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

接口不响应?一文掌握连接超时与读取超时的排查与防御

接口不响应?一文掌握连接超时与读取超时的排查与防御 明明刚才还连得上为什么现在就不接电话了“六花”不是你的女朋友也不是某位客服而是你在生产环境里调用率最高的那个服务。你客户端里那行http://sixflower/api/order就是你在拨出去的电话“Connection timed out” 这句话翻译成人类语言就是对方根本不接或者接了但一直不说话。这不是段子。这种问题每年都要在无数系统里重演一遍页面转圈、调用方疯狂重试、数据库连接被打满、告警群里全是“发送失败”。很多人第一反应是重启重启确实能救一次但救不了整个系统。真正有价值的能力是把“为什么不接电话”拆成一层层可验证的问题然后针对每一层给出对应的工程解法。这篇文章要和你一起做一件事用一个模拟服务复现“连接超时”和“读取超时”两类经典故障然后从网络层到应用层完整排查最后给出客户端超时设置、重试退避、幂等设计、健康检查、负载均衡和熔断降级的可落地建议。读完你能够独立完成一次接口不响应的排查并且知道怎么防止它再次发生。我的判断很明确接口不响应绝大多数不是网络“玄学”而是超时配置缺失、线程阻塞、连接池耗尽或健康检查失效的必然结果。下面开始动手。1. 服务不响应的本质一次调用就是一次“打电话”也许有些读者会问“为什么不接六花的电话”这个标题是不是在玩梗是但它把服务调用形容得很准确。这里的“六花”不是一个真实存在的产品名而是我们对一个后端服务实例的代号。你调用它的接口就是给它的 IP:端口 打电话。一次完整的业务调用至少要经历这么几个环节DNS 解析拿到对方的“手机号”也就是目标 IP。TCP 连接建立拨号并等待对方接通对应三次握手。TLS 握手如果走 HTTPS加密通话协商。发送 HTTP 请求你开始说话。服务端业务处理对方听清你的问题并思考答案。返回 HTTP 响应对方给出回答。任何一个环节卡住你最终看到的都是同一个结果调用失败或超时。这就解释了为什么这类问题特别容易让人迷茫因为“失败”这个最终表现太笼统了。如果不把调用过程拆开看你很难定位到底卡在哪一环。从这个角度总结排查“六花为什么不接电话”本质上是在排查这条调用链的哪一段断掉了。把过程拆开之后我们可以把超时问题分成两类后续排查工具的选择也会清晰很多。1.1 两类最容易混淆的超时一个客户端在发起请求时至少会涉及两个超时时间连接超时connect timeout从发起 TCP 连接到建立完成的等待时间。如果对方 IP 不可达、端口没监听、被防火墙丢弃就可能超时。连接超时更像是“电话一直打不通占线或者空号”。读取超时read timeout连接已经建立请求已经发出去了等待服务端返回响应的时间。如果服务端处理太久、线程阻塞、响应数据一直没到达连接虽然还在但客户端已经等不下去了。读取超时更像是“电话通了但对方沉默很久不说话”。两类超时的现象很像但定位方向完全不同。下面我会专门演示它们怎么被区分开。1.2 你看到的错误码不等于真实原因很多人一看到Connection reset、Connection refused、Read timed out、504 Gateway Timeout就觉得“网络有问题”。这是一种常见误判。举几个例子Connection refused往往是服务根本没监听端口或监听 IP 不对而不是网络断了。Read timed out可能是服务端业务慢也可能是反向代理没把请求转发进去。Connection reset可能是某个中间件主动断开了连接比如 Nginx 的超时时间比客户端短或者服务端连接池回收了空闲连接。也就是说错误提示只是入口真正的分析对象是每一次调用在哪个环节耗时多少以及服务端那一刻在忙什么。带着这个认知我们搭建实验环境。2. 搭建“不接电话”的实验环境先动手复现故障。这个步骤不需要多高的机器配置只要本地能跑 Python 3 和 curl。没有 Python 也可以跳过模拟服务直接对着你熟悉的某台测试服务器进行操作但为了把问题控制在小范围里我更建议你先在本地跑通。2.1 环境要求组件用途说明Python 3启动模拟 HTTP 服务版本没有强制要求3.6 即可curl发起 HTTP 请求Windows / Linux / macOS 基本都自带Java可选跑客户端超时示例JDK 8 即可一个终端观察日志和请求结果PowerShell 或 Bash 均可2.2 编写一个会“沉默”的模拟服务我特意设计了两个接口/health用于健康检查会立刻返回 ok/api/order用于模拟耗时很长的业务处理会故意睡眠 30 秒。这样我们就能复现“健康检查正常业务接口却超时”这种最常见也最迷惑的现象。# 文件路径mock_server.py from http.server import HTTPServer, BaseHTTPRequestHandler import time class SlowHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path /health: self.send_response(200) self.send_header(Content-Type, text/plain) self.end_headers() self.wfile.write(bok) elif self.path /api/order: # 模拟服务端业务处理耗时过长 time.sleep(30) self.send_response(200) self.send_header(Content-Type, application/json) self.end_headers() self.wfile.write(b{code:0,message:success}) else: self.send_response(404) self.end_headers() def log_message(self, format, *args): print(f[{self.log_date_time_string()}] {self.address_string()} {format % args}) if __name__ __main__: server HTTPServer((0.0.0.0, 8080), SlowHandler) print(mock server started at :8080) server.serve_forever()代码解释继承BaseHTTPRequestHandler实现do_GET方法。当请求路径是/health时直接返回ok响应很快。当请求路径是/api/order时调用time.sleep(30)模拟长耗时业务。其余路径返回 404。这里有一个细节Python 自带的HTTPServer是单线程模型一次只能处理一个请求。如果我们先用 curl 占用/api/order30 秒再发起另一个请求会看到连接排队并表现为“连接建立后迟迟没有响应”。这正好模拟了单线程服务或线程池被占满的场景。在生产环境里Java 应用线程池被打满也会有类似表现。2.3 启动服务并验证接口运行python3 mock_server.py看到mock server started at :8080就说明服务已经起来了。新开一个终端先测健康检查curl -i http://127.0.0.1:8080/health预期返回HTTP/1.0 200 OK Content-Type: text/plain ok再测慢接口curl -v --connect-timeout 3 --max-time 8 http://127.0.0.1:8080/api/order这里故意把总超时设置成 8 秒但接口默认 30 秒才返回所以大概率会超时退出。看到类似输出就说明我们复现了读取超时* Trying 127.0.0.1:8080... * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) GET /api/order HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: curl/8.4.0 ... * Operation timed out after 8001 milliseconds with 0 bytes received * Closing connection curl: (28) Operation timed out after 8001 milliseconds with 0 bytes received好故障已经复现了。接下来开始分层排查。3. 分层排查五层结构找到“失联”点在一个真实系统里调用链可能是客户端 - 网关/Nginx - 服务实例 - 数据库/外部依赖。所以排查原则是从外到内、从网络到应用先确定问题是否在你这一侧再往下追。3.1 第一层网络通不通先 ping 目标地址确认基本网络可达。但 ping 只能说明主机在网络层面是活的不能说明端口可用。ping -c 3 127.0.0.1如果 ping 不通方向就非常清楚了这是网络路由、防火墙或对端主机的网络配置问题。如果 ping 通继续看下一层。3.2 第二层端口有没有人接使用 nc 探测端口nc -zv 127.0.0.1 8080如果端口没监听你会看到类似Connection refused如果端口正常会输出类似Connection to 127.0.0.1 port 8080 [tcp/http-alt] succeeded!。这一步用来区分服务到底有没有起来还是被网络策略挡在外面。如果端口拒绝去服务端看进程是否存活、监听地址是否正确。比如用ss -lntp | grep 8080检查ss -lntp | grep 8080看到LISTEN状态说明端口监听正常看不到就需要回到服务本身排查启动失败原因。注意不要只听运维说“服务在跑”要以端口监听结果为准。3.3 第三层HTTP 到底有没有被正确响应端口通不代表接口没问题。用 curl 的 verbose 输出可以看到 TCP 握手是否成功、请求头是否发出、响应头是否回来curl -v --connect-timeout 3 --max-time 8 http://127.0.0.1:8080/api/order示例输出里会有这一段* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) GET /api/order HTTP/1.1 Host: 127.0.0.1:8080这说明 TCP 和 HTTP 请求都已经发出去了但服务端迟迟没有响应体。也就是说问题不在你到服务器的“电话线路”而在服务器接起电话后一直没说话。如果在这个阶段看到 4xx、5xx那就不是“不接电话”而是“接了电话但拒绝你”。这类问题需要去看业务逻辑、鉴权和网关配置排查方向完全不同。3.4 第四层应用线程卡在哪里当网络和端口都没问题但业务接口迟迟不返回就要看服务端内部。首先看服务端日志有没有线程或者业务日志打印到一半就停了有没有慢 SQL 告警有没有锁等待。其次Java 应用可以打印线程栈jstack pid /tmp/thread_dump_$(date %F).txt这里必须强调这个操作在生产环境执行前要征得团队授权并尽量在低峰期进行。拿到线程栈后重点看RUNNABLE和WAITING/BLOCKED状态的线程。如果发现大量线程停在SocketInputStream.read、LockSupport.park、JDBC 连接获取等位置往往就接近问题核心了。再看数据库侧。MySQL 中可以直接查询当前正在执行的语句SHOW FULL PROCESSLIST;如果看到大量Waiting for table metadata lock或Sending data状态且耗时长说明数据库成了瓶颈。此时客户端收到读取超时只是表象真正的根因在数据库慢查询、锁或者连接池不足。如果服务端使用连接池比如 HikariCP连接池耗尽会表现为线程等待getConnection。从连接池监控指标中可以看到 active 连接数等于最大连接数等待线程数持续上升。3.5 第五层集群与网关怎么处理单实例没问题不代表整个链路没问题。如果前面还有网关需要确认转发规则和目标地址。常见坑Nginx 反代指向服务 A但服务 A 被注册中心摘除后端实际已经没有可用实例。网关到后端服务的连接超时设置太短而后端业务本身就是长任务导致网关提前掐断。服务实例还在但健康检查接口返回失败负载均衡器已经把它下线。所以排查到这一层时要同时看注册中心里服务实例的状态、网关/负载均衡的 upstream 配置和日志、健康检查结果。下面给出一段 Nginx 常见超时与重试配置。注意这不是唯一标准具体参数以你的 Nginx 版本为准upstream sixflower-server { server 192.168.1.10:8080 max_fails3 fail_timeout30s; server 192.168.1.11:8080 max_fails3 fail_timeout30s; } server { listen 80; location /api/ { proxy_pass http://sixflower-server; proxy_connect_timeout 5s; proxy_read_timeout 10s; proxy_send_timeout 10s; proxy_next_upstream error timeout http_502 http_503; proxy_next_upstream_tries 2; } }这里的思路是如果第一台实例连接失败、超时或返回 502/503Nginx 会自动尝试下一台最多尝试 2 次。这样一来单实例的“不接电话”不会再直接暴露给客户端。4. 代码层面的防御超时、重试与幂等排查很重要但比排查更重要的是让系统在“六花偶尔不接电话”时还能正常工作。先从客户端代码做起。4.1 客户端必须显式设置超时很多标准库默认超时是“无限等待”一旦服务端不返回客户端线程就永远挂住。这在生产环境里非常危险因为连接池和线程池都会被慢慢占满。用 Java 的HttpURLConnection写一个最基础版本// 文件路径CallSixFlowerClient.java import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; import java.net.SocketTimeoutException; public class CallSixFlowerClient { public static String call(String urlStr, int connectTimeoutMs, int readTimeoutMs) throws Exception { HttpURLConnection conn (HttpURLConnection) new URL(urlStr).openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(connectTimeoutMs); conn.setReadTimeout(readTimeoutMs); int code conn.getResponseCode(); try (BufferedReader reader new BufferedReader(new InputStreamReader(conn.getInputStream()))) { StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) { sb.append(line); } return HTTP code : sb; } } public static void main(String[] args) throws Exception { try { System.out.println(call(http://127.0.0.1:8080/api/order, 3000, 3000)); } catch (SocketTimeoutException e) { System.out.println(六花还是没接电话: e.getMessage()); } } }关键点setConnectTimeout控制建立 TCP 连接时的等待时间。setReadTimeout控制连接建立后等待响应的最大时间。两个值都不是越大越好。连接超时一般 2 到 5 秒读取超时视业务而定普通同步接口 5 到 10 秒比较常见但不能直接照搬需要结合业务耗时和 P99 指标。用 Spring 的RestTemplate时也要显式设置Configuration public class RestTemplateConfig { Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(2000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }如果不设置老版本RestTemplate在某些实现下也可能无限等下去。这是一个容易被忽略的坑。4.2 不是所有接口都适合重试重试要满足两个前提错误值得重试。比如Connection timeout、Read timeout、503 Service Unavailable大概率是临时问题可以重试。接口支持幂等。重试机制无法保证第一次请求在服务端已经执行了一半。比如你已经提交了一笔订单服务端在返回响应前崩溃了客户端重试就会导致重复下单。所以对于写接口必须有幂等键或业务侧去重逻辑例如请求头里带一个唯一的Idempotency-Key服务端用 Redis 或数据库唯一约束去重。如果接口不幂等重试不是防御而是事故放大器。4.3 Java 实现带指数退避的重试指数退避的意思是第一次失败后等较短时间然后再逐步增大等待时间比如第一次 500ms第二次 1000ms第三次 2000ms避免短时间内发起重试风暴。// 文件路径RetryClient.java import java.net.HttpURLConnection; import java.net.URL; import java.net.SocketTimeoutException; public class RetryClient { public static String requestWithRetry(String urlStr, int timeoutMs, int maxAttempts) throws Exception { int attempt 0; while (attempt maxAttempts) { attempt; try { HttpURLConnection conn (HttpURLConnection) new URL(urlStr).openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); int code conn.getResponseCode(); if (code 502 || code 503 || code 500) { System.out.println(第 attempt 次返回 code 准备重试); } else { return HTTP code success; } } catch (SocketTimeoutException e) { System.out.println(第 attempt 次超时: e.getMessage()); } catch (java.net.ConnectException e) { System.out.println(第 attempt 次连接被拒绝: e.getMessage()); break; // 连接拒绝通常没必要重试服务可能未启动 } if (attempt maxAttempts) { break; } long waitMs (long) Math.min(Math.pow(2, attempt - 1) * 500, 4000); System.out.println(等待 waitMs ms 后重试); Thread.sleep(waitMs); } throw new Exception(重试 maxAttempts 次后仍然失败); } public static void main(String[] args) throws Exception { String url http://127.0.0.1:8080/api/order; try { System.out.println(requestWithRetry(url, 2000, 3)); } catch (Exception e) { System.err.println(最终失败 e.getMessage()); } } }代码逻辑说明循环里先发起请求收到 5xx 或超时后进入重试流程。遇到连接拒绝时直接退出因为服务没启动时再重试也只是浪费资源。每次重试间隔通过min(2^(attempt-1) * 500, 4000)计算做一个简单的指数退避。达到最大次数后抛出异常由上层业务决定降级逻辑。实际项目中不建议每个人都手写一遍重试可以用 Spring Retry、Resilience4j 或微服务框架自带的 Retry 组件。但是自己写一遍能更好理解其中的设计判断。5. 服务端与运维侧的兜底策略客户端防御到位后服务端也要配合否则那台“六花”还是不接电话。5.1 健康探活接口健康检查接口应该是轻量的不能把数据库查询这种重逻辑放进去否则数据库一抖动健康检查就失败实例被反复摘除和加入流量分配反而更不稳定。Spring Boot Actuator 提供了一个常用配置management: endpoints: web: exposure: include: health,info endpoint: health: probes: enabled: true/actuator/health/liveness表示进程是否还活着/actuator/health/readiness表示是否已经准备好接收流量。在 Kubernetes 中这两个探针分别对应livenessProbe和readinessProbe。思路是存活探针挂了就重启就绪探针挂了就先不下发流量而不是直接重启实例。5.2 熔断降级超时和重试能做到“接不到电话时保持耐心”但不能解决“对方一直生病”的问题。如果六花已经持续报错 30 分钟你还在无限重试只会把流量继续打到一个不健康的实例上。此时需要熔断。熔断器的核心状态机是关闭 - 打开 - 半开。当错误率超过阈值时断路器打开后续请求快速失败不再真实调用服务经过一个冷却窗口后进入半开状态放少量请求探活成功率达到预期则恢复关闭。Java 生态里常用的有 Resilience4j阿里的 Sentinel 也提供了类似能力。具体配置按你的技术栈来这里不展开。5.3 优雅上下线和连接池管理还有一个常见原因容易被忽略服务发布的时候新实例已经启动但旧实例还在被调用或者新实例启动时连接池还没就绪流量就进来了。这会导致发布期间偶发超时。解决办法是启用优雅上下线停止接收新流量后先让在途请求处理完再关闭新实例注册到注册中心前先通过健康检查。具体实现和你的注册中心、发布平台紧密相关。连接池方面建议给每一次请求增加连接获取超时例如connectionRequestTimeout防止线程无限期等待连接池资源。6. 运行结果与效果验证现在回到实验环境完整走一遍验证流程。先启动 mock 服务终端 Apython3 mock_server.py然后终端 B 执行健康检查curl -i http://127.0.0.1:8080/health预期输出HTTP/1.0 200 OK Content-Type: text/plain ok再执行带超时的慢接口调用curl -v --connect-timeout 3 --max-time 8 http://127.0.0.1:8080/api/order预期会在 8 秒左右退出并看到类似Operation timed out after 8001 milliseconds的提示。这验证了“连接建立成功但读取响应超时”。接着演示 Java 客户端。先编译运行javac CallSixFlowerClient.java java CallSixFlowerClient因为客户端设置了 3 秒读取超时而服务端/api/order要 30 秒才返回所以预期输出是六花还是没接电话: Read timed out最后可以尝试把time.sleep(30)改成time.sleep(2)再运行客户端预期拿到正常响应HTTP 200 : {code:0,message:success}判断成功的标准很简单客户端不再无限等待错误信息能明确指向“连接超时”或“读取超时”并且服务端日志能看到请求已经进来。如果以上都能闭环说明你已经具备排查一次接口不响应事故的基础能力。7. 常见问题与排查方法这里把生产环境中常见的“六花不接电话”场景汇总成一张表。这张表的价值不在背下来而是在下次告警时能快速对号入座。问题现象可能原因排查方式解决方案connect timeoutIP/端口不可达、防火墙丢弃报文ping、nc、telnet、traceroute放通防火墙、确认服务监听、检查路由connection refused服务未启动或监听地址不正确ss/netstat、查看进程状态启动服务、修改监听 IP 为 0.0.0.0 或具体网卡连接成功但 read timeout服务端线程阻塞、慢 SQL、锁等待看服务端日志、jstack、show full processlist优化 SQL、修复锁竞争、谨慎调整线程池偶发性超时连接池耗尽、GC 停顿、网络抖动连接池监控、GC 日志、APM 耗时调大连接池、优化 GC、增加重试但控制频率网关报 504上游耗时超过代理超时时间对比客户端超时和网关超时调整 Nginx proxy_read_timeout、优化上游服务在注册中心显示在线但调用失败实例启动但未就绪、健康检查失效看实例日志、探活接口、注册中心详情完善 liveness/readiness 探针、优雅上下线发布重启后前几分钟大量超时注册中心发现延迟、缓存失效、连接池冷启动观察发布后流量曲线和日志预热缓存、延迟注册、先小流量验证这张表重点想说明一个判断一旦现象被翻译成具体错误类型排查范围至少缩小一半。不要直接问“服务为什么慢”而要先问“是连接超时读取超时还是拒绝连接”。8. 最佳实践与工程建议围绕“服务不响应”这个主题把一些工程规范收敛成几条建议。8.1 给每一个外部调用设置合理的超时无论是 HTTP、RPC 还是数据库连接都要有超时。超时时间不要统一复制建议根据接口的真实耗时分布通常是 P99来定再留出 20% 到 30% 的余量。没有压测和监控就不要拍脑袋写大数字。8.2 重试必须有限次并配合退避和抖动指数退避能解决一部分重试风暴问题但同一时刻大量客户端同时重试还是可能把服务端压垮。因此可以引入随机抖动让每次重试时间不完全一致这样能有效避免惊群效应。8.3 写接口必须幂等只要你的系统存在重试就必须假设同一个请求可能被服务端执行多次。在请求头或业务参数中增加幂等键服务端用唯一索引、Redis 或状态机做去重。这是工程底线不要等到重复下单事故出现后才补。8.4 日志要能串起一次完整调用排查超时最怕“日志断片”。建议至少记录请求入参、出参、耗时、错误码和 traceId。如果引入了链路追踪一个 traceId 就能串起网关、服务和数据库调用排查时间会从小时降到分钟。8.5 生产操作要有权限边界使用 jstack、tcpdump、重启服务、改网关配置等都属于生产风险操作。执行前务必确认自己是否有权限操作是否在变更窗口内先备份并在变更后立即观察告警。能通过监控和日志解决的问题尽量不要在线抓包。8.6 用故障演练验证高可用设计如果你已经配置了超时、重试、熔断和健康检查那几百行配置到底有没有生效最好的验证方式是做一次故障演练杀掉一个服务实例观察客户端是否自动切换让服务接口 sleep 5 秒观察调用方是否快速失败把 Nginx 的一台 upstream 改成错误地址观察故障转移是否生效。这类演练可以在测试环境定期做避免高可用设计只是“纸面上存在”。9. 总结从“为什么不接电话”到“不接电话也不慌”这篇博客围绕一个很形象的场景展开调用远端服务失败就像是“六花不接电话”。我们把一次调用拆成 DNS、TCP、TLS、HTTP 请求、业务处理、HTTP 响应六个环节再用模拟服务复现了连接超时和读取超时随后给出了五层排查路径网络、端口、HTTP、应用线程、集群网关。真正要拿走的不只是命令而是一套防御性设计意识。客户端要设置超时、有限重试并保证幂等服务端要提供轻量健康探活、优雅上下线和连接池管理中间件要有故障转移、熔断降级和可观测性。这样做的目标是即使“六花”真的不接电话你的系统也能在短时间内完成判断、隔离和降级而不是把故障扩散到整条调用链。下一步建议你用文中的示例在本地搭建一个故障环境先把连接超时和读取超时都亲手复现一遍再对照排查表做一次演练。遇到新问题时优先记录现象、错误类型和链路 traceId。排查经验积累得越多你写的代码就越少会依赖“重启解决一切”。
返回列表