你以为TCP连接还活着?它可能早就偷偷死了
事情是这样的。
上周四晚上,线上报警突然炸了——用户下单接口大量超时,但后端服务明明还活着,数据库也稳如老狗,Redis也正常。排查了两小时,最后发现的问题让我想砸键盘:TCP连接池里有一半的连接其实是死的。
对,你没看错。应用觉得自己在和后端愉快地通信,实际上那个连接早就断了,只是没人告诉它。
这个问题太经典了,经典到我怀疑每个后端工程师都踩过,但很多人根本不知道自己踩过——直到某天被它背刺。
一个诡异的超时现场
先还原一下案发现场。我们的服务架构大概是:网关 → 业务服务 → 数据库/缓存。
接口超时集中在「业务服务调用数据库」这个环节,但数据库监控完全正常,连接数也没飙高。重启服务?好了。五分钟后又挂。
第一反应是:数据库连接泄漏?连接池配置有问题?
查了一圈,发现一个有意思的现象:所有超时的请求,都是在「空闲」了大概75秒之后发出去的。而正常请求几乎没有这个问题。
75秒。这个数字很眼熟。Linux内核的TCP Keepalive默认超时是7200秒(两小时),但我们的服务里配置了75秒。
# 我们的服务配置
tcp:
keepalive: true
keepalive_idle: 75
keepalive_interval: 5
keepalive_count: 3
问题就在这:TCP Keepalive 是把双刃剑,你以为它在帮你保活,实际上它在悄悄杀你。
TCP Keepalive 的黑暗面
大多数人对TCP Keepalive的理解是这样的:「哦,就是保活嘛,防止连接被中间设备(比如防火墙/NAT)干掉。」
这个理解对了一半。
Keepalive 的机制是这样的:
- 连接空闲
keepalive_idle秒后,内核开始发送探测包 - 每间隔
keepalive_interval秒发一个 - 发了
keepalive_count次都没相应,就判定连接已死,通知应用层
在我们的配置里:75秒空闲 → 发送探测 → 等5秒 → 没回应再发 → 最多发3次。
也就是说,一个连接如果真的挂了,最多 75 + 5*3 = 90秒 内会被判定死亡。
听起来挺合理的对吧?
但问题在于——Keepalive 只负责检测「对方主机是否还活着」,它检测不了「对方进程是否还活着」。
什么区别?
想象一下这个场景:后端进程卡死了,OS还活着,TCP栈还在,那Keepalive就会一直认为连接是好的。但实际上对端应用已经无法处理任何请求了。
更坑的是另一个场景:对端进程僵死了,但还在accept()。它会接受连接,但永远不会处理。Keepalive探测会成功(因为TCP层面有响应),但应用层面已经死透了。
这就是我们的故障根因吗?不是,但差不多——
真正的凶手:CLOSE_WAIT 陷阱
我们遇到的是另一个经典问题:CLOSE_WAIT 堆积。
当对端异常崩溃(比如直接kill -9),本端会收到FIN,但本端如果没正确处理这个FIN(没close socket),就会进入CLOSE_WAIT状态,连接挂在那,既不发送数据也不接收,但应用以为它还活着。
在我们的代码里,有一段HTTP客户端的封装,大概长这样:
func (c *Client) Do(req *Request) (*Response, error) {
conn, err := c.pool.Get()
if err != nil {
return nil, err
}
// 发送请求...
resp, err := c.send(conn, req)
if err != nil {
// 错误处理:标记连接为坏,下次复用时跳过
conn.MarkBad()
c.pool.Put(conn) // 放回池里,但标记了bad
return nil, err
}
c.pool.Put(conn) // 正常放回
return resp, nil
}
问题出在哪?当连接在send时断了(比如对端崩溃),我们catch到了error,把连接标记为bad然后放回池里。但这个连接其实已经被对端关闭了(进入了某种中间状态),我们把它放回池的时候,它可能已经处于一种「半死不活」的状态。
下次复用这个连接的时候,write()会成功(因为内核认为连接还活着),但read()永远等不到响应(因为对端根本不认识这个连接了,会发RST)。
这就是为什么超时总是发生在「空闲一段时间后的第一个请求」——因为池子里的连接在复用。
你以为的最佳实践可能都是坑
很多人学到的HTTP客户端「最佳实践」是这样的:
- 使用连接池,复用连接
- 设置合理的超时(connect timeout + read timeout)
- 开启TCP Keepalive
这三板斧看起来没问题,但每个都有隐藏的坑:
坑1:连接池不健康连接检测
连接池最大的问题不是「拿不到连接」,而是「拿到的连接是坏的」。大多数连接池实现只做「数量管理」,不做「健康检测」。你把一个半死的连接放回去,它不会自动消失。
坑2:超时设置不合理
很多人把超时设成30秒、60秒,但从来不考虑:「如果服务端真的卡住了,60秒的超时能救你吗?」
答案是:不能。它只会让你等60秒才发现问题。而且这种超时是被动超时,你根本不知道是因为服务端慢了还是连接死了。
坑3:Keepalive不是银弹
Keepalive能检测「对端主机死了」,但检测不了「对端进程死了」。而且Keepalive的探测会消耗网络流量,在高并发场景下可能成为性能瓶颈。
真正能救你的几件事
说了这么多坑,是时候给点硬货了。这么多年踩坑踩出来的经验:
第一件事:主动检测,而不是被动超时
不要依赖TCP层面的超时,要在应用层做健康检测。连接归还池之前,做一次轻量的ping:
func (conn *Connection) IsHealthy() bool {
// 尝试发送一个心跳
if err := conn.SendPing(); err != nil {
return false
}
// 等待pong,超时就判死
return conn.WaitPong(2 * time.Second)
}
// 归还连接前检测
func (p *Pool) Put(conn *Connection) {
if !conn.IsHealthy() {
p.remove(conn) // 真的销毁,不放回
return
}
p.put(conn)
}
这个检测会增加一点延迟,但比起把一个坏连接放回池里导致后续一堆请求超时,这笔账划算得多。
第二件事:区分「超时」和「死亡」
超时有两种:一种是「服务端还活着但慢」,另一种是「连接已经死了」。
对于前者,你等待是有意义的;对于后者,你等待只是在浪费时间。
一个好的策略是:「快速失败 + 重试到另一台」:
// 第一次请求,只给1/3的超时
resp, err := client.Do(req, WithTimeout(defaultTimeout/3))
if err != nil {
// 快速失败后,立刻换一台重试
resp, err = client.Do(req, WithTimeout(defaultTimeout), WithRetry())
}
这样,如果连接是死的,你用1/3的时间就能发现,然后换一条好路走。
第三件事:给你的连接池加个保质期
不要让连接在池里待太久。即便是健康的连接,如果闲置超过一定时间(比如5分钟),也应该主动销毁重建。
type PoolConfig struct {
MaxIdle: 100
MaxActive: 1000
IdleTimeout: 5 * time.Minute // 闲置超过5分钟就销毁
}
这个设计能规避很多「连接早已死去但我们以为它还活着」的问题。
第四件事:监控连接池的库存周转
你可能会问:「我怎么知道池子里有多少坏连接?」
答案是监控。
// 记录连接的健康状态
pool.metrics.Inc(connection.healthy)
pool.metrics.Inc(connection.dead)
// 定期上报:池子里有多少连接,上次归还时有多少是坏的
log.Printf(pool stats: active=%d, idle=%d, dead_in_last_check=%d,
pool.activeCount(), pool.idleCount(), pool.recentDeadCount())
当「dead」数量开始飙升的时候,你就知道该查查了。
背刺你的不是技术,是你对技术的误解
TCP Keepalive是个好东西,但它不是银弹。连接池是工程实践中最重要的优化之一,但它也是bug的温床。
很多时候,不是技术背刺了你,是你对技术的理解背刺了你。
你以为Keepalive能保活,所以你的连接永远健康?错了。
你以为把错误连接标记为bad放回池里,下次就不会用到?错了。
你以为超时设长一点就能容忍一切?错了。
后端开发这个行当,干久了就会发现:最难的不是写出能跑的代码,是搞清楚你写的代码到底在干什么。
下次再遇到诡异的超时,先别急着加日志、restart、call老板。冷静下来想想:你的连接池,真的还活着吗?
(写完了,发现这篇文章又是一次深夜故障排查的血泪史。不是说TCP Keepalive不好,只是想说——任何工具都有它的边界,了解边界比掌握用法更重要。)