【计算机网络】短连接、长连接与连接池:从 TCP 握手到池化复用的完整链路

👋 关于作者

你好,这里是一条 泥憨鱼 | 专注于 后端开发 、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 个线程在等连接,线程池被大量阻塞线程占满,连不需要数据库的接口也拿不到线程执行。两个池的容量要一起设计:连接池上限应该小于等于线程池大小,否则多出来的线程只是在排队消耗内存。反过来,连接池远大于线程池也没有意义,多出来的连接永远借不出去。

收束

连接复用到什么程度,取决于三个量一起看:调用频率决定建连开销能否被摊薄,下游承载能力决定连接数上限,中间设备超时决定连接能活多久。三者里任何一个没算清楚,复用策略都会在某个方向上出问题。

忽略的是池上限和下游连接数上限的关系。池只是在应用侧做排队,它不增加下游的承载能力。池上限设得比下游能接受的还大,压力并不会消失,只是从应用侧推迟到了建连失败的那一刻------池把问题往后推了一格,没有解决它。

相关推荐
tianyuanwo1 小时前
NetBox 数据库详解:当“关系型数据库”被装进一个网络运维的领域模型里
运维·数据库·netbox
IvorySQL2 小时前
深入 PostgreSQL WAL:从崩溃恢复到逻辑复制
数据库·postgresql
北京盛世宏博2 小时前
数字孪生 3D 可视化实战:档案十防体系温湿度数据联动与告警定位实现
网络·档案八防·十防
幽灵诶3 小时前
Mysql(基于测试的学习)
数据库·mysql
Lynne3093 小时前
EtherCAT网络中,多个节点如何配置才能互不干扰?
网络·网络配置·ethercat·工业网络
砚底藏山河3 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
Yang96113 小时前
实验室里的多面手:鼎讯信通TM-150信号发生器技术拆解与实测思考
网络
cyf313 小时前
第 05 篇:IP 协议—— 网络层的“快递单号与物流追踪“
网络协议·tcp/ip·路由表·traceroute·ip协议·ping·ip分片
宇宙无敌暴龙战士6664 小时前
MySQL 8 库表操作学习笔记:SQL 基础、库表管理、约束与用户授权全流程
数据库·sql·mysql·学习笔记·后端开发