“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 应用为例,运行环境如下:
操作系统:Linux(CentOS 7 / Ubuntu 20.04 均可) JDK:JDK 8 或 JDK 11 框架:Spring Boot 2.x 构建工具:Maven 3.6+如果你本机环境版本不同,不需要完全一致。重点在于理解排查思路,命令和代码需要按实际版本做微调。
2.2 核心排查工具
| 工具 | 用途 | 来源 |
|---|---|---|
| top / htop | 查看进程 CPU、内存占用 | Linux 自带 |
| jps | 查看 Java 进程 PID | JDK 自带 |
| 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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",pid=12345,fd=84))说明端口正常监听。这说明服务没有宕机,只是请求处理不过来。
再查看当前 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 prio=5 os_prio=0 tid=0x00007f5a1800a800 nid=0x2b3e 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重点关注:
FGC:Full GC 次数是否持续增长。FGCT:Full 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 观察线程状态。这个过程只需要半天时间,但它能让你把“服务在运行”和“服务在正常服务”这两件事彻底区分开,也能让你在面对线上超时时不再只想着重启。
如果你的项目也出现过类似的“空转”问题,欢迎在评论区分享你的排查过程。你的排查路径,可能就是另一个人解决问题的关键线索。