晚上八点,大促预热。监控大屏上,订单服务的接口成功率从 99.99% 一路滑到 3%,我的手机像被扔进了洗衣机。我先看了一眼机器:CPU 5%,内存 40%,磁盘、网络都很平静。一台安静得像在睡觉的机器,接口却全部超时——这种场景我称之为“植物人症状”:人还活着,但不响应。
同事在群里问:“是不是订单服务挂了?”我说:“没挂,它在等。”他说:“等谁?”我说:“等一个永远不会回话的下游。”这篇就是记录我们怎么从一个“看起来什么都没坏”的服务里,找出被打满的线程池、被拖垮的链路,以及以后怎么让它别再陪跑。
一、现象:资源都空闲,请求却全在排队
先给“植物人症状”下诊断:CPU 低 + 请求超时,说明线程没有在算,而是在等。等什么?只有几种可能:等锁、等 IO(网络/磁盘)、等连接池、等线程池队列。看 Tomcat 线程使用最快的办法是查 Actuator 指标:
$ curl -s localhost:8080/actuator/metrics/tomcat.threads.busy
{"name":"tomcat.threads.busy","measurements":[{"statistic":"VALUE","value":200.0}],...}
$ curl -s localhost:8080/actuator/metrics/tomcat.threads.config.max
{"name":"tomcat.threads.config.max","measurements":[{"statistic":"VALUE","value":200.0}],...}busy = 200 = max。Tomcat 的 maxThreads 默认就是 200(官方文档),也就是说所有工作线程都被占满了。此时新连接会先被接受但不处理(maxConnections 默认 8192,超过则进入操作系统的 acceptCount 队列,默认 100),线程一个都腾不出来,请求当然就在排队,最终客户端超时。
那这 200 个线程到底在忙什么?答案在 jstack 里。先保留现场再重启,这是老规矩:连抓三份,间隔 5 秒。
$ for i in 1 2 3; do jstack -l 6120 > /data/dumps/jstack-$(date +%H%M%S).txt; sleep 5; done
# 官方文档说明 jstack 是“实验性、不受支持”的工具,官方建议的替代命令:
$ jcmd 6120 Thread.print -l > /data/dumps/jcmd-threads.txt二、排查路线图
线程池打满,本质是“到达速率 > 处理速率”并且“处理线程被慢调用占住”。下面这张图是这次事故的因果链路,也是排查时的思路:从症状(上游超时)往回追,直到找到最初变慢的那个点。
三、动手:让线程栈自己说话
第 1 步:统计线程状态分布
三千行的 jstack 没法人肉读,先做统计:
$ grep 'java.lang.Thread.State' jstack-200412.txt | sort | uniq -c | sort -rn
142 java.lang.Thread.State: RUNNABLE
33 java.lang.Thread.State: WAITING (parking)
21 java.lang.Thread.State: TIMED_WAITING (parking)
12 java.lang.Thread.State: TIMED_WAITING (sleeping)
5 java.lang.Thread.State: WAITING (on object monitor)第一反应:“142 个 RUNNABLE,是不是 CPU 在跑?”别急,CPU 才 5%。这里有个极其容易被坑的知识点:Java 线程状态里的 RUNNABLE,包含“正在阻塞地读 socket”的线程——在 JVM 看来,线程进入了本地方法(native)执行,没有被 Java 层面的锁或 park 挂起,就算 RUNNABLE;但在操作系统层面它其实在睡觉,等网卡数据。所以“一堆 RUNNABLE + CPU 很低”,要去看它们的栈顶。
第 2 步:按栈顶聚合,看大家都停在哪
$ awk '/^"http-nio-8080-exec/{getline; getline; print}' jstack-200412.txt | sort | uniq -c | sort -rn | head
139 at java.net.SocketInputStream.socketRead0(Native Method)
31 at sun.misc.Unsafe.park(Native Method)
19 at java.lang.Object.wait(Native Method)
6 at java.lang.Thread.sleep(Native Method)200 个线程里有 139 个停在 socketRead0——阻塞地等对端返回数据;另有一部分停在 park/wait。先看第一类,往下展开完整栈:
"http-nio-8080-exec-37" #98 daemon prio=5 os_prio=0 tid=0x00007f3c2c0a8800 nid=0x1a3f runnable [0x00007f3bd97f5000]
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:108)
at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:271)
at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
at org.springframework.http.client.HttpComponentsClientHttpRequest.executeInternal(HttpComponentsClientHttpRequest.java:87)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:776)
at com.demo.order.client.PointsClient.deduct(PointsClient.java:42)
at com.demo.order.service.OrderService.create(OrderService.java:131)读法:从下往上,业务入口 OrderService.create → 调 PointsClient.deduct(扣积分)→ RestTemplate → Apache HttpClient → 最终卡在 socketRead0。请求已经发出去,正在等积分服务的响应。这就是“下游慢”在线程栈里的样子。
再看第二类:
"http-nio-8080-exec-88" #149 daemon prio=5 os_prio=0 tid=0x00007f3c2c0b1000 nid=0x1a99 waiting on condition [0x00007f3b8a1f5000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000006c2a18e38> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at org.apache.http.pool.AbstractConnPool.getPoolEntryBlocking(AbstractConnPool.java:377)
at org.apache.http.pool.AbstractConnPool.access$200(AbstractConnPool.java:69)
at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:245)
at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.leaseConnection(PoolingHttpClientConnectionManager.java:267)
at com.demo.order.client.PointsClient.deduct(PointsClient.java:42) at com.demo.order.service.OrderService.create(OrderService.java:131)这一类线程连请求都没发出去:它们卡在 PoolingHttpClientConnectionManager.leaseConnection,在等一条空闲连接。连接池上限是 50,那 50 条连接全都被上面那 139 个“慢请求”占着(还没归还),后来者只能排队。WAITING (parking) + getPoolEntryBlocking,就是“连接池耗尽”的标准指纹。
到这里链路已经清楚:积分服务变慢 → 订单服务调用积分的线程被长时间占用 → 连接池被占满 → 所有 Tomcat 线程被占满 → 订单服务对外全部超时。积分只是个附属功能,却让下单主流程陪葬。
第 3 步:确认“没有超时”
为什么线程会一直等?看客户端配置:
@Bean
public RestTemplate restTemplate() {
// 只配了连接池大小,没有任何超时
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(50);
cm.setDefaultMaxPerRoute(50);
CloseableHttpClient client = HttpClients.custom().setConnectionManager(cm).build();
return new RestTemplate(new HttpComponentsClientHttpRequestFactory(client));
}Apache HttpClient 4.x 里,如果不显式设置,connectTimeout、socketTimeout(读超时)、connectionRequestTimeout(从池里借连接的等待时间)的默认值都是 -1,即无限(实际上会退化为系统默认,读超时通常等于永远)。这就意味着:只要积分服务不回包,订单线程就会一直挂着——这就是“没有超时”的代价。
Tomcat 那头也一样:请求进来后,线程被占住,客户端网关 3 秒就超时断开了,但服务端线程并不知道,还傻傻地在等积分服务,浪费资源做无用功。更糟的是,网关和客户端 App 会自动重试,流量被放大了 2–3 倍,雪上加霜。
第 4 步:还有一个隐藏炸弹——无界队列
翻代码时还发现订单里“发送通知”用了一个异步线程池:
// 工具类里的“方便写法”
private static final ExecutorService NOTIFY_POOL = Executors.newFixedThreadPool(8);
NOTIFY_POOL.execute(() -> smsClient.send(...)); // 短信服务同样很慢
Executors.newFixedThreadPool 内部用的是无界的 LinkedBlockingQueue。JDK 官方文档对无界队列的说明写得很直白:核心线程都忙时,新任务在队列里排队,永远不会创建超过 corePoolSize 的线程,maximumPoolSize 不起作用;如果任务到达速度超过处理速度,队列会无限增长——它既不会拒绝,也不会报警,就那么默默地把内存吃完。这次事故里,jstack 里有 8 个 pool-3-thread-N 全部卡在 socketRead0,而队列里堆了几十万个 Runnable,最终又把堆推上了 80%。
用 jstack 看无界队列很难,得用 jcmd <pid> GC.class_histogram 看 LinkedBlockingQueue$Node 与自定义 Runnable 的数量,或者在监控里把线程池的 queue.size 暴露出来。
四、根因
综合三处发现,根因不是“积分服务变慢”(那是诱因),而是订单服务缺少三道保险:
- 没有超时:HTTP 调用没有读超时和借连接超时,慢调用能无限期占用线程;
- 队列无界:异步线程池用无界队列,慢下游让任务无限堆积,既不拒绝也不降级;
- 没有隔离:积分、短信、下单主流程共用同一批 Tomcat 线程和同一个连接池,任何一个下游变慢都会占满共享资源。
顺便回答一个常见疑问:“为什么增加线程数不能解决?”因为线程只是把“堵”的位置往后推。当下游延迟从 50ms 涨到 30s,同样的 QPS 需要的并发线程数会涨 600 倍(利特尔法则:并发数 = 吞吐 × 平均耗时)。200 个线程和 2000 个线程在 30 秒的耗时面前,都是杯水车薪,反而让下游更难恢复。
五、止血与修复
止血(按顺序做,每一步都有明确目的)
- 先把“毒源”切掉:在订单服务里通过配置中心(比如 Nacos/Apollo)把“扣积分”开关关掉,让下单主流程不再调积分。这类“降级开关”一定要提前做,这次是紧急加的;
- 网关限流 + 关掉自动重试:防止重试风暴把刚恢复的实例再次打满;
- 滚动重启订单服务:被卡住的线程不会自己恢复(读超时是无限的),重启前已保存三份 jstack;
- 通知积分负责人排查慢查询(后来发现是一条缺索引的 SQL 在大促流量下被放大)。
根治 1:所有远程调用必须设置三个超时
RequestConfig rc = RequestConfig.custom()
.setConnectTimeout(500) // 建立 TCP 连接:500ms
.setConnectionRequestTimeout(200) // 从连接池借连接:200ms,借不到立刻失败,别排队
.setSocketTimeout(1500) // 读超时:略大于下游 P99,别设成“分钟级”
.build();
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(rc)
.evictExpiredConnections()
.build();超时的取值原则:上游总超时 > 各下游超时之和,且远小于线程池被占满的容忍时间。比如网关 3 秒,订单内部调积分不能超过 1.5 秒,否则用户端早就放弃了,服务端却还在等。
根治 2:线程池自己 new,队列有界,拒绝策略明确
阿里巴巴的《Java 开发手册》明确禁止用 Executors 创建线程池,理由就是上文说的无界队列和无界线程数。改为:
ThreadPoolExecutor notifyPool = new ThreadPoolExecutor(
8, 16, // core / max
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // 有界队列:容量要能算出来
new ThreadFactoryBuilder().setNameFormat("notify-%d").build(), // 一定要命名,jstack 才看得懂
new ThreadPoolExecutor.CallerRunsPolicy() // 或自定义:记录指标 + 降级
);四种拒绝策略(JDK 官方文档)怎么选:
| 策略 | 行为 | 适用 |
|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 调用方能捕获并做降级(返回“系统繁忙”)时首选 |
CallerRunsPolicy | 由提交任务的线程自己执行,天然形成“反压”,降低提交速率 | 任务必须执行、可接受主线程变慢(注意:如果调用线程是 Tomcat 线程,等于把慢下游的压力压回请求线程) |
DiscardPolicy | 悄悄丢弃 | 仅限日志、埋点这类允许丢的任务 |
DiscardOldestPolicy | 丢弃队头最老任务并重试 | 几乎不推荐,除非明确要“只保留最新” |
还要记住 ThreadPoolExecutor 的入池规则:先到核心线程数 → 再入队列 → 队列满了才扩到最大线程数 → 还满就拒绝。所以队列如果无界,maximumPoolSize 永远是摆设;队列如果很大(几万),扩容也会来得很晚。
根治 3:隔离,别让一个下游拖死所有人
隔离的目的是让“积分慢”只影响“积分的额度”,而不是所有线程。常用做法是舱壁模式(Bulkhead)。以 Resilience4j 为例,官方文档里给出两种实现:SemaphoreBulkhead(信号量,默认 maxConcurrentCalls=25、maxWaitDuration=0,超限立即失败)和 ThreadPoolBulkhead(独立线程池 + 有界队列,默认队列容量 100)。我们选了信号量方式,配置如下:
resilience4j:
bulkhead:
instances:
pointsService:
maxConcurrentCalls: 20 # 同时最多 20 个线程在调积分
maxWaitDuration: 0ms # 满了立刻失败,不排队
timelimiter:
instances:
pointsService:
timeoutDuration: 1500ms
circuitbreaker:
instances:
pointsService:
failureRateThreshold: 50
slowCallDurationThreshold: 1000ms
slowCallRateThreshold: 50 # 慢调用比例超过 50% 就熔断,快速失败
waitDurationInOpenState: 10s@Bulkhead(name = "pointsService", fallbackMethod = "deductFallback")
@CircuitBreaker(name = "pointsService", fallbackMethod = "deductFallback")
public void deduct(Long userId, int points) { /* 调积分 */ }
// 降级:积分晚点补偿,别阻塞下单。可写入 MQ / 本地消息表异步重试
private void deductFallback(Long userId, int points, Throwable t) {
compensationProducer.send(new PointsDeductMsg(userId, points));
}下面这张图对比了“共享线程池”与“舱壁隔离”两种结构。左边是本次事故的形态:所有下游共用 200 个 Tomcat 线程;右边是改造后:每个下游有自己的并发上限,积分把自己的 20 个名额用完就快速失败,下单和库存的线程池不受影响。
验证:故障演练
改完后我们在预发环境做了一次故障演练:用 tc qdisc add dev eth0 root netem delay 3000ms 给积分服务的网卡加 3 秒延迟,再打 500 QPS 压测。结果:积分调用在 1.5 秒后统一超时进入 fallback,熔断在约 10 秒后打开;订单接口 P99 稳定在 120ms,Tomcat busy 线程数不超过 40。之前会全军覆没的场景,现在只是积分“晚一点到账”。
六、复盘:这类坑怎么提前躲开
| 做法 | 为什么 |
|---|---|
| 所有远程调用(HTTP、Dubbo、Redis、MQ、JDBC)必须设置连接/读/借连接三种超时 | 没有超时,一次慢调用就能无限占用一个线程;线程是最贵的共享资源 |
| 线程池手动创建、队列有界、线程命名、拒绝策略明确 | Executors 的无界队列会让问题悄悄发酵;命名后 jstack 才可读 |
| 非核心下游做舱壁隔离 + 熔断 + 降级开关 | 下游变慢是常态,重点是让影响可控 |
| 上游总超时 > 下游超时之和,并关闭不必要的自动重试 | 避免用户已放弃、服务端还在“无效劳动”,以及重试风暴 |
监控 tomcat.threads.busy、连接池 active/pending、线程池 queue.size 与拒绝次数 | 线程池打满前,这几个指标一定先亮红灯 |
| jstack 排查口诀:先统计状态 → 按栈顶聚合 → 看是 socketRead / park 连接池 / 锁 | 把三千行变成三个数字,找到问题只要一分钟 |
| 大促前做“慢下游”故障演练(netem 加延迟) | 只有演练过,才知道保险到底有没有生效 |
一句话备忘:CPU 低但全超时 → 线程在等 →
jstack统计状态、按栈顶聚合 → 栈顶是socketRead0就是等下游,是getPoolEntryBlocking就是连接池耗尽 → 超时、有界队列、舱壁隔离,一个都不能少。
事后复盘会上,产品经理问我:“能不能让积分服务别慢?”我说:“可以,但更重要的是,让我们别因为它慢而陪它一起慢。”——后来这句话被贴在了我们的架构评审模板第一行:“你的下游如果挂了 30 秒,你会怎样?”
参考资料
- JDK 17 API:ThreadPoolExecutor——三种排队策略(直接交接、无界队列、有界队列)的利弊、四种拒绝策略行为,以及核心/最大线程数的入池规则。
- JDK 17 jstack 手册——jstack 的用法、-l 选项,以及其“实验性”状态与推荐的
jcmd Thread.print替代方式。 - Apache Tomcat 9 HTTP Connector 配置——maxThreads 默认 200、maxConnections 默认 8192、acceptCount 默认 100 及请求排队流程。
- Resilience4j Bulkhead 文档——SemaphoreBulkhead 与 ThreadPoolBulkhead 的差异与默认配置。
一起聊聊