
你是否有过这样的经历服务明明没宕机日志也还在打印但外部请求就是进不来或者回调接口偶发性超时调用方疯狂重试业务方疯狂追问最后发现是线程池队列堆积、数据库连接池被占满、甚至是死锁导致整个服务“假死”。前几天排查一个类似问题时同事盯着监控屏喊了一句“为什么不接六花的电话!!!”——六花是我们给回调服务起的代号而“不接电话”正好对应了服务不响应、接口超时、请求被拒绝这一整类线上故障。这类问题很典型不是宕机胜似宕机进程还在接口不通日志正常但请求超时。网上关于单一原因的分析很多但缺少一套从现象到根因的系统化排查闭环。本文围绕“服务不接电话”这个场景完整梳理从网络层、进程层、线程层到业务层的排查链路包含可复现的示例代码、常用排查命令、典型根因分析和生产环境的最佳实践。1. “六花不接电话”到底在说什么1.1 一句话理解问题“不接六花的电话”翻译成技术语言就是外部调用方向某个服务发起请求但迟迟得不到响应或者连接直接被拒绝、被重置。六花可以是任何一个负责接收请求的服务比如订单回调服务、消息推送服务、开放API网关甚至是内部RPC的Provider。这类问题的核心特征是服务进程没有退出CPU可能不高内存可能充足但请求就是处理不了。它比“服务宕机”更难排查因为监控面板上看不到明显的红色告警日志里也可能没有异常堆栈只是调用方反馈“超时了”“连不上”。1.2 它和普通故障的区别普通故障一般指进程崩溃、端口未监听、数据库连接失败这类“硬错误”错误信息明确定位路径清晰。“不接电话”则更多属于“软故障”或“假死”状态对比维度进程崩溃假死不响应进程状态退出ps 查不到存在ps 能查到端口监听消失可能还在监听日志输出停止可能正常也可能阻塞请求表现连接拒绝连接成功但无响应或连接超时排查难度较低较高1.3 常见应用场景这种问题在以下场景中特别容易出现回调接口支付回调、短信回调、第三方开放平台回调调用方有严格的超时限制。消息消费MQ消费端处理慢导致消息积压业务方感知为“电话打不通”。定时任务任务执行阻塞后续任务排队整个调度链路卡死。开放API对外提供的HTTP接口网关层超时时间短后端处理慢导致大量504。本文后面的排查示例会围绕一个模拟的“六花回调服务”来展开。它接收外部POST请求内部执行一段业务处理然后返回结果。我们要解决的是为什么外部请求进来了但响应出不去。2. 环境准备与版本说明先说明一下本文的演示环境。实际项目中的版本可能不同但排查思路和命令是通用的你可以根据自己环境的输出调整。2.1 演示环境组件说明操作系统CentOS 7.x / Ubuntu 20.04 均可JDKOpenJDK 1.8 或 11框架Spring Boot 2.7.xHTTP 客户端curl 命令排查工具telnet、ss、netstat、ps、jstack、top数据库可选MySQL 5.7用于演示连接池占满的情况如果你的环境是Spring Boot 3.x JDK 17核心排查命令一样只是部分JVM参数名可能略有差异不影响整体流程。2.2 示例项目结构为了便于复现问题我们创建一个最小化的Spring Boot项目目录结构如下liuhua-callback/ ├── pom.xml └── src/main/ ├── java/com/example/liuhua/ │ ├── LiuhuaCallbackApplication.java │ ├── controller/CallbackController.java │ └── service/CallbackService.java └── resources/ └── application.yml这个项目只暴露一个回调接口模拟“六花”对外接收请求的核心入口。2.3 Maven 依赖pom.xml中只需要引入Spring Boot Web起步依赖即可!-- 文件路径liuhua-callback/pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies这里使用Spring Boot 2.7.18是因为它基于JDK 8兼容性最好适合大多数存量项目。如果你的项目已经升级到Spring Boot 3.x使用对应的3.x版本即可代码层面的差异很小。3. 复现“不接电话”的场景排查问题之前能稳定复现是最重要的。复现不了的问题改完也不知道有没有修好。下面我们构造几个典型的“不接电话”场景。3.1 场景一接口处理线程被阻塞先写一个最简单的回调接口但故意让它在处理请求时进入长时间阻塞// 文件路径src/main/java/com/example/liuhua/controller/CallbackController.java package com.example.liuhua.controller; import com.example.liuhua.service.CallbackService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; import java.util.Map; RestController RequestMapping(/callback) public class CallbackController { Resource private CallbackService callbackService; PostMapping(/receive) public String receive(RequestBody MapString, Object payload) { // 模拟回调处理这里会阻塞线程 return callbackService.process(payload); } }// 文件路径src/main/java/com/example/liuhua/service/CallbackService.java package com.example.liuhua.service; import org.springframework.stereotype.Service; import java.util.Map; import java.util.concurrent.TimeUnit; Service public class CallbackService { public String process(MapString, Object payload) { // 模拟业务处理耗时过长线程长时间不释放 try { TimeUnit.MINUTES.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return success; } }启动服务后用curl发送请求curl -X POST http://localhost:8080/callback/receive \ -H Content-Type: application/json \ -d {orderId:20250101001}此时curl会一直卡住直到5分钟后才返回。对于调用方来说这就是“六花不接电话”端口能连通。TCP连接已建立。请求已发送。但响应迟迟不来。3.2 场景二Tomcat线程池被占满上面只是一个请求阻塞影响还不大。如果连续发起多个请求Tomcat默认的工作线程池很快就会被占满后续所有请求都会排队等待。Spring Boot内置Tomcat的默认最大工作线程数是200。但生产环境中如果连接池、线程池配置过小或者业务处理中有大量慢调用线程池占满是常态。为了快速复现可以在application.yml中把最大线程数调小# 文件路径liuhua-callback/src/main/resources/application.yml server: port: 8080 tomcat: max-threads: 5 accept-count: 2配置解释max-threadsTomcat最大工作线程数这里故意调成5。accept-countaccept队列长度超过队列长度后新连接会被拒绝或等待。然后并发发起10个请求for i in $(seq 1 10); do curl -X POST http://localhost:8080/callback/receive \ -H Content-Type: application/json \ -d {\orderId\:\$i\} done你会看到有5个请求在阻塞处理中5个在accept队列排队第11个请求可能直接被拒绝或长时间连不上。从调用方视角看就是“电话打不进去”。3.3 场景三数据库连接池耗尽更隐蔽的一种情况是接口本身没有死循环也没有长阻塞但每次请求都要获取数据库连接而数据库连接池已经满了。spring: datasource: url: jdbc:mysql://localhost:3306/liuhua?useSSLfalse username: root password: root hikari: maximum-pool-size: 3 connection-timeout: 5000这里的maximum-pool-size故意设置得很小。当3个连接都被占用且长时间不释放时第4个请求等待5秒后抛出连接超时异常。表现也是接口超时或者报错从外部看同样是“不接电话”。这三个场景分别对应三种典型根因业务代码阻塞、线程池资源耗尽、连接池资源耗尽。接下来我们就按照从外到内的顺序逐层定位。4. 系统性排查链路从现象到根因排查“服务不接电话”最忌讳一上来就翻代码。正确顺序是先确认网络通不通再确认端口和进程然后看线程状态最后才去看业务代码。4.1 第一层确认网络连通性第一步要区分是网络不通还是服务不处理。使用ping确认主机是否可达ping 192.168.1.100使用telnet确认端口是否可连telnet 192.168.1.100 8080如果端口能通说明TCP连接可以建立问题大概率在服务内部。如果端口不通可能原因包括服务没有启动。端口被防火墙拦截。服务监听的IP不是对外IP。使用curl验证HTTP层是否正常curl -v http://192.168.1.100:8080/callback/receive \ -X POST \ -H Content-Type: application/json \ -d {test:1}-v参数会输出完整握手过程和响应头。如果连接建立成功但一直卡住说明请求已经进入服务端但服务端没有给出响应。这一层要回答的问题请求到底有没有到达服务端4.2 第二层确认端口监听状态网络通了之后要确认服务的端口监听是否正常。ss -lntp | grep 8080输出示例LISTEN 0 200 *:8080 *:* users:((java,pid12345,fd88))如果看不到8898行说明服务可能没有启动或启动失败。如果看到了但Recv-Q和Send-Q数值异常偏大说明有大量连接积压。这里解释一下这两个队列Recv-Q已由内核接收但还未被应用读取的数据量。如果持续很大说明应用处理不过来或应用根本没有调用accept()。Send-Q已发送但未被对端确认的数据量。如果持续很大说明对端不读数据或网络拥堵。当大量请求堆积时Recv-Q会明显增长。这一步能确认请求已经到了操作系统的socket队列但应用没有及时处理。4.3 第三层查看进程状态和线程状态确认端口在监听后要确认进程本身是否“活”着。ps -ef | grep liuhua检查进程是否正常存在。然后使用top查看进程的CPU和内存占用top -p 12345观察进程状态S列。常见的状态有状态含义S睡眠通常是等待IO或锁R运行中或可运行D不可中断睡眠通常是IO阻塞Z僵尸进程如果大量线程处于S状态且CPU占用很低说明线程在等待某种资源——锁、连接、队列、网络响应都可能导致这种状态。为了进一步查看线程内部状态使用jstack导出线程快照jstack 12345 jstack_$(date %Y%m%d_%H%M%S).log在生成的线程快照中重点搜索BLOCKED、WAITING、TIMED_WAITING状态的线程。grep -n BLOCKED\|WAITING jstack_*.log | head -50如果看到大量线程处于WAITING状态并且堆栈指向同一个锁对象说明存在锁竞争或死锁风险。如果线程全部堆积在org.apache.tomcat.util.threads.TaskQueue说明Tomcat线程池已满新请求在排队等线程。这一层要回答的问题进程是否存活线程在等什么4.4 第四层检查数据库连接池和外部依赖如果线程快照显示线程都在等待数据库连接则需要检查数据库连接池的状态。Spring Boot默认使用HikariCP可以通过监控端点查看。在pom.xml中引入Actuatordependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency在application.yml中暴露监控端点management: endpoints: web: exposure: include: health,metrics然后访问curl http://localhost:8080/actuator/metrics/hikaricp.connections curl http://localhost:8080/actuator/metrics/hikaricp.connections.active curl http://localhost:8080/actuator/metrics/hikaricp.connections.pending三个指标的含义hikaricp.connections当前总连接数。hikaricp.connections.active正在使用的连接数。hikaricp.connections.pending等待获取连接的线程数。如果active等于maximum-pool-size同时pending持续增长说明连接池已经耗尽。此时优先检查数据库端的慢SQL和长事务。只需要一条SQL就能查询当前数据库连接SHOW PROCESSLIST;重点看Time列。如果存在大量Time很大的空闲连接或Command为Query但Time很长的会话说明数据库端可能也有阻塞。4.5 第五层检查线程死锁死锁是“假死”中最严重的一种。两个或多个线程互相持有对方需要的锁导致所有相关业务全部卡死。使用jstack可以自动检测死锁。在导出的线程快照末尾通常会有一段类似以下内容的输出Found one Java-level deadlock: http-nio-8080-exec-3: waiting to lock monitor 0x0000000000000000 (object 0x00000000d9f8a000, a java.lang.String), which is held by http-nio-8080-exec-4 http-nio-8080-exec-4: waiting to lock monitor 0x0000000000000000 (object 0x00000000d9f8a030, a java.lang.String), which is held by http-nio-8080-exec-3一旦出现Found one Java-level deadlock问题就很明确了。可以用kill -3命令直接让JVM输出线程快照到标准输出也可以使用jstack重定向到文件。避免死锁的根本手段是统一加锁顺序、减少锁粒度、使用带超时的锁。4.6 第六层分析业务日志如果线程状态正常连接池正常也没有死锁那就要回到业务代码本身。这个阶段需要借助日志。在application.yml中配置日志级别logging: level: com.example.liuhua: DEBUG在业务代码中增加关键日志PostMapping(/receive) public String receive(RequestBody MapString, Object payload) { log.info(收到回调请求参数{}, payload); try { String result callbackService.process(payload); log.info(回调处理完成结果{}, result); return result; } catch (Exception e) { log.error(回调处理失败, e); return failed; } }观察日志输出如果请求入口日志有打印但处理完成日志没有说明阻塞发生在process方法内部。如果连入口日志都没有说明请求没有进入Controller层问题在更前面的过滤器、拦截器或Servlet容器。如果异常日志频繁出现说明有未被捕获的异常或重试风暴。这一层要回答的问题请求走到了哪一步停在了哪里5. 快速止血与恢复方案排查问题需要在“定位根因”和“恢复业务”之间取得平衡。生产环境出问题时第一时间应该想办法让业务先恢复而不是在现场慢慢分析。5.1 重启服务最简单且有效的止血手段。如果服务是无状态服务或者有比较完善的集群负载均衡直接把问题实例重启流量自动切换到其他节点。# 以systemd管理的服务为例 systemctl restart liuhua-callback如果是Kubernetes环境kubectl rollout restart deployment/liuhua-callback重启能解决大部分“假死”问题因为它会释放所有线程、连接、锁。但重启后如果没有找到根因问题大概率还会复现。5.2 临时扩容如果确认是资源不足导致的问题可以临时增加实例数量分担压力。# Kubernetes 临时扩副本 kubectl scale deployment liuhua-callback --replicas5扩容的同时要检查上游调用方的超时配置是否需要同步调整。5.3 手动释放异常连接如果问题定位到数据库连接池被占用可以手动杀掉数据库端的异常会话让连接池中的连接尽快释放。-- 查看当前所有会话 SHOW PROCESSLIST; -- 杀掉指定会话谨慎操作 KILL thread_id;需要特别说明的是KILL操作要非常谨慎确保杀掉的是异常会话而不是正常业务会话。建议先找到长时间运行的SQL确认来源后再处理。生产环境操作前最好先与DBA确认。5.4 快速摘除故障节点如果服务是一个集群确认某一台实例异常后先从负载均衡中摘除该节点再慢慢排查。# 以Nginx为例将故障节点的server配置注释掉 # 然后reload配置 nginx -s reload这样就避免了新请求继续打到故障节点上同时保留现场用于排查。6. 常见问题与排查思路速查表根据经验整理一张“不接电话”类问题的速查表方便你遇到类似情况时快速对照。问题现象常见原因快速验证方法解决思路连接直接被拒绝服务未启动 / 端口未监听ss -lntpgrep 连接超时防火墙拦截 / 网络不通telnet ip port检查防火墙规则和网络策略连接建立但请求无响应Tomcat线程池满jstack查看线程堆积调大线程池、优化业务耗时偶发超时数据库连接池耗尽Actuator指标查看pending优化慢SQL、调大连接池接口完全卡死死锁jstack查看Found deadlock统一加锁顺序、使用带超时的锁部分请求超时慢调用/外部依赖超时查看日志耗时设置Feign/HTTP客户端超时、引入熔断大量请求排队accept队列溢出ss查看Recv-Q调大accept-count、增加实例请求正常但响应慢业务逻辑中长循环或IO阻塞打印耗时日志优化代码、异步化处理这张表的价值在于把“现象”和“可能原因”对应起来排查时可以直接按图索骥不用每次都从头开始试。7. 最佳实践与工程建议7.1 统一超时设置很多“不接电话”的故障根源其实是超时设置不合理。调用方超时时间太短服务端处理稍慢就会触发重试重试又加剧服务压力最终恶性循环。建议在项目中统一配置HTTP客户端和数据库连接的超时时间。spring: datasource: hikari: connection-timeout: 5000 validation-timeout: 3000 max-lifetime: 1800000同时也需要关注业务调用链路上的超时比如使用RestTemplate或OpenFeign时必须显式设置连接超时和读取超时。7.2 使用超时锁而不是无限期锁在并发场景中尽量使用tryLock而不是synchronized直接加锁避免锁竞争导致线程无限期阻塞。import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class OrderLockService { private final Lock lock new ReentrantLock(); public boolean tryProcess(String orderId) { try { if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务处理 return true; } finally { lock.unlock(); } } else { // 获取锁超时记录日志并快速失败 return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } }这段代码的原理是尝试在3秒内获取锁如果拿不到就返回失败而不是一直等待。生产环境中“快速失败 重试机制”往往比“无限等待”更可靠。7.3 健康检查必须包含核心依赖Spring Boot Actuator默认的健康检查只检查应用本身的存活状态。要让健康检查更有效应当把数据库、消息队列、Redis等核心依赖都加到健康检查中。management: health: db: enabled: true redis: enabled: true这样负载均衡器才能准确判断一个实例是否真正“健康”。如果数据库连不上健康检查直接返回DOWN流量就不会打到这个实例上。7.4 线程池隔离与监控对于关键回调接口建议使用独立的线程池避免和其他业务共用默认的Tomcat线程池。同时对线程池的核心参数进行监控活跃线程数。队列积压量。拒绝任务数。任务处理耗时分布。一旦发现队列持续增长就可以提前扩容或限流而不是等线程池满后才被动处理。7.5 日志中记录关键耗时排查“不接电话”问题时最怕的是日志里什么都看不到。建议在关键业务方法中记录耗时long start System.currentTimeMillis(); try { // 业务处理 } finally { long cost System.currentTimeMillis() - start; if (cost 1000) { log.warn(回调处理耗时过长cost{}ms, cost); } }当耗时超过阈值时输出告警日志这样问题还在酝酿阶段就能被发现。7.6 幂等与重试机制回调类接口天然需要面对重复请求。调用方因为超时会重试服务端因为处理慢可能被多次调用。因此回调接口必须实现幂等。简单做法是使用唯一请求IDpublic String process(MapString, Object payload) { String requestId (String) payload.get(requestId); // 先检查是否已处理 if (cacheService.exists(requestId)) { return success; } // 处理业务 // 处理完成后记录requestId cacheService.save(requestId, done); return success; }幂等设计不只是防止重复处理也是“不接电话”场景下的兜底方案即使请求被重试多次也不会产生脏数据。7.7 生产环境变更要谨慎如果排查到最后需要修改配置或代码必须遵循这些原则先在测试环境复现并验证修复方案。配置修改前备份原配置。代码发布采用灰度发布先让少量流量验证。数据库变更先备份尤其是涉及UPDATE和DELETE的语句。观察监控指标确认修复有效后再全量发布。生产环境的任何变更都应当可回滚。至少要做到代码可回滚、配置可回滚、数据可恢复。8. 总结与后续学习方向本文围绕“服务不接电话”这一典型线上故障从现象出发完整梳理了从网络连通性、端口监听、进程线程状态、连接池状态到业务日志的排查链路并提供了三个可复现的代码场景业务线程阻塞、Tomcat线程池占满、数据库连接池耗尽。排查这类问题时最重要的不是记住某条命令而是掌握排查顺序先确认请求有没有到服务端再确认服务端有没有在干活最后才去看代码哪里卡住了。这能帮你快速缩小范围避免在最外层浪费太多时间。同时生产环境遇到问题先恢复业务再查根因千万不要在现场反复调试而影响可用性。下一步你可以继续深入以下方向学习Tomcat线程池、HikariCP连接池的源码理解资源耗尽的内部机制。学习Arthas等在线诊断工具掌握thread、watch、trace等命令不用重启就能定位阻塞点。学习分布式链路追踪如SkyWalking、Zipkin从全链路视角分析慢调用到底慢在哪一环。学习JVM线程状态转换和死锁检测原理提升对线程Dump的分析能力。如果本文对你有帮助建议收藏备用。下次再遇到“为什么不接电话”的线上告警至少能少走几个小时的弯路。