👋 关于作者
你好,这里是一条 泥憨鱼 | 专注于 后端开发 、AI 前沿技术与计算机基础 。

🏠 个人主页 :一条泥憨鱼
通过技术博客记录技术成长,分享学习心得与经验。期待与您共同交流!
📚精选技术专栏:
|-------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| ❄️后端开发 | ❄️Redis| 黑马点评 |
| ❄️从0开始学习计算机网络 | ❄️数据结构与算法 |
| ❄️苍穹外卖 | ❄️探秘AI与agent |
一个查询接口压测 QPS 从 200 提到 2000,P99 从 20ms 涨到 300ms,慢查询日志却一条没有,耗时全花在建立连接上。这篇从 TCP 建连的真实开销讲起,一路讲到长连接的心跳保活、连接池的借还语义和池大小怎么定。
短连接的成本花在哪
短连接的模型很直白:请求来了建连,数据传完断开,下次再来重新建。单看一次请求,开销似乎可以忽略,但把一次完整请求拆开,成本分布在四个位置。

第一段是 TCP 三次握手。客户端发 SYN,服务端回 SYN-ACK,客户端再回 ACK,一个 RTT 之后连接才可用。同机房内 RTT 大约 0.2 到 0.5 毫秒,跨机房可能到几毫秒甚至几十毫秒。
第二段是 TLS 握手,如果走 HTTPS 或加密的数据库连接。TLS 1.2 完整握手需要两个 RTT,TLS 1.3 压缩到一个 RTT,但加上证书校验和密钥协商,实际开销仍然明显。这也是为什么 HTTPS 站点普遍启用会话复用。
第三段是TCP 慢启动。新建的连接拥塞窗口初始只有约 10 个 MSS(Linux 较新内核默认值),意味着这条连接第一次能发的数据量很小,需要几个 RTT 才能把窗口涨上来。对于传输量稍大的响应,新建连接的前几个包几乎都在等窗口扩张。
第四段在关闭侧。主动关闭方进入 TIME_WAIT,持续 2 倍 MSL(Linux 上通常 60 秒)。这段时间里,该四元组不能被复用,端口被占着。
单次建连多花一两个 RTT,看起来不多。问题在于它和 QPS 是乘法关系。2000 QPS 的短连接,意味着每秒 2000 次握手加 2000 次挥手,每秒新增 2000 个 TIME_WAIT 状态的套接字,60 秒内累积到 12 万个。客户端可用端口范围默认约 2.8 万个(net.ipv4.ip_local_port_range 常见配置 32768-60999),端口很快耗尽,新连接直接报 Cannot assign requested address。服务端一侧同样承压:大量短连接让 accept 队列和文件描述符频繁创建销毁,netstat 里 TIME_WAIT 数量飙升,严重时新连接被丢弃。
这两个坑在生产里都真实存在,而且症状具有迷惑性------业务代码完全正常,报错出现在网络层,排查时容易误判成下游故障。
长连接省掉了什么,又引入了什么
长连接把连接的生命周期从"一次请求"拉长到"多次请求",握手和慢启动只在建立时付一次,后续请求直接复用已有的 TCP 通道。上面那笔建连开销,在连接存续期内被摊薄到接近零。
代价是连接变成了有状态资源,而状态会出问题。
最典型的是连接被中间设备静默掐断。客户端和服务端之间通常隔着 NAT 网关、负载均衡、防火墙,这些设备维护连接状态表,对空闲连接有超时回收策略,常见值从几十秒到几分钟不等。超时一到,设备直接丢弃状态表项,不发任何通知。客户端以为连接还活着,下次写入时才发现对端已经没了------这就是所谓的"连接假死",表现为偶发的 Connection reset by peer 或读到 EOF。

解法是心跳保活,让连接上的数据间隔始终小于中间设备的空闲超时。关键在于两个数值的配合:心跳间隔必须小于链路上所有中间设备中最小的 idle timeout。链路里如果有一台设备 60 秒回收空闲连接,心跳设成 90 秒就毫无意义。工程上的经验是取最小超时值的三分之一到二分之一,留出网络抖动和调度延迟的余量。
TCP 自带的 keepalive 不能替代应用层心跳。内核参数 tcp_keepalive_time 默认 7200 秒,即使调小,探测失败后还要经历若干次重试才判定断连,检测周期太长,而且它只探测 TCP 层连通性,无法发现对端进程已死但内核仍持有连接的情况。应用层心跳更可控。
长连接还有两个约束。一是连接状态和业务状态可能不一致:连接活着,但服务端上的会话、事务上下文、认证票据已经过期,请求发过去被拒。这类问题要在协议层处理,比如连接失效时重建并重新认证。二是单连接上的队头阻塞------同一条连接上的请求按序处理,一个慢请求会堵住后面所有的。HTTP/1.1 的管线化和数据库连接的串行执行都有这个问题,这也是为什么长连接几乎总是和"一连接一请求"的独占使用方式搭配,而不是让多个线程共享一条连接。
连接池是长连接的管理层,不是长连接本身
这里要区分两个概念,混淆它们会导致很多设计错误。
连接复用是一件事:把建好的连接留着,下次接着用。连接池是另一件事:在并发环境下管理一批可复用连接,决定谁借、谁还、最多建几条、坏了怎么换。单线程程序里一条长连接就够了,不需要池;只有并发请求争抢有限连接时,才需要池来做分配和限流。
池的核心结构是三个集合:空闲连接队列、活跃连接集合、等待队列。借连接时从空闲队列取,取不到且未达上限就新建,达到上限就进等待队列阻塞;归还时把连接放回空闲队列,并唤醒等待者。

用一段最小实现说明借还语义,代码省略了异常处理和超时,只保留结构:
java
public class SimplePool {
private final BlockingQueue<Connection> idle; // 空闲连接
private final AtomicInteger total = new AtomicInteger();
private final int maxSize;
public Connection borrow() throws InterruptedException {
Connection c = idle.poll();
if (c != null) return c;
// 未达上限则新建,否则阻塞等待归还
if (total.get() < maxSize && total.incrementAndGet() <= maxSize) {
return createConnection();
}
total.decrementAndGet();
return idle.take(); // 等待其他线程归还
}
public void release(Connection c) {
idle.offer(c); // 归还,唤醒等待者
}
}
真实实现(HikariCP、Druid、Apache Commons Pool)要复杂得多,但骨架就是这个:一个并发安全的空闲队列加一个总量计数器。池的所有高级能力------健康检查、泄漏检测、空闲回收、连接预热------都是在这个骨架上加的策略。
池大小怎么定,是更容易出错的地方。常见的错误直觉是"并发高就把池调大",实际上池上限受下游承载能力约束。数据库有 max_connections,如果 10 个应用实例每个配 50 条连接,总需求 500 条,而数据库上限是 200,多出来的 300 条会在建连时被拒绝,表现为间歇性的连接获取失败。池上限的正确算法是先确定下游能给多少,再除以实例数,留出余量给运维和监控连接。
池也不是越大越好。每条连接在下游都占用一个服务线程和一份内存,连接数超过下游最优并发度后,吞吐不再上升,反而因为上下文切换和锁竞争下降。经验上 OLTP 场景的池大小远小于直觉值,HikariCP 文档里给出的参考公式是"连接数 = CPU 核数 × 2 + 有效磁盘数",前提是查询足够快。这个数字对小规模服务经常只有个位数。
连接泄漏是另一个把池耗尽的典型原因。借出的连接在异常路径上没有被归还,池里的可用连接越来越少,最终所有请求都卡在 borrow() 上。现象很好认:接口整体挂起而非报错,线程栈里大量线程停在获取连接的位置,池的监控指标显示活跃连接数持续等于上限、空闲数为零。
池化之后的风险
池解决的是复用和分配,不解决使用方的纪律问题。下面几个坑在项目里反复出现。
借出后不归还。 最隐蔽的形态是异常路径漏了关闭。用 try-with-resources 或框架提供的模板方法能规避大部分情况,但如果手动管理连接,任何在 release 之前抛异常的代码路径都会泄漏。用连接池自带的泄漏检测(HikariCP 的 leakDetectionThreshold)能在一段时间后打出借出点的堆栈,是排查这类问题的有效手段。
池内连接被对端关闭。 连接在池里空闲时,对端或中间设备把它断了,池并不知情。下次借出直接使用,第一个请求就报错。两种应对思路:借出时检测(testOnBorrow,每次借出跑一次校验查询)和空闲时保活(定期心跳或 testWhileIdle)。前者最可靠但每次借出多一次往返,后者开销低但存在检测窗口。折中做法是设置 testWhileIdle 配合合理的空闲检测周期,再加上使用方的重试逻辑兜底。校验查询要足够轻,一条 SELECT 1 或协议层的 ping 就够,别写复杂 SQL。
事务生命周期和连接生命周期不匹配。 连接在池里是复用的,但事务状态不是。如果一条连接上开了事务却没有正确提交或回滚就归还,事务状态可能残留在连接上,污染下一个使用者。这也是为什么连接池归还时通常要重置连接状态(回滚未提交事务、清除会话变量)。用 Spring 的 @Transactional 时,连接由事务管理器统一借还,一般不会出问题;手动管理 Connection 又混用事务时就要格外小心。
**池大小和线程池大小错配。**请求线程池有 200 个线程,连接池只有 20 条连接,结果是 180 个线程在等连接,线程池被大量阻塞线程占满,连不需要数据库的接口也拿不到线程执行。两个池的容量要一起设计:连接池上限应该小于等于线程池大小,否则多出来的线程只是在排队消耗内存。反过来,连接池远大于线程池也没有意义,多出来的连接永远借不出去。
收束
连接复用到什么程度,取决于三个量一起看:调用频率决定建连开销能否被摊薄,下游承载能力决定连接数上限,中间设备超时决定连接能活多久。三者里任何一个没算清楚,复用策略都会在某个方向上出问题。
忽略的是池上限和下游连接数上限的关系。池只是在应用侧做排队,它不增加下游的承载能力。池上限设得比下游能接受的还大,压力并不会消失,只是从应用侧推迟到了建连失败的那一刻------池把问题往后推了一格,没有解决它。