你的服务半夜自己崩了,但你什么都没改
凌晨三点,告警响了。服务进程还在,健康检查还是绿的,但新的请求开始大量超时。你登上机器,top 看 CPU 空闲、内存充足、磁盘空旷,一切岁月静好。直到你敲了一句 ss -s,屏幕上跳出来的数字让你后背一凉:
TIME-WAIT sockets: 32768
TCP: inUse 0 orphan 0 tw 32768 ...
三万多个 TIME_WAIT,像三万具尸体堵在小区门口。你还没搞明白发生了什么,客户端那边的网关已经开始报「连接池获取超时」。
今天咱们不聊缓存策略,也不聊微服务拆分。来聊聊一个更底层、也更让人抓狂的话题:TCP 长连接的生命周期管理。这东西平时安静得像地板下的电缆,只有等你家跳闸的时候才出来背锅。
一、TIME_WAIT 不是 bug,它是一种自我保护
先纠正一个流传很广的说法:TIME_WAIT「浪费端口」「会拖垮性能」,这个锅 TCP 不背。
标准四次挥手的过程是这样的:
- 客户端发 FIN,说「我没数据了」
- 服务端回 ACK,说「知道了」
- 服务端把剩下的数据发完,再发 FIN,说「我也说完了」
- 客户端回 ACK,「收到」
问题出在第 4 步:这个 ACK 在网络里丢了怎么办?服务端会一直重发 FIN,直到超时重传几次后放弃。而服务端为了保证「重发的那个 FIN 还能被正确识别」,必须让这个连接在本地多留一会儿。这就是 TIME_WAIT 的使命——它是最后那个 ACK 的保险丝。
主动关闭方负责收尾,代价是连接要在本地多待 2MSL(Linux 上默认 60 秒)。听起来很长,但你想想——如果没有这 60 秒,网络抖动一次,你的服务就会攒下一堆「半死不活」的僵尸连接,那才叫真正的灾难。
所以真正的问题不是「TIME_WAIT 存在」,而是你的连接为什么在频繁地新建和关闭。三万个 TIME_WAIT 通常只有两种病因:短连接风暴,或者有人在用完就扔还不复用。
二、先分清两种病:短连接 vs 长连接
有个常见的误判:看到 TIME_WAIT 多,就以为自己是长连接架构,然后加了一堆「连接池优化」,结果一点用没有。因为如果你的客户端每次请求都 Close(),那 TIME_WAIT 天然堆积,加池子也救不了。
判断方法很简单,看数字比值:
# 当前处于各状态的连接数
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# 本地端口范围,这是短连接的上限天花板
cat /proc/sys/net/ipv4/ip_local_port_range
# 输出:32768 60999 → 约 28000 个可用端口
看到没?本机临时端口就 28000 出头。如果你的 TIME_WAIT 占满了这个池子,短时间内新的 outbound 连接就会随机失败,客户端报「cannot assign requested address」。这就是很多「莫名其妙偶发失败」的真正原因。
如果是短连接病,处方是:
- HTTP 侧开启 Keep-Alive,把「一次请求一个 TCP 连接」改成「一个连接跑 N 个请求」
- 客户端用长连接池,别每次
http.Get()就新建一个 Transport - 如果是调用别人给的 API,先看看对方是不是不支持 keep-alive——这一条经常被忽略,很多老系统硬生生把长连接降级成了短连接
三、长连接也有病:僵死连接与 Keep-Alive 的谎言
长连接不是装上就完事。中间设备(NAT 网关、防火墙、负载均衡)都有空闲连接老化策略,常见的是 300 秒、600 秒、900 秒。你的连接在应用层看还活着,实际已经被中间的盒子悄悄扔掉了。
表现极具迷惑性:绝大多数请求正常,偶尔某一次请求卡住整整 30 秒才失败,日志里只有一句 timeout。原因就是应用复用了那条「已死但未察觉」的连接,写入的数据没人读,对端早就把 RST 发了,你这边还在傻等。
解法是三层一起动:
// Go 里:必须自定义 Transport,http.DefaultTransport 不能复用
tr := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second, // 建连超时
KeepAlive: 30 * time.Second, // TCP 层探测间隔
}).DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // 这个值默认只有 2,串行调用时必踩
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}
顺便吐槽一句:Go 里 MaxIdleConnsPerHost 默认值是 2。你以为连接池有 100 个空闲连接,其实同一个后端只留 2 个,多余的照样新建连接、照样 TIME_WAIT。多少人在这上面查了半天气到抓狂,答案就一个默认值。
应用层再加一层心跳,让对端和应用双方都知道连接还活着:
// 心跳包本身就是一次极小的读写,能穿透大多数静默断连
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
n, err := conn.Read(buf)
if err != nil {
if os.IsTimeout(err) {
// 判定为僵死连接:直接丢弃,不要心疼
return conn.Close()
}
return err
}
关键原则:心跳超时就当连接已经死了。别舍不得,TCP 不会因为你「再等等」就复活。
四、优雅关闭:别用 RST 甩客户一脸
很多服务的下线逻辑是这样写的:收到 SIGTERM,立刻 os.Exit(0)。
然后客户端那边就看到一堆 Connection reset by peer。因为进程一退出,socket 被内核直接 RST 掉,之前那些正在传输的数据连同缓冲区一起被丢弃。
正确姿势是「排干再走」:
// 1. 收到终止信号后,先从负载均衡摘掉自己
// 2. 停止接受新连接
ln.Close()
// 3. 等待存量请求处理完(带超时兜底)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx)
// 4. 最后才退出
顺序不能反。先摘流量、再停接收、再排干、最后退出。任何一步颠倒,都会有一批请求在中间态被拒——这就是为什么很多团队每次发版都要「半夜重启还要提前发公告」。
五、还有几个坑值得单独点名
net.ipv4.tcp_tw_reuse:网上很多教程说打开它能减少 TIME_WAIT 占用。在纯客户端场景它有用,但在 NAT 或多网卡环境里乱开会导致连接串台——A 客户端的报文被送到 B 客户端的 socket 上,症状是「数据莫名其妙错乱」,排查起来能让人怀疑人生。客户端机器可以开,服务器慎开。
net.ipv4.tcp_fin_timeout:这个参数经常被误传成「TIME_WAIT 超时时间」。它管的是 FIN_WAIT2 状态,不是 TIME_WAIT。改它来治 TIME_WAIT,属于典型的「药不对症还多吃两片」。
backlog 太小:突发流量时,连接请求在内核队列里排不上,客户端直接收到 ECONNREFUSED 或超时。看 ss -lnt 里的 Send-Q(即 backlog 默认值),高并发服务给到 1024 起步都不夸张。
SYN flood 与 SYN cookies:半连接队列被打满时,启用 net.ipv4.tcp_syncookies=1 能让内核用编码方式保存状态而不是分配内存。代价是丢失部分 TCP 选项(尤其是窗口缩放),但在被攻击时这是你唯一能活下来的东西。
六、一份可以贴到墙上的排查清单
ss -tan | awk '{print $1}' | sort | uniq -c看状态分布——TIME_WAIT 多还是 ESTABLISHED 多?cat /proc/sys/net/ipv4/ip_local_port_range确认本地端口上限- 确认客户端是否复用连接(短连接病)
- 确认
MaxIdleConnsPerHost这类池参数不是默认值 - 检查中间设备的空闲连接老化时间,把心跳间隔设在它的一半以内
- 检查下线流程是否走了「摘流量 → 排干 → 退出」
- 突发失败先看 backlog 和 SYN cookies,再看是不是有人在重连风暴
写在最后
TCP 这层的东西有个共同特点:平时完全感知不到,一旦出问题又什么都说不清。连接数、端口、时间差——没有哪个指标能单独讲故事,必须把 ss 的输出、应用日志和客户端侧的报错时间线对齐着看。
而绝大多数所谓的「网络抖动」,拆开看都是生命周期管理没做好:连接建得太勤、关得太急、死得不明不白、走得也不体面。
所以,下次告警再响的时候,别急着扩容。先敲一句 ss -s。你多半会发现,服务器根本没在忙,它只是堵在那儿,一动不动。