[故障复盘] 生产环境 HTTP 连接池耗尽导致的“服务假死”分析

日期:2025-12-24

关键词Connection PoolThread StarvationHttpClientTimeout

适用范围:后端开发、DevOps、架构组

1. 问题现象 (Symptoms)

生产环境某 Java 服务出现请求超时(504 Gateway Timeout),由于无明显错误日志,排查较困难。具体特征如下:

  • 业务表现:前端调用涉及发送邮件(或第三方 API)的接口时一直转圈,最终超时。其他无关接口可能受影响变慢。
  • 资源监控
    • CPU:极低(< 5%),未见繁忙计算。
    • 内存:正常,无 OOM 迹象。
    • 线程数异常飙升,Tomcat 线程数接近最大值。
      线程数量截图
  • 网络连接:与第三方服务(如 SendGrid)建立的 ESTABLISHED 连接数极少(个位数)。

2. 根因分析 (Root Cause Analysis)

经过 jstack 线程堆栈分析,确认由于同步调用外部服务且 HTTP 连接池配置不当,导致 Tomcat 核心线程耗尽。

2.1 技术原理

应用程序使用了 Apache HttpClient 调用第三方服务,但使用了默认配置:

  • MaxTotal (总连接数):默认 20。
  • DefaultMaxPerRoute (单域名并发数)默认仅为 2

2.2 故障链条

  1. 外部波动:第三方服务(SendGrid)响应略微变慢(例如从 200ms 变到 2s)。
  2. 资源占满:仅需 2 个并发请求,就会占满针对该域名的 2 个连接配额。
  3. 线程阻塞:后续进来的第 3、4…100 个请求,在获取连接时被阻塞。
    • 代码位置:org.apache.http.pool.AbstractConnPool.getPoolEntryBlocking
    • 线程状态:WAITING (parking)
  4. 级联崩溃:Tomcat 的 HTTP 处理线程(http-nio-exec-*)因等待连接而挂起,无法释放。随着请求不断进来,Tomcat 线程池被耗尽,导致整个服务对新请求无响应(假死)。

形象比喻:银行只有 2 个柜台窗口。当这 2 个窗口的业务员办理速度变慢时,大厅里排队的人(Tomcat 线程)会越来越多,且大家都只能干等,无法离开去做别的事。

3. 解决方案 (Resolution)

3.1 紧急修复(配置层)

显式调整 HttpClient 的连接池参数,使其匹配业务并发需求。

PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();

// 1. 扩大并发限制 (最关键)
// 将针对单一域名的最大并发数从默认的 2 提升至 50 或 100
cm.setDefaultMaxPerRoute(50); 

// 2. 扩大总池子
cm.setMaxTotal(200);

// 3. 设置"获取连接"的超时时间 (防御性编程)
// 如果池子满了,等待 2 秒还没拿到连接,直接抛出异常,而不是让线程无限期 WAITING
RequestConfig requestConfig = RequestConfig.custom()
    .setConnectionRequestTimeout(2000) 
    .setConnectTimeout(3000)
    .setSocketTimeout(5000)
    .build();

3.2 架构优化(代码层)

核心原则:核心业务路径严禁同步调用非核心外部依赖。

将发送邮件、短信、Webhook 等操作改为异步处理

  • 方案 A (Spring @Async):使用独立的线程池处理耗时任务。
  • 方案 B (MQ 消息队列):将任务写入 RocketMQ/RabbitMQ,由 Consumer 慢慢消费。即使第三方服务宕机,也只会导致消息积压,不会拖垮主 Web 服务。

4. 最佳实践与避坑指南 (Best Practices)

为避免此类问题再次发生,团队开发需遵循以下规范:

避坑

  1. 不要信任默认值:绝大多数 HTTP 客户端(Apache HttpClient, OkHttp, RestTemplate)的默认并发配置都偏低(通常为 5),不适用于生产环境。
  2. 严禁无限等待:所有涉及网络 I/O 的操作,必须显式设置三个超时:
    • Connect Timeout (握手超时)
    • Socket/Read Timeout (读取超时)
    • Connection Request Timeout (从池中获取连接的超时) —— 本次故障的元凶

建议

  1. 隔离线程池:如果必须同步调用,请使用 Hystrix/Sentinel/Resilience4j 进行线程池隔离。不要让外部调用占用 Tomcat 的主请求处理线程。
  2. 监控可视化
    • 在 Datadog/Prometheus 中添加连接池监控面板(Leased Connections, Pending Connections)。
    • 设置告警:当 Pending Connections > 0 持续 1 分钟时报警。
  3. 定期排查
    • 当发现 CPU 低但响应慢时,第一时间打 Thread Dump (jstack)
    • 关注 WAITING 状态且大量停留在同一行代码的线程。

附件:排查常用命令

# 1. 查看 Java 进程的线程总数
ps -eLf | grep java | wc -l

# 2. 查看与目标第三方服务(如端口443)建立的连接数
netstat -an | grep :443 | grep ESTABLISHED | wc -l

# 3. 快速抓取线程快照
jstack <pid> > dump.txt
# 搜索 "WAITING" 和 "http-nio" 关键字

将文件dump.txt下载下来之后可以给到https://fastthread.io/ 来分析线程的情况,得到实际的线程情况。

fastthread.io线程分析截图

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐