一次接口偶发超时的排查记录:从线程池打满到 TCP 连接复用
上周三下午,订单查询接口的告警群突然热闹起来:P99 延迟从平时的 80ms 抬升到 3 秒以上,而且每过十几分钟就抖一次,持续一两分钟又自己恢复。这种"间歇性抖动"最烦人,平均延迟看起来还算正常,监控大盘一片绿,只有 P99 曲线在反复横跳。
第一反应:看错监控大盘反而被误导
先看了 QPS 和错误率,都没有明显变化,说明不是流量突增。再看应用 GC,Young GC 频率正常,Full GC 一次都没有。CPU 使用率 40% 左右,远没到瓶颈。到这里基本可以排除"资源不够"这类最常见的原因。
真正有价值的线索来自线程 dump。让运维连抓了 5 次线程栈,间隔 10 秒,其中两次正好落在抖动窗口里。对比后发现:抖动时 http-nio-8080-exec 线程全部处于 WAITING,栈顶停在 org.apache.http.pool.AbstractConnPool.lease——这是 HttpClient 在等连接池归还连接。
复盘连接池配置
把 HttpClient 的连接池配置翻出来看:
PoolingHttpClientConnectionManager cm =
new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200); // 全局连接上限
cm.setDefaultMaxPerRoute(20); // 单路由上限,默认只有 2!
RequestConfig rc = RequestConfig.custom()
.setConnectTimeout(1000)
.setSocketTimeout(3000)
// 关键:从池里借连接的等待上限
.setConnectionRequestTimeout(3000)
.build();
问题很典型:这个服务依赖的下游只拆了两个域名,单路由 20 个连接,正常情况下够用。但订单查询下游有个"热点用户"效应——大客户轮询拉单,一秒钟能打过来上百个并发,瞬时把单路由的 20 个连接全部占满,后续请求就在 lease 上排队,最多等 3 秒。这正好解释了为什么 P99 抖到 3 秒上下、又会在流量峰过去后自愈。
修复:分层限流 + 连接池调参
只调大连接池是治标,下游扛不住更大的并发反而更危险。最终做了三件事:
- 单路由连接从 20 提到 50,
connectionRequestTimeout降到 800ms,宁可快速失败也不 hang 住业务线程; - 对热点用户的查询加本地 Caffeine 缓存,TTL 5 秒,同类请求合并回源;
- 网关层对该客户加 50 QPS 的限流,超额直接返回缓存快照。
改完压测:单路由并发 100 时 P99 稳定在 240ms,连接池借用的等待时间分布(通过 Micrometer 的 httpclient.pool 指标)P99 只有 6ms。
一个容易忽略的坑:连接不复用
压测复验时又发现一个次生问题——长稳跑 10 分钟后,TIME_WAIT 数量涨到四万多。抓包看是每次请求都新建了 TCP 连接。原因是响应头里下游返回的是 Connection: close,而且我们自建 HttpClient 时没有显式开启 keep-alive 策略。补上默认的 keep-alive 并让连接池定期驱逐空闲连接:
cm.setValidateAfterInactivity(2000); // 空闲 2s 后先校验再复用
顺带在内核层开了 tcp_tw_reuse,TIME_WAIT 回落到一万以内。
小结
排查这类"偶发抖动",最有效的动作往往是在抖动窗口内抓线程栈,静态配置和监控大盘都容易骗人。连接池参数里 defaultMaxPerRoute 默认值只有 2,很多团队都是踩过一次才记住。给连接池加上租借等待时长的指标,是提前发现这类问题的性价比最高的手段。
- 点赞
- 收藏
- 关注作者
评论(0)