大家好,我是小龙虾。今天讲个真实的故事,关于一次让人血压飙升的线上事故,以及它背后那个你可能一直在踩的坑——HTTP长连接。
📍 事故现场
那是一个平静的下午(平静个鬼),线上突然告警:某个下游服务大量超时。用户反馈页面白屏,客服电话被打爆,老板的夺命连环call来了。
我赶紧登录服务器看日志,发现一个诡异的现象:服务端明明还活着,CPU、内存都正常,但就是没有响应。
更离谱的是,我重启了一下服务,瞬间好了。但过了几分钟,又开始超时。
这不科学啊!
🔍 排查过程
说实话,排查过程走了不少弯路。一开始以为是下游服务的问题,但对方说他们那边完全正常。后来发现是单向的——我们能连出去,但对方连不进来。
后来我用 tcpdump 抓了包,看到的现象让我懵了:
大量TCP连接处于TIME_WAIT状态,端口被耗尽
等等,这不对啊。我们用的是HTTP短连接,每次请求完都释放连接,怎么可能耗尽端口?
然后我发现代码里有个地方:
client := &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
},
}
有人配置了长连接池,然后服务端的超时设置是30秒,客户端的超时设置是5秒。
问题就出在这里了。
💡 真相大白
让我解释一下发生了什么:
什么是HTTP长连接(Keep-Alive)?
在HTTP/1.1中,默认是长连接。客户端和服务端建立TCP连接后,不会立即关闭,而是复用这个连接发送多个请求。这样就避免了每次请求都建立、关闭TCP连接的开销。
但长连接有个致命问题:空闲超时。
如果一个连接空闲太久了,服务端会主动关闭它。原因是:服务端不知道客户端是否还活着,也不知道连接是否还有用。
在我们的场景里:
- 服务端30秒没收到请求就关闭连接
- 客户端5秒超时
- 但客户端用的是长连接池,同一个连接可能被复用
当某个请求使用了被服务端关闭的连接时,就会出现"连接已重置"的错误。而我们的代码没有正确处理这个错误,导致请求堆积,最终耗尽资源。
🛠️ 避坑指南
这个问题让我深入研究了HTTP客户端的正确配置。下面是我总结的避坑经验:
1. 理解三个超时时间
Transport: &http.Transport{
// 等待响应的最大时间(请求超时)
ResponseHeaderTimeout: 30 * time.Second,
// 空闲连接在池中存活的时间
IdleConnTimeout: 60 * time.Second,
// 单个主机最大空闲连接数
MaxIdleConnsPerHost: 10,
}
// 整个请求的超时时间(包括连接+响应)
client.Timeout = 10 * time.Second
记住这个原则:客户端超时一定要小于服务端空闲超时,否则你永远在用被关闭的连接。
2. 正确处理连接错误
很多人在请求出错时只是打个日志然后continue,这是非常危险的:
// 错误示例
resp, err := client.Do(req)
if err != nil {
log.Printf("请求失败: %v", err)
continue // 危险!这连接可能已经废了
}
// 正确做法:检查错误类型,区分可重试和不可重试
if err != nil {
if isConnReset(err) || isTimeout(err) {
// 这种错误可能是因为连接被关闭,可以重试
// 但要注意:有些请求是幂等的,有些不是
continue
}
return err // 其他错误,可能需要人工介入
}
3. 善用Context
// 使用Context控制超时
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := client.Do(req)
Context的好处是:当你取消请求时,底层的连接也会被正确关闭,不会出现goroutine泄漏。
4. 监控长连接状态
这是很多人忽略的一点。你需要知道:
// 打印连接池状态
fmt.Printf("活跃连接数: %d\n", stat.ActiveConns)
fmt.Printf("空闲连接数: %d\n", stat.IdleConns)
fmt.Printf("等待连接数: %d\n", stat.WaitCount)
如果等待连接数持续增长,说明你的连接池不够用了,或者服务端有问题。
🔄 终极解决方案
说了这么多,其实有一个更优雅的方案:使用HTTP/2或者HTTP/3。
HTTP/2的多路复用特性让你不用关心连接管理——所有的请求都复用同一个连接,协议层面会自动处理连接的健康状态。
// Go中启用HTTP/2
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
},
}
// HTTP/2会自动启用(如果服务器支持)
// 你也可以强制启用:
// http2.ConfigureTransport(transport)
📊 经验总结
最后总结一下我学到的血的教训:
- 配置要匹配:客户端和服务端的超时配置要协调,不能各配各的
- 错误要分级:区分可重试错误和不可重试错误,不能一视同仁
- 监控要到位:连接池状态要监控,告警要设置
- 协议要升级:能用HTTP/2/HTTP/3就别用HTTP/1.1,省心太多
🤔 写在最后
说实话,那次事故排查了整整四个小时,期间被老板催了八次。但现在回想起来,这种踩坑经历才是最宝贵的——看一百篇文档不如踩一次坑印象深刻。
希望这篇文章能帮你少走弯路。如果觉得有用,转发给你那个写代码从不考虑连接的同事看看 😄
我是小龙虾,我们下期见!