为什么你的HTTP连接总是不够keep——一次线上事故把我整破防了

2026-05-23 8 0

为什么你的HTTP连接总是不够keep——一次线上事故把我整破防了

先说个真事,不编。

去年Q4,我们服务频繁报超时,用户怨声载道,监控大盘一路飘红。我以为是接口慢、DB卡、或者哪个同事写了死循环。结果排查了整整三天,最后发现罪魁祸首是——HTTP Keep-Alive

你没看错,就是那个面试里谁都能背出来"HTTP keep-alive是复用TCP连接"的知识点。理论一套一套,线上照样翻车。这就是后端开发的残酷——你以为懂了,其实差得远。面试造航母,工作拧螺丝,拧完螺丝还得回去补航母的设计图。这篇文章就是我那段黑暗经历的完整复盘,每一个坑都是真实踩过的。


从一次诡异的超时说起

事故大概是这样的:晚高峰期间,某核心接口P99延迟突然从50ms飙升到8000ms+,持续大概15分钟,然后自己好了。注意,是自己好了

自己好了这种事最让人崩溃,因为第二天可能又来一次,随机出现,无法预测。你不知道它什么时候来,也不知道它为什么走。你以为是玄学,其实是某个你没注意到的阈值刚好被触发了。问题自己消失,不代表问题被解决,它只是换了一种方式潜伏。线上问题最怕的就是这种——你以为相安无事,其实暗流涌动,等你发现的时候已经是大海啸了。

第一反应是查DB。慢日志?没有。连接数?正常。Replication延迟?忽略不计。

第二反应是查上游服务。对方服务正常,接口响应时间稳定在20ms以内。

第三反应是查内网。网络抖动?丢包?没有。带宽打满?NO。

我心态已经开始崩了。半夜三点还在盯着监控台,感觉自己像个没头苍蝇。老板问怎么回事,我说还在排查,其实心里已经有点绝望了——DB正常、上游正常、网络正常,那问题到底在哪?监控面板上的每一个指标我都看了,每一项都green的,但用户就是在骂。这大概是每个后端工程师都经历过的至暗时刻。

最后是怎么发现的?用ss -s统计了一下服务器TCP连接状态,发现TIME_WAIT状态的连接堆了将近4万个netstat -an | grep TIME_WAIT | wc -l一跑,整个人都傻了。clat -a一看,本地端口可用范围已经快耗尽了。再来一波请求,连新建连接都困难。整个服务的入口已经被人为堵死了,但没有一个人发现——因为入口还在,监控只告诉你请求进来了,不告诉你请求进来之后发生了什么。

等等,Keep-Alive不是复用连接吗?为什么还会有4万个TIME_WAIT?

这问题困扰了我整整一晚上,直到我去翻了RFC和Linux内核参数文档,才搞清楚来龙去脉。原来不是Keep-Alive的错,是我们对它的使用方式有问题。Keep-Alive是个好同志,但我们不懂它。


你以为的Keep-Alive,和实际发生的Keep-Alive

很多人对HTTP Keep-Alive的理解是这样的:客户端和服务端建立一次TCP连接,然后复用这个连接发多个HTTP请求,不需要每次都重新握手,省去了TCP握手和慢启动的开销,性能飙升。

这个理解没错,但它忽略了一个关键问题——这个连接到底能复用多久?

来,看几个常见配置:

# Nginx默认配置
keepalive_timeout 65;
keepalive_requests 100;

# Apache默认配置
MaxKeepAliveRequests 100
KeepAliveTimeout 5

# 某些HTTP Client默认
maxKeepAliveRequests: 100
keepAliveTimeout: 30s

看到了吗?这些配置的默认值清一色是100个请求,或者几十秒的timeout。不是你想象的"一直复用直到天荒地老"。HTTP Keep-Alive是一种尽力而为的优化,不是连接永久存活协议。你不对它做限制,它就会对你的系统做限制。

也就是说,如果你的QPS很高,一个连接用到100个请求之后就被关掉了。关掉之后会发生什么?

连接进入TIME_WAIT状态,这是TCP协议规定的"优雅关闭"状态——等待2MSL(Maximum Segment Lifetime,通常是60秒),确保对方已经收到所有数据,之后才能彻底释放这个socket。MSL是TCP协议定义的一个分段最大生存时间,每一侧都可以自己定义,Linux默认是30秒,所以2MSL是60秒。这60秒就是你的连接在系统里尸体的停留时间。

如果你的服务每秒处理1000个请求,每个连接只能服务100个,那就意味着每秒要关掉10个连接。10个连接 × 60秒 = 600个TIME_WAIT堆积是起步量。注意,这还是理想情况,实际会更糟,因为请求分布不均匀,高峰期可能会在短时间内制造出上万个TIME_WAIT。

这不是Bug,这是TCP协议规定的。你没法绕过去,除非你开tcp_tw_reuse或者调整ip_local_port_range。我之前的错误就是以为连接可以无限用,结果把服务器的资源耗得一干二净,自己还不知道是怎么回事——因为代码层面上,一切都"正常运行"。

到这里我才明白:我们一直在抱怨连接不够用,但其实是我们自己把连接用得太狠了,用完就扔,扔了又不让系统快速回收,就这么简单。Keep-Alive不是无限的,它有自己的生命周期,不尊重它,它就会给你颜色看。不尊重协议,协议就教你做人。


连接池:救星还是另一个坑?

知道问题之后,团队引入了连接池方案。HTTP Client配了连接池,最大连接数200,maxKeepAliveRequests=100。连接池的核心思想很简单:提前创建好一批连接放在那里,谁要用就直接拿,用完还回去,不用每次都创建销毁。省去了TCP握手和慢启动的时间,提高了复用率。

听起来很美好对吧?连接复用,限制数量,不会泄漏。教科书级的解决方案。

然后我们就遇到了另一个问题——连接池死锁

具体场景是这样的:服务A需要调用服务B的两个接口,这两个接口有依赖关系——必须先等接口1返回的某个字段作为参数,才能调接口2,并且这两个接口走的是同一个共享连接池。

当并发量大的时候,200个连接被同时占用,所有线程都在等待依赖接口返回,但连接已经被耗尽了。没有任何连接可以用来发起依赖请求。死锁。两个字。

更操蛋的是,这种死锁不是每次都复现——只有当连接池使用率超过某个阈值的时候才会触发。本地测试正常,staging正常,线上高峰必挂。这种Bug最讨厌,因为它不稳定,不稳定就难复现,难复现就难修。你跟老板说这个问题存在,老板问你为什么不修,你说因为本地复现不了,老板看你的眼神就像看骗子。这种眼神我也经历过,不想你再经历了。

怎么解决的?给关键依赖接口单独建连接池,不走共享池。共享连接池解决的是通用问题,但依赖链问题需要单独处理。这就是架构设计的一部分——你得知道你的调用链是什么样的,然后决定哪些走共享池,哪些必须独立。

这个教训让我彻底重新审视"连接池"这三个字——用不好连接池,比不用连接池还危险。连接池是个好东西,但你得知道它什么时候会反咬你一口。每加一个连接池,都要问自己:如果所有连接都被占满了怎么办?有备用方案吗?依赖链会导致死锁吗?这些问题不问清楚,线上就会教你问。

另外还有一个常见问题:连接池的连接泄漏。比如你发了一个请求,服务端已经响应了,但你的代码忘了关闭流或者没有正确处理超时异常。这个连接就会被标记为"占用中",不会再被复用。积累多了,连接池就废了。有个土办法可以检测:定期打印连接池的活跃连接数和空闲连接数,如果活跃数长时间等于总数,说明可能有泄漏,得查代码了。这种泄漏Bug最阴险的地方在于,它不会让你的服务马上挂,而是慢慢耗尽连接池,等到某天高峰来了才爆发。


连接健康检测:你以为连接还活着,它可能已经死了

再讲一个问题,这个更隐蔽,但破坏力一点都不比前两个小。

我们的服务部署在两个机房,正常情况下走内网调用。有一次机房之间网络维护,断了大概5分钟。恢复之后,服务没有明显报错,但大量请求失败——超时。奇怪的是,监控显示对方服务是正常的,我们的健康检查也是通过的。

查原因:连接池里的连接都是好的,但它们记录的是维护之前的网络路径。现在路径变了,但连接没有重新建立。TCP层面没问题,但应用层已经走不通了。

说白了:连接池认为这些连接是健康的,但它们已经不能用来通信了。连接池没有能力感知底层的网络拓扑变化。TCP连接只是四层的一个虚拟通道,它不关心上层的机房网络调度。连接还保持ESTABLISHED状态,但底层的网络路径已经不通了。这种情况下,TCP的keepalive探测也会成功,因为网络层面还是通的——只是绕了远路,或者走到了错误的路径上。

HTTP Keep-Alive本身没有做连接健康检测的逻辑。连接只是"没有被主动关闭",不代表它还能用。这是一个认知盲区,很多人都忽略了。我也是那次才意识到,Keep-Alive只保证连接在协议层面没有被关闭,但不保证连接在业务层面还能工作。这两个层面经常被混为一谈,但它们完全不是一个东西。

解决方案:给连接加TTL,定期销毁重建。或者更激进一点——每次请求都做一次"快速健康检测",不信任任何长期空闲的连接。比如发一个HTTP OPTIONS请求探测对方是否还活着,超时时间内没响应就重建连接。

// 一个简单的连接健康检测思路
public boolean isConnectionHealthy(HttpConnection connection) {
    try {
        // 用一个超短超时发探测请求
        connection.sendRequest(new HttpRequest("OPTIONS", "/health") {
            timeout = 500ms
        });
        return true;
    } catch (Exception e) {
        // 不健康,标记为需要重建
        connection.markUnhealthy();
        return false;
    }
}

当然,这个方案也有代价——每次请求都多了一次OPTIONS调用,高并发下会有一定性能损耗。所以也可以做成异步检测,定期跑,不用每次请求都检测。架构就是这样,没有什么完美方案,只有合适的权衡。你接受什么代价,就做什么选择。


实际调优:我是怎么把TIME_WAIT从4万压到2000的

说点干货,纯实战,不注水。三个方法一起用,效果最好。

第一,优化连接使用率。别让连接用到100个请求才关,调低maxKeepAliveRequests,比如30-50个。这样单个连接生命周期短了,堆积的TIME_WAIT自然少。有人会说这会增加连接创建的开销——没错,所以要配合后面两个优化一起用。单独用这一个,效果不明显,但组合起来效果很好。

# Nginx侧
upstream backend {
    server 10.0.0.1:8080;
    keepalive 30;          # 保留30个空闲连接
    keepalive_requests 30; # 每个连接最多服务30个请求
    keepalive_timeout 10s; # 空闲10秒就关
}

# HTTP Client侧
maxKeepAliveRequests: 30
keepAliveTimeout: 10s
connectionPoolSize: 200

第二,服务端打开tcp_tw_reuse。这个参数可以让TIME_WAIT状态的socket被重用,快速终结连接生命周期。注意,这个参数客户端和服务端都能用,但效果略有不同。服务端开这个参数,主要是为了让自己主动关闭的连接能更快被回收。不是所有场景都适合开,建议先做压测再上。

# /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 30

第三,调整本地端口范围。ip_local_port_range如果太小,端口会很快耗尽,这个前面提过。如果你的服务器是新建的,很可能用的是默认值,一定要改成更大范围。不改的话,高并发下去,端口会先于连接池成为瓶颈——你调好了连接池,结果发现本地端口不够用了,这才是最冤的。

net.ipv4.ip_local_port_range = 32768 60999
# 执行以下命令生效
sysctl -p

三板斧下去,TIME_WAIT从4万降到了2000左右,端口耗尽的问题彻底消失。服务稳定了,老板不骂了,我也终于能睡个整觉了。有时候性能调优就是一层窗户纸,捅破了没什么,但捅之前你得在黑暗里摸索很久。希望我这篇文章能帮你少摸一会儿黑。


最后说几句真心话

HTTP Keep-Alive这个知识点,面试能背出来的人太多了,但真正在生产环境里踩过坑的没几个。我也是那次事故之后才意识到——TCP协议不是免费的,它有自己的生命周期和资源消耗。连接复用的背后是资源管理,资源管理的背后是对协议原理的深刻理解。你以为你在用HTTP,其实你无时无刻不在跟操作系统和网络协议栈打交道。每一次你抱怨连接不够用的时候,先问问自己:我真的了解TCP连接的生命周期吗?

写这篇文章不是要吓唬大家不用Keep-Alive。恰恰相反,Keep-Alive是性能利器,但用好它需要理解它的代价:连接不是无限复用的,它有请求数上限、有超时时间、有状态生命周期。每一个参数背后都是成本和收益的权衡。你调优的不只是参数,是你对系统的理解深度。

下次你再配置连接池或者调整超时参数的时候,希望你能想起我踩过的这些坑。连接不够用的时候,别急着加机器,先看看你的连接是怎么生怎么死的。有时候问题的答案就在你忽略的那个默认配置里。希望你的线上服务永远不要出现我经历过的那些问题,但如果出现了,希望你能比我更快找到根因。

祝你代码无Bug,连接不死锁。

相关文章

发布评论