Java / JVM 排查 列表全部文章
Java / JVM 排查

下游慢 2 秒,上游全挂:一次线程池被打满的连环追尾

积分服务变慢,订单服务接口全部超时,CPU 却只有 5%。jstack 看到 200 个 Tomcat 线程都阻塞在 socket read 与连接池里,根因是没设超时、队列无界、没有隔离。附拒绝策略与超时、舱壁隔离的落地写法。

更多

晚上八点,大促预热。监控大屏上,订单服务的接口成功率从 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

二、排查路线图

线程池打满,本质是“到达速率 > 处理速率”并且“处理线程被慢调用占住”。下面这张图是这次事故的因果链路,也是排查时的思路:从症状(上游超时)往回追,直到找到最初变慢的那个点。

网关 / 客户端超时重试放大流量 订单服务 Tomcat200 个工作线程全部被占满 HttpClient 连接池上限 50,连接被占用后来者排队等连接 积分服务每次调用 30s+ MySQL锁等待 / 慢 症状从右向左蔓延:越靠左,越先被用户看到 缺了哪三道保险? ① 没有 HTTP 读超时(一直等) ② 线程池/队列无界(一直排) ③ 没有隔离(积分慢拖垮主流程)

三、动手:让线程栈自己说话

第 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 暴露出来。

四、根因

综合三处发现,根因不是“积分服务变慢”(那是诱因),而是订单服务缺少三道保险:

  1. 没有超时:HTTP 调用没有读超时和借连接超时,慢调用能无限期占用线程;
  2. 队列无界:异步线程池用无界队列,慢下游让任务无限堆积,既不拒绝也不降级;
  3. 没有隔离:积分、短信、下单主流程共用同一批 Tomcat 线程和同一个连接池,任何一个下游变慢都会占满共享资源。

顺便回答一个常见疑问:“为什么增加线程数不能解决?”因为线程只是把“堵”的位置往后推。当下游延迟从 50ms 涨到 30s,同样的 QPS 需要的并发线程数会涨 600 倍(利特尔法则:并发数 = 吞吐 × 平均耗时)。200 个线程和 2000 个线程在 30 秒的耗时面前,都是杯水车薪,反而让下游更难恢复。

五、止血与修复

止血(按顺序做,每一步都有明确目的)

  1. 先把“毒源”切掉:在订单服务里通过配置中心(比如 Nacos/Apollo)把“扣积分”开关关掉,让下单主流程不再调积分。这类“降级开关”一定要提前做,这次是紧急加的;
  2. 网关限流 + 关掉自动重试:防止重试风暴把刚恢复的实例再次打满;
  3. 滚动重启订单服务:被卡住的线程不会自己恢复(读超时是无限的),重启前已保存三份 jstack;
  4. 通知积分负责人排查慢查询(后来发现是一条缺索引的 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 个名额用完就快速失败,下单和库存的线程池不受影响。

改造前:共享 200 个线程 改造后:舱壁隔离 + 超时 + 熔断 Tomcat 200 线程(共享) 积分慢 → 线程被占满 → 库存、支付也进不来 连接池 50 条被慢请求占满 积分(慢)占满 库存无线程可用 支付无线程可用 Tomcat 200 线程 每个下游有独立并发上限 + 超时 超限立即 fallback,不占线程 积分 ≤20熔断+降级 库存 ≤50正常 支付 ≤50正常 目标:任意一个非核心下游变慢,都不能影响下单主流程

验证:故障演练

改完后我们在预发环境做了一次故障演练:用 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 秒,你会怎样?”

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人