ARTICLE DETAIL

资讯详情

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

服务空转问题排查:从现象到根因的完整复盘

服务空转问题排查:从现象到根因的完整复盘 “4 hours and 37 minutes of serving nothing”——一次服务空转问题的完整排查复盘如果你在监控面板上看到这样一行记录某个服务进程已经运行了 4 小时 37 分钟端口正常监听健康检查偶尔通过但业务请求处理数量为 0你会怎么想这不是段子而是后端开发中非常典型的一类问题服务进程活着业务却已经完全“空转”。所谓 serving nothing不是说服务器没有返回任何内容而是说请求进来之后没有得到真正的业务处理要么被无限期排队要么被错误地丢弃要么在某个隐藏的瓶颈处原地打转。这篇文章我从问题现象出发梳理服务空转的常见原因、排查工具和完整定位思路并给出一个可复现的实战案例。读完你可以掌握区分“服务不可用”和“服务空转”的本质差异。用一套通用命令快速判断服务卡在哪个环节。通过线程栈、连接池状态、队列深度定位根因。在代码层面提前规避这类问题。无论你是刚接触后端开发还是已经在维护线上服务这套排查思路都能直接复用。1. 背景与核心概念1.1 什么是“服务空转”先来解释这个概念。正常情况下一个 Web 服务从收到请求到返回响应会经过一条完整链路客户端请求 → 网关/负载均衡 → Web 容器/接口层 → 业务逻辑 → 数据库/缓存/外部调用 → 返回响应如果这条链路的任何一个环节出现阻塞而进程本身又没有退出就会出现“进程活着、端口在听、请求没结果”的现象。我把这种情况称为“服务空转”。它和“服务宕机”最大的区别在于对比维度服务宕机服务空转进程状态进程不存在或已退出进程存活端口状态端口未监听端口正常监听健康检查大概率失败可能成功也可能失败错误日志有异常堆栈可能没有任何异常用户感知连接拒绝请求超时或一直转圈正是因为这个区别服务空转比宕机更难排查。宕机时有异常堆栈可查但空转时一切看似正常实际上业务已经完全停滞。1.2 为什么会出现空转根据我接触过的项目经验服务空转通常由以下几类原因引起线程池耗尽核心线程全部阻塞在慢调用上新请求进入队列排队队列满后拒绝或无限等待。连接池耗尽数据库连接池、HTTP 连接池被占满业务代码在等待获取连接时阻塞。死锁多线程竞争同一把锁形成循环等待相关线程永远无法继续执行。GC 长时间停顿内存不足或 GC 配置不合理JVM 频繁 Full GC业务线程长时间暂停。有界队列无界异步任务请求先被接收但异步队列积压消费者线程处理不过来请求迟迟得不到业务响应。锁等待与分布式锁超时业务依赖的分布式锁未释放后续请求全部阻塞等待。所以“服务空转”不是某一个具体技术组件的问题而是一类系统性问题。排查时要同时关注进程、线程、连接池、队列、GC 多个层面。1.3 为什么开发者需要掌握这类排查能力在实际开发里你和这个问题的距离可能比想象中更近。一个常见的场景是功能上线后测试环境一切正常但生产环境流量一上来接口响应越来越慢最后完全卡死。这时候如果没有系统化的排查思路很容易陷入“重启一下试试”的循环。而重启只能临时恢复无法解决根因过一段时间问题会再次出现。掌握服务空转的排查方法本质上是提升你对服务运行时状态的理解不是只看代码写对了没有还要知道代码在运行时会如何与线程、连接池、队列、GC 交互。这种能力在线上问题处理、性能调优、容量评估中都是必备的。2. 环境准备与排查工具清单排查服务空转第一步是把工具准备好。这里列一套通用工具清单适用于大多数 Java 后端服务其他语言栈也可以找到对应替代品。2.1 运行环境本文的案例以 Spring Boot 应用为例运行环境如下操作系统LinuxCentOS 7 / Ubuntu 20.04 均可 JDKJDK 8 或 JDK 11 框架Spring Boot 2.x 构建工具Maven 3.6如果你本机环境版本不同不需要完全一致。重点在于理解排查思路命令和代码需要按实际版本做微调。2.2 核心排查工具工具用途来源top / htop查看进程 CPU、内存占用Linux 自带jps查看 Java 进程 PIDJDK 自带jstack导出 Java 线程栈JDK 自带jstat查看 JVM GC 与内存状态JDK 自带netstat / ss查看端口和连接状态Linux 自带lsof查看进程打开的文件和网络连接Linux 自带curl模拟客户端请求Linux 自带Arthas在线诊断 Java 应用可查看线程、反编译、动态日志开源工具在开始排查之前建议先把这些命令记在笔记里。线上问题发生的时候每一分钟都很宝贵临时查命令会耽误很多时间。2.3 示例项目结构本文后面的模拟案例会用到下面这个项目结构service-stuck-demo/ ├── pom.xml └── src/main/java/com/example/stuck/ ├── StuckDemoApplication.java ├── controller/StuckController.java ├── service/AsyncTaskService.java └── config/AsyncConfig.java这是一个最小的 Spring Boot 项目。你可以用 Spring Initializr 创建也可以直接照着下面的代码手动搭建。3. 核心原理一条请求是如何“消失”的在开始模拟和排查之前需要先理解请求在服务内部的完整生命周期。只有知道正常情况下请求应该经历哪些步骤才能判断它到底在哪一步出了问题。3.1 请求处理链路以 Spring Boot内嵌 Tomcat为例一个 HTTP 请求的典型处理链路如下Tomcat 接收请求 → 分配工作线程 → 进入 Spring MVC 的 DispatcherServlet → 路由到对应 Controller → 执行业务逻辑可能调用 Service、DAO → 访问数据库/缓存/第三方接口 → 返回响应这条链路上的资源是有上限的Tomcat 默认的工作线程数有限默认配置通常在 200 左右。数据库连接池的连接数有限例如 HikariCP 默认 10。线程池队列的长度有限或无限。外部接口的响应时间不可控。当请求数量超过某个资源的承载上限时后面的请求就会排队等待。等待时间一长用户看到的就是请求超时或一直在转圈。3.2 空转的三个典型位置服务空转最常见的三个位置是第一Tomcat 工作线程耗尽。当所有 Tomcat 工作线程都阻塞在慢业务逻辑上时新请求无法获得线程只能在 Tomcat 的 accept 队列里等待。这时候通过jstack会看到大量线程处于WAITING或TIMED_WAITING状态而且线程名大多类似http-nio-8080-exec-XX。第二业务线程池队列积压。很多项目为了提升性能会把耗时的业务放到自定义线程池异步执行。如果线程池配置不合理比如核心线程数太小、队列无界就会导致任务大量积压在队列里。请求已经返回了“已受理”但实际上业务处理还在排队。第三数据库连接池等待。如果某个慢 SQL 占住了所有数据库连接其他需要访问数据库的请求就会在getConnection()处阻塞等待。jstack上能看到大量线程阻塞在HikariCP的getConnection调用上。3.3 为什么日志可能“什么都没有”这是服务空转最迷惑人的地方。按直觉来说服务出了问题就应该有报错日志但空转的时候经常什么异常都没有。原因在于线程池阻塞、连接池等待、队列积压都属于“等待”不是“异常”。代码里的try-catch只能捕获异常捕获不了等待状态。业务代码既不会抛错也不会打印日志只是安静地停在那里。所以排查空转问题不能只看应用日志必须通过线程栈、连接状态、队列深度这些运行时数据来判断。4. 完整实战案例从“看似正常”到“定位根因”这一节我们构造一个典型的异步任务积压场景模拟服务空转问题然后完整走一遍排查过程。4.1 创建项目结构先创建一个 Maven 项目在pom.xml中引入 Spring Boot 的 Web 依赖。!-- 文件路径service-stuck-demo/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 版本调整这里只是示例。核心目的是演示异步队列阻塞导致的空转问题。4.2 编写启动类// 文件路径src/main/java/com/example/stuck/StuckDemoApplication.java package com.example.stuck; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; EnableAsync SpringBootApplication public class StuckDemoApplication { public static void main(String[] args) { SpringApplication.run(StuckDemoApplication.class, args); } }EnableAsync表示开启 Spring 的异步执行能力这样我们才能观察到线程池积压效果。4.3 配置异步线程池这里定义一个自定义线程池。为了让问题更容易复现把核心线程数设得小一点。// 文件路径src/main/java/com/example/stuck/config/AsyncConfig.java package com.example.stuck.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.ThreadPoolExecutor; Configuration public class AsyncConfig { Bean(businessTaskExecutor) public ThreadPoolTaskExecutor businessTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数2 executor.setCorePoolSize(2); // 最大线程数5 executor.setMaxPoolSize(5); // 队列容量设置一个比较大的队列模拟积压 executor.setQueueCapacity(1000); // 线程名前缀方便在 jstack 中识别 executor.setThreadNamePrefix(biz-thread-); // 拒绝策略由调用者线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里的关键点是核心线程数只有 2意味着同时最多只有 2 个异步任务在执行。队列容量 1000超过核心线程数的任务会先进入队列排队。如果队列也满了拒绝策略是CallerRunsPolicy即由提交任务的线程自己去执行。在实际项目中queueCapacity是无界队列的风险最大。无界队列会导致任务无限堆积内存不断上涨最终引发 OOM 或 GC 频繁。4.4 编写异步任务和接口定义两个类一个模拟慢速异步任务一个提供 HTTP 接口触发任务。// 文件路径src/main/java/com/example/stuck/service/AsyncTaskService.java package com.example.stuck.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; Service public class AsyncTaskService { private static final Logger log LoggerFactory.getLogger(AsyncTaskService.class); Async(businessTaskExecutor) public void executeSlowTask(int taskId) { log.info([{}] 任务开始执行, taskId); try { // 模拟一个执行耗时 10 秒的任务 Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } log.info([{}] 任务执行完毕, taskId); } }// 文件路径src/main/java/com/example/stuck/controller/StuckController.java package com.example.stuck.controller; import com.example.stuck.service.AsyncTaskService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/demo) public class StuckController { Autowired private AsyncTaskService asyncTaskService; GetMapping(/submit) public String submit(RequestParam(value taskId, defaultValue 0) int taskId) { // 每调用一次提交一个异步任务 asyncTaskService.executeSlowTask(taskId); // 接口立刻返回但真正的业务处理还在异步队列里排队 return task taskId submitted; } }注意这个接口的设计方式它接收请求后立刻把任务丢给异步线程池然后马上返回“提交成功”。如果提交了大量任务而异步线程池处理不过来业务就会积压。但这和真实空转还有一点距离——因为 Tomcat 线程本身没有被阻塞。为了让问题更接近“服务空转”我们再写一个同步阻塞接口。// 文件路径src/main/java/com/example/stuck/controller/StuckController.java追加内容 GetMapping(/sync) public String sync(RequestParam(value taskId, defaultValue 0) int taskId) throws InterruptedException { // 模拟一个执行耗时 3 秒的同步业务 Thread.sleep(3000); return sync task taskId done; }这样服务里既有异步积压又有同步慢接口能够更真实地模拟线上场景。4.5 启动服务并制造“空转”启动 Spring Boot 应用mvn spring-boot:run启动成功后先用一个循环向/demo/submit持续提交异步任务for i in $(seq 1 50); do curl http://localhost:8080/demo/submit?taskId$i echo done因为每个异步任务要执行 10 秒而核心线程只有 2 个所以很快队列里就会积压 40 多个任务。接着用top或只调用同步接口观察现象。你会发现/demo/sync还能正常返回但如果你把同步接口访问频率提高或者任务队列继续增大最终会看到整体响应变慢甚至没有响应。这里我们已经制造出了一个“服务在运行但业务没有正常消化”的场景。接下来进入排查环节。4.6 排查第一步观察进程状态先确认 Java 进程是否还活着拿到进程 PID。jps -l输出类似12345 service-stuck-demo.jar再用top -p 12345观察进程的资源占用top -p 12345重点关注%CPU如果 CPU 很低说明业务线程很可能在等待而不是计算。%MEM如果内存持续上涨可能是队列积压导致对象堆积。如果 CPU 占用很低但接口大量超时基本可以排除“计算密集型任务导致 CPU 跑满”的可能优先怀疑线程阻塞或队列积压。4.7 排查第二步检查端口和连接状态确认端口还在监听ss -lntp | grep 8080输出类似LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:((java,pid12345,fd84))说明端口正常监听。这说明服务没有宕机只是请求处理不过来。再查看当前 TCP 连接数ss -ant | grep 8080 | awk {print $1} | sort | uniq -c如果看到大量SYN_RECV或ESTAB连接积压说明新连接已经无法被及时处理。Tomcat 的 accept 队列可能在排队。4.8 排查第三步导出线程栈这是整个排查过程中最关键的一步。用jstack导出当前线程快照jstack 12345 /tmp/jstack_$(date %s).txt然后查看异步线程的执行状态grep -A 5 biz-thread- /tmp/jstack_*.txt如果队列积压你会看到类似下面的内容biz-thread-1 #28 prio5 os_prio0 tid0x00007f5a1800a800 nid0x2b3e waiting on condition [0x00007f59dfd47000] java.lang.Thread.State: TIMED_WAITING (sleeping) at java.lang.Thread.sleep(Native Method) at com.example.stuck.service.AsyncTaskService.executeSlowTask(AsyncTaskService.java:19)这说明异步线程确实阻塞在Thread.sleep上和代码完全对应。同时还要查看 Tomcat 工作线程的状态grep -A 5 http-nio-8080-exec- /tmp/jstack_*.txt如果看到大量WAITING并且栈信息定位到ThreadPoolExecutor.getTask()说明 Tomcat 线程正在等待新的请求任务。4.9 排查第四步检查 JVM GC 状态线程栈只能看到线程状态还需要确认 GC 环节有没有问题。使用jstat查看 GC 情况jstat -gcutil 12345 1000 5输出示例S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 45.32 88.12 95.20 92.10 152 2.345 3 0.876 3.221重点关注FGCFull GC 次数是否持续增长。FGCTFull GC 总耗时是否过大。O老年代使用率是否居高不下。如果FGC频繁且老年代使用率接近 100%说明服务已经处于内存压力极大的状态。这种情况下即使业务代码没问题GC 停顿也会让请求看起来像“卡死”。4.10 排查结论与修复通过上面的步骤我们可以得出最终结论场景一如果异步线程池队列积压。大量异步任务在队列中等待核心线程只有 2 个消费速度远小于生产速度业务处理产生了大量积压。接口虽然快速返回“提交成功”但真正的业务处理始终没有完成。用户侧看到的现象就是部分功能一直处于处理中最终超时。修复方式将异步线程池的核心线程数调大让消费速度跟上生产速度。将无界队列换为有界队列并设置合理的拒绝策略。对异步任务的执行耗时做评估过慢的任务需要拆分或优化。增加队列积压监控超过阈值时及时告警。场景二如果 Tomcat 工作线程耗尽。当所有 Tomcat 线程都阻塞在慢业务上时请求会排队等待。此时需要优化业务逻辑的耗时而不是只调大 Tomcat 线程数——线程数越大并发压力对数据库连接池的压力也越大反而可能让问题更严重。场景三如果是 GC 频繁导致。排查堆内存占用生成堆 dump 分析大对象检查是否有人为的内存泄漏。同时调整 JVM 堆大小和 GC 回收器配置。4.11 改进后的异步线程池配置修复异步任务积压问题可以用下面这种更稳妥的配置方式// 文件路径src/main/java/com/example/stuck/config/AsyncConfig.java改进版 Configuration public class AsyncConfig { Bean(businessTaskExecutor) public ThreadPoolTaskExecutor businessTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数可以按任务并发量评估 executor.setCorePoolSize(10); // 最大线程数避免无限创建线程 executor.setMaxPoolSize(20); // 有界队列避免无界积压 executor.setQueueCapacity(200); // 线程空闲回收时间 executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(biz-thread-); // 拒绝策略使用调用者线程执行或自定义告警 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }关键变化核心线程数从 2 调到 10提升消费速度。队列从 1000 改为 200避免无限积压。设置了keepAliveSeconds让空闲线程及时回收。这里还需要注意一点线程池的参数不是越大越好。如果任务本身比较慢而且涉及数据库操作线程数过大会直接把数据库连接池打满。合理的做法是根据压测结果逐步调整而不是一次性拍脑袋配一个很大的数。5. 常见问题与排查思路5.1 高频问题汇总问题现象常见原因解决思路接口长时间不返回进程仍存活Tomcat 线程池耗尽jstack 查看线程栈定位阻塞点接口快速返回但业务无进展异步线程池队列积压监控队列深度调整线程池参数日志无异常但服务整体变慢GC 频繁jstat 查看 GC分析堆内存大量连接处于等待状态数据库连接池耗尽查看慢 SQL调大连接池或优化查询应用偶尔恢复又卡死锁竞争或分布式锁未释放查看锁等待线程检查锁过期时间健康检查通过但真实请求失败健康检查接口不包含核心依赖检测完善健康检查逻辑加入数据库/缓存检测5.2 一条通用排查路径面对服务空转问题推荐按照下面的顺序排查确认进程存活jps或ps -ef | grep java。确认端口监听ss -lntp | grep 端口号。观察系统负载top查看 CPU 和内存。导出线程栈jstack pid 文件。分析线程状态找WAITING、BLOCKED、TIMED_WAITING数量占比。查看 GC 状态jstat -gcutil pid 1000 10。检查连接池通过应用监控或 arthas 查看连接池活跃连接数。检查队列深度通过监控系统查看各线程池队列积压数量。结合日志定位搜索超时、重试、熔断相关关键字。这套流程不需要特别的先进工具只要能熟练使用jstack和jstat大部分服务空转问题都能定位到具体代码位置。5.3 容易被忽略的检查点实际排查中有几个检查点很容易被忽略健康检查接口设计如果健康检查只返回固定字符串不检查数据库和缓存连接就可能出现健康检查通过但业务不可用的情况。依赖服务的超时时间如果某次外部调用没有设置超时时间线程会一直阻塞等待。这类问题在线程栈上表现为大量线程卡在同一个第三方客户端调用上。日志异步写入如果日志组件也使用异步队列队列积压会导致日志丢失让线上问题更难排查。日志组件的队列也需要监控。容器资源限制在 Docker 或 Kubernetes 中运行的 Java 应用如果 JVM 没有识别容器内存限制可能频繁触发 OOM Killer 或 GC 异常。6. 最佳实践与工程建议排查是一个被动动作真正好的做法是在设计和编码阶段就尽量避免服务空转。下面是几条经过实际项目验证的建议。6.1 线程池参数要有监控线程池属于“看不见摸不着”的资源但它的状态最能反映服务健康度。建议在项目里把线程池的关键指标暴露给监控系统当前活跃线程数。队列中积压任务数。被拒绝的任务数。核心线程数和最大线程数。以 Spring Boot 为例可以通过ThreadPoolTaskExecutor的getThreadPoolExecutor()拿到原生线程池然后定期采样或用 Micrometer 暴露为 Metric。// 示例通过 ApplicationRunner 定时打印线程池状态 Component public class ThreadPoolMonitor implements ApplicationRunner { Autowired Qualifier(businessTaskExecutor) private ThreadPoolTaskExecutor executor; Override public void run(ApplicationArguments args) { ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(() - { ThreadPoolExecutor pool executor.getThreadPoolExecutor(); System.out.printf( active%d, queueSize%d, completed%d%n, pool.getActiveCount(), pool.getQueue().size(), pool.getCompletedTaskCount() ); }, 0, 10, TimeUnit.SECONDS); } }在生产环境建议直接接入 Prometheus Grafana把active、queueSize等指标做成实时曲线并设置告警阈值。6.2 无界队列是一颗定时炸弹new LinkedBlockingQueue()看起来很方便但它意味着任务可以无限堆积。一旦生产者速度长期大于消费者速度内存会被缓慢撑满最终 OOM。务必要使用有界队列并且配合明确的拒绝策略CallerRunsPolicy调用者线程执行减慢生产速度但可能阻塞调用方。DiscardPolicy/DiscardOldestPolicy会丢任务一般不建议直接用于核心业务。自定义策略把被拒绝的任务写入日志或消息队列方便人工介入。真实项目里推荐“拒绝时告警 降级处理”的组合方案宁可暂时拒绝一部分请求也不能让整个服务因为内存耗尽而崩溃。6.3 所有外部调用都要有超时时间外部调用数据库、Redis、第三方 HTTP 接口是最常见的阻塞来源。如果某个调用没有设置超时时间一旦对端服务挂起本服务的线程就会跟着挂起。以 Spring 的RestTemplate为例一定要设置连接超时和读取超时Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); // 连接超时2 秒 factory.setConnectTimeout(2000); // 读取超时5 秒 factory.setReadTimeout(5000); return new RestTemplate(factory); }数据库连接池也需要设置连接超时时间比如 HikariCP 的connection-timeout默认 30 秒。在高并发场景下这个值可以适当调小避免请求长时间排队。6.4 健康检查必须真实反映服务状态健康检查是运维和编排系统判断服务是否正常的依据。一个只返回200 OK却完全不检查依赖的健康检查接口会让问题被隐藏。建议健康检查至少做到检查数据库连接是否能正常获取。检查关键缓存组件如 Redis是否可访问。检查核心线程池队列积压是否超过阈值。返回的响应中带上各项检查结果。这样监控系统发现服务异常时就能直接看出是哪一层出了问题。6.5 压测是发现服务空转最有效的手段很多服务空转问题是在高并发下才会暴露的。代码写完后建议在测试环境做一轮基础压测重点观察线程池活跃线程数的变化趋势。请求响应时间在并发升高时的拐点。数据库连接池活跃连接数的峰值。GC 频率和耗时。压测并不是必须追求极高的 QPS而是找到服务的瓶颈在哪里并把瓶颈控制在可预期的范围内。6.6 保留现场是排查的前提一旦线上出现服务空转第一反应不要是立刻重启。先执行下面的“现场保留”操作# 保存线程栈至少连续采样两次间隔 10 秒 jstack pid /tmp/stuck_1.txt sleep 10 jstack pid /tmp/stuck_2.txt # 保存 GC 状态 jstat -gcutil pid 1000 5 /tmp/gc_1.txt # 保存网络连接状态 ss -ant /tmp/ss_1.txt如果两次线程栈采样中大量线程停留在同一个方法调用上那基本可以判定线程阻塞的位置。保留这些现场文件后续做根因分析会轻松很多。7. 总结“4 hours and 37 minutes of serving nothing”听起来像一句抱怨但放在后端服务场景里它描述的是一种非常典型的故障状态进程没有宕端口还在听健康检查甚至可能还在通过但所有请求都在暗中排队、积压、等待最终化为一个又一个超时。这样的问题无法靠“加资源”根治因为线程池参数、队列容量、连接池大小、外部调用超时时间每一样都是需要被设计、被监控、被压测验证的系统参数。通过 jstack 看线程状态通过 jstat 看 GC 数据通过监控看队列深度你才能真正看清服务内部发生了什么。建议你拿一个简单的 Spring Boot 项目把本文的案例跑一遍写一个异步任务接口配一个很小的线程池压一批请求然后用 jstack 观察线程状态。这个过程只需要半天时间但它能让你把“服务在运行”和“服务在正常服务”这两件事彻底区分开也能让你在面对线上超时时不再只想着重启。如果你的项目也出现过类似的“空转”问题欢迎在评论区分享你的排查过程。你的排查路径可能就是另一个人解决问题的关键线索。
返回列表