记一次 TCP 连接数暴涨的排查

周四晚上九点四十,值班手机响了。网关集群其中一台机器的 TCP 连接数从平时的 6 万左右涨到 19 万,还在涨。业务指标倒是正常,错误率没动,延迟 P99 略微抬头但不算难看。

第一反应是被攻击了,看了眼接入层的 QPS,平稳。不是流量的问题,那就是连接的问题——请求数没变,连接数翻了三倍,说明连接没被复用,或者没被释放。

先看连接都处于什么状态

上机器:

$ ss -s
Total: 192243
TCP:   191876 (estab 23104, closed 145632, orphaned 89, timewait 145630)

timewait 14 万。心里basically有数了,TIME_WAIT 堆积,大概率是哪里在疯狂建短连接。

TIME_WAIT 本身不算事故,它是 TCP 正常挥手后主动关闭方的必经状态,2MSL 也就一分钟。14 万的存量意味着每秒大概有两千多个连接在被主动关闭。问题是:谁在关,关的是哪边的连接?

$ ss -tan state time-wait | awk '{print $4}' | awk -F: '{print $2}' | sort | uniq -c | sort -rn | head
 145203 48392...(本地随机端口,对端是 上游服务:8080)
    427 443

几乎全部 TIME_WAIT 的对端都指向 10.3 网段的 8080 端口——是我们自己的一组上游服务。也就是说,是 Nginx 作为客户端去连 upstream,用完就关,每个请求一个新连接。

为什么突然不复用了

这套网关到 upstream 的 keepalive 是一直开着的,运行两年没出过事。先确认配置:

$ nginx -T | grep -A 5 "upstream order_svc"
upstream order_svc {
    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
    # keepalive 128;
}

keepalive 那行被注释了。

翻 git 历史,下午五点有一次配置变更,diff 里这行注释赫然在列。提交信息写的是「调整 order_svc 上游节点」。找变更的同事问了下,他说改节点列表的时候是从另一个 upstream 块复制过来改的,那个块本来就没开 keepalive——也就是说不是手抖注释掉的,是整块替换的时候带进来的。

到这里其实可以直接改回去收工了,但有两个问题没想通,不弄明白睡不着:

一是为什么业务没报错?因为短连接虽然浪费,但每个请求功能上是完整的,只是多了握手开销。P99 抬头的那一点就是三次握手的代价。

二是为什么连接数是慢慢涨上来的,而不是配置生效后立刻跳变?后来想明白了,reload 之后旧 worker 带着旧连接 graceful shutdown,存量长连接还在服务,新 worker 全走短连接。旧连接随着自然到期逐渐退出,短连接占比慢慢爬升,TIME_WAIT 才呈现出那种斜坡式增长。报警阈值是绝对值,所以延迟了四个多小时才触发。

修复与复盘

改回去很简单:

upstream order_svc {
    server 10.0.1.1:8080;
    server 10.0.1.2:8080;
    keepalive 128;
}

顺带提醒一句,upstream 开 keepalive 还需要 location 里这两行配合,少一行都不生效,这是 Nginx 一个老生常谈的坑:

proxy_http_version 1.1;
proxy_set_header Connection "";

reload 之后十分钟,TIME_WAIT 从 14 万掉回 4000,estab 稳定在 2 万出头。

复盘会上定了三条改进,都不是什么新鲜东西,但确实管用:

配置变更的 diff 必须逐行 review,「整块复制」这种操作方式要在 review 时格外警惕——diff 看起来是「改了节点列表」,实际语义变化藏在被一起带走的行里。

给网关加了一个连接复用率的监控指标(请求数/新建连接数),这个比值掉到个位数就报警。比起绝对连接数,这个指标对「keepalive 失效」类问题敏感得多,而且不会因为业务量自然增长产生误报。

压测环境的配置和线上做了自动 diff 巡检。这次的配置在压测环境其实也错了一周了,没人发现,因为压测环境连接数再高也没人看。

两周后的回马枪

本来以为这事翻篇了,结果新加的连接复用率指标两周后又报了一次——另一个 upstream,复用率从 200 多掉到 30。这次不是配置问题,keepalive 好好地开着。

排查发现是 upstream 那边的服务做了一次发布,新版本给响应加了个 Connection: close 头。问他们为什么加,答曰:排查一个内存问题的时候临时加的,想让连接快点回收,「忘了删,而且当时看也没什么影响」。

确实没什么影响——对他们没影响,握手成本都在我们这边。这种「成本在别人家」的改动是最难防的,谁也不会为别人的指标做回归。好在这次从报警到定位只花了二十分钟,新指标算是立了一功。后来我们干脆把连接复用率做成了按 upstream 维度拆分的面板,哪个后端发布搞了小动作一眼就能看出来。

几个顺带学到的细节

排查过程里翻了些资料,记几个以前理解不准确的点。

net.ipv4.tcp_tw_reuse 只对「出站连接」(自己是客户端)生效,而且只在时间戳满足条件时复用,它不是清理 TIME_WAIT 的开关。网上一堆文章教人开 tcp_tw_recycle,那个参数因为在 NAT 环境下丢包,4.12 内核已经删掉了,2026 年了不要再抄了。

TIME_WAIT 真正的资源消耗比想象中小,一个大概占 0.2KB 内存,14 万个也就 30MB。真正的风险是本地端口耗尽——出站连接的四元组里本地端口是有限的(ip_local_port_range 默认大概 28000 个),短连接打满之后新连接会拿不到端口,报 Cannot assign requested address。我们这次距离打满还有一段余量,算是运气。

如果实在要扛短连接场景,正经做法是扩大端口范围、开 tw_reuse、或者干脆多挂几个本地 IP,而不是去动 TIME_WAIT 的回收。

还有一个差点忘了的细节:排查中途我顺手看了眼 conntrack 表,conntrack -C 显示 38 万,离 nf_conntrack_max 的 52 万不算远。这台机器因为历史原因装过防火墙规则,conntrack 模块一直加载着。如果连接数再涨一倍,会先撞上 conntrack 表打满——那个的症状是随机丢包,比端口耗尽难排查得多。当晚顺手把这批网关机的 conntrack 上限调大了一档,也算这次报警的意外收获。这种「排查 A 问题时撞见 B 隐患」的情况,我的习惯是当场处理或者当场建工单,过夜必忘。

就这些。这次问题本身不复杂,但「配置整块复制夹带私货 + 报警滞后四小时 + 压测环境同样烂了一周没人知道」这三件事叠在一起,还是挺值得记一笔的。写完翻了下博客存档,发现 2017 年我写过一篇 Nginx 连接复用的文章,里面郑重其事地讲了 keepalive 的配置要点——九年后栽在同一个知识点上,只不过这次不是不懂,是没看住。知识会过时,但「人会犯的错」这部分知识保质期是真的长。