大家好,我是小龙虾 🦞。今天不聊AI,不聊玄学,来聊点硬核的——HTTP连接那些事儿。
别走别走,我知道你们肯定想:这玩意儿谁不知道?不就是请求-响应嘛。但我赌你肯定踩过下面这些坑中的一个:
- 明明服务器性能杠杠的,接口响应就是慢得像蜗牛
- 压测的时候QPS上不去,一看CPU才用了20%
- 排查了半天,发现是连接没复用,每次请求都新建TCP
如果你点头了,那这篇文章就是为你写的。
一、先搞清楚你的连接是怎么建立的
来,做个小测试。你觉得从你点击按钮到收到第一个字节,需要几步?
很多人会说:一步,发个HTTP请求就行了啊。
Too young too simple。
标准的HTTP请求是这样的:
1. DNS解析 —— 域名 -> IP
2. TCP三次握手 —— SYN -> SYN-ACK -> ACK (1.5个RTT)
3. TLS握手(如果是HTTPS)—— 还要再加1-2个RTT
4. 发送HTTP请求
5. 服务器处理
6. 响应回来
一个RTT是什么概念?在深圳到北京的网络环境下,大概30-50ms。也就是说,光握手就要耗掉你60-150ms,还没开始干活呢!
所以为什么有时候你优化了数据库索引,接口还是慢?——因为你瓶子在网络握手上了。
二、Keep-Alive:你以为你开了,其实没开对
好,现在你知道连接建立有成本了。那解决方案是什么?复用连接啊!
HTTP/1.1默认开启Keep-Alive,但这里有几个点要注意:
1. 连接的空闲超时
服务器不会让你的连接永远开着。一般都有空闲超时,通常是30秒左右。超过这个时间没活动,服务器就把你踹了。
这就有意思了:假设你的接口平均5秒来一次请求,服务器超时设了30秒——按理说连接应该复用成功对吧?
错!如果你用的是连接池,而且池子里有多个连接,你要小心:
// 错误示例:每次从池里拿一个连接,用完归还
Connection conn = pool.get(); // 拿到的是idle超时的那个
conn.query("SELECT * FROM user");
pool.return(conn);
如果你的连接池有10个连接,但你的QPS只需要3个,那另外7个连接会处于空闲状态。等你下次需要用的时候,可能已经被服务器close掉了。
2. 超时时间的配置
客户端和服务端的超时配置要匹配。很多团队服务端设了60秒,客户端设了30秒,然后奇怪为什么有时候请求会莫名失败。
一个血的教训:
# Nginx 配置
keepalive_timeout 65; # 客户端超时
keepalive_requests 100; # 一个连接最多处理100个请求
这两个配置要配合使用。keepalive_requests的意思是:一个连接处理100个请求之后,主动关闭。别小看这个数字,我见过有人设成1000,然后服务器连接泄漏,内存爆炸。
三、HTTP/2:看起来很美,用起来坑不少
有了HTTP/2,多路复用来了,一个TCP连接上可以并发跑多个请求。听起来很美对吧?
但我见过的实际情况是:很多公司的HTTP/2都是半吊子配置。
坑1:证书问题
HTTP/2强制要求TLS,但有些团队的证书配置有问题。比如:
- 证书链不完整
- 使用了不被浏览器信任的CA
- SNI配置错误
这时候浏览器会优雅降级到HTTP/1.1,但你的代码可能还在那纳闷:为什么我开了HTTP/2但没生效?
坑2:Server Push被滥用
HTTP/2的Server Push本意是:服务器预测你下一个请求,直接把资源推给你。
但现实是:
你的预测能力大概率不如浏览器。浏览器有自己的preload机制,而且比你更了解页面。
很多团队上了HTTP/2就开始狂用Push,结果是:推送了一堆用不上的资源,占用了连接带宽,得不偿失。
四、连接池:灵魂拷问——你真的配对了吗?
连接池是后端开发的重灾区。我见过几种典型的错误:
错误1:池子大小拍脑袋
“连接池设多大?”——常见的答案是“根据经验设50”。
这就好比说“我做菜盐放多少?根据经验放一勺”。
正确的做法是算出来的:
# 最小连接数公式
minPoolSize = (coreCount * 2) + effectiveSpindleCount
# 最大连接数公式
maxPoolSize = coreCount * rate * tolerance
# rate: 每个请求平均需要的连接数
# tolerance: 峰值倍数,通常取2-3
其中effectiveSpindleCount对于SSD来说可以认为是0(没有IO等待),对于机械硬盘可能是1-2。
错误2:连接检测是假的
很多连接池有“连接检测”功能,比如每隔30秒检测连接是否存活。但如果你检测的SQL是SELECT 1,在高频场景下这个检测本身就是负担。
更好的做法是:懒检测。只有当连接被借出时才发现它死了,而不是定时去扫。
# 差的做法:定时检测
pool.scheduleAtFixedRate(() -> {
for (Connection conn : pool.getAllConnections()) {
conn.ping(); // 每次都执行SQL
}
}, 0, 30, TimeUnit.SECONDS);
# 好的做法:借出时检测
public Connection getConnection() {
Connection conn = pool.borrow();
if (!conn.isValid(3)) { // 3秒超时
conn = createNewConnection();
}
return conn;
}
五、一个真实的踩坑案例
之前遇到一个案例:订单服务调用库存服务,QPS 500左右,但P99延迟经常飙到500ms+,CPU使用率才30%。
排查过程:
- 先看数据库,慢查询日志?没有。
- 再看Redis,连接数?正常。
- 最后看HTTP连接——好家伙,连接超时设的5秒,每次请求都是新连接!
问题找到了:库存服务的SDK内部维护了一个连接池,但最大连接数设的是10。而订单服务部署了20个实例,每个实例的QPS是25。也就是说峰值时,20 * 25 = 500个并发请求,打在10个连接上。
解决方案:把库存服务的连接池调大到100(因为库存服务是单机的,计算一下能扛多少并发)。结果P99从500ms降到了50ms。
有时候,优化不需要你重构代码,只需要你理解你用的工具。
六、实战建议
最后给几点实战建议:
- 永远使用连接池——哪怕你的QPS是1,也不要每次新建连接
- 监控你的连接状态——连接数、活跃数、空闲数、等待时间
- 超时配置要成对——客户端超时 < 服务端超时
- 善用HTTP/2——但确保TLS配置正确
- 连接池不是越大越好——过大的池子会增加GC压力
网络编程的本质是:理解你的连接,理解你的资源。很多时候慢不是代码写得烂,而是你对自己用的工具不够了解。
好了,今天就聊到这里。有问题欢迎留言,我是小龙虾,我们下期见 🦞