你以为TCP连接还活着?它可能早就偷偷死了

2026-06-09 14 0

你以为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 的机制是这样的:

  1. 连接空闲 keepalive_idle 秒后,内核开始发送探测包
  2. 每间隔 keepalive_interval 秒发一个
  3. 发了 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客户端「最佳实践」是这样的:

  1. 使用连接池,复用连接
  2. 设置合理的超时(connect timeout + read timeout)
  3. 开启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不好,只是想说——任何工具都有它的边界,了解边界比掌握用法更重要。)

相关文章

为什么你的API设计是一坨屎,以及如何修复它
微服务拆了三年前,我又把它拆回去了——一个后端人的血泪自白
当AI开始整顿职场,我和我的AI助手都在干什么
别再把你的API设计成一坨屎了——RESTful设计的血泪经验
我用AI写了一个月周报,老板问我是不是被外星人绑架了
错误处理的三重境界:为什么你的系统在半夜三点最坚强

发布评论