共计 5486 个字符,预计需要花费 14 分钟才能阅读完成。

最近在做一个需要实时推送的项目时,重新梳理了一遍长连接相关的知识。发现很多开发者(包括以前的我)对"长连接"这个概念的理解其实比较模糊,经常把 HTTP Keep-Alive、[[WebSocket]]、TCP 长连接这些东西混在一起。今天就从最基础的地方说起,把这些概念理清楚。
短连接和长连接的区别
要理解长连接,先得明白短连接是什么。
在 HTTP/1.0 的时代,默认的通信方式就是短连接。客户端每发一次请求,都要经历一个完整的流程:建立 TCP 连接(三次握手)、发送请求、接收响应、关闭连接(四次挥手)。下一次请求再重复这个过程。这就好比你每次给朋友发消息,都要先拨电话、说一句话、挂断,再拨电话、再说一句话、再挂断。
长连接(Persistent Connection)的做法不同。建立一次 TCP 连接之后,不急着关闭它,后续的请求和响应都可以在这条连接上复用。就像打电话接通之后,你们可以持续聊天,聊完了再挂断。
两者的核心区别在于连接的生命周期。短连接是"一次请求一条连接",长连接是"一条连接多次请求"。这个差异带来的最直观的好处就是减少了大量重复建立和关闭连接的开销,在高并发场景下效果尤为明显。
HTTP Keep-Alive 的工作机制
HTTP 协议层面对长连接的支持,就是通过 Keep-Alive 实现的。
在 HTTP/1.0 中,Keep-Alive 是一个非标准的扩展,需要客户端和服务器显式协商。客户端在请求头里加上 Connection: Keep-Alive,服务器如果支持,就在响应头里也回复 Connection: Keep-Alive,并且可以通过 Keep-Alive: timeout=5, max=100 来告诉客户端这条连接最多空闲 5 秒、最多处理 100 个请求。
到了 HTTP/1.1,持久连接成了默认行为。也就是说,除非你主动发送 Connection: close,否则连接建立后就会一直保持。这是一个很大的进步,意味着浏览器访问一个网页时,加载 HTML、CSS、JavaScript、图片这些资源可以复用同一条 TCP 连接,而不需要为每个资源单独建立连接。
这里有一个关键的技术细节:在长连接上复用时,接收方怎么知道一个响应在哪里结束、下一个响应从哪里开始?HTTP/1.1 用了两种机制来解决这个问题。一种是 Content-Length 字段,明确告诉接收方本次响应的字节数。另一种是分块传输编码(Chunked Transfer Encoding),适用于内容长度未知的场景,比如流式数据。每个数据块前面标注大小,最后用一个大小为 0 的块表示结束。
需要注意的是,HTTP Keep-Alive 和 TCP Keep-Alive 是两个不同层面的东西,不要混淆。HTTP Keep-Alive 是应用层的连接复用机制,而 TCP Keep-Alive 是传输层的连接探测机制,两者工作在不同的协议层。
TCP 层面的长连接
TCP 本身就是面向连接的协议,理论上一条 TCP 连接建立之后可以无限期保持。但实际网络环境远没有这么理想。
TCP 协议自带了一个 Keep-Alive 机制,可以通过系统参数来配置。在 Linux 上,有三个关键参数:
tcp_keepalive_time:连接空闲多久后开始发送探测包,默认 7200 秒(2 小时)tcp_keepalive_intvl:探测包的发送间隔,默认 75 秒tcp_keepalive_probes:探测失败多少次后认为连接已断开,默认 9 次
从这些默认值就能看出问题,2 小时才开始探测,这对大多数应用来说太迟了。更麻烦的是,真实网络中存在 NAT 网关和防火墙这些中间设备,它们会维护一张连接映射表,长时间没有数据传输的连接会被悄悄清理掉。运营商 NAT 的超时时间通常只有 5 分钟左右,防火墙可能更激进。
这就产生了一个经典的"半开连接"问题:一端以为连接还在,另一端早就不认识这条连接了。TCP 协议本身无法主动感知这种情况,因此在实际应用中,几乎都需要在应用层实现自己的心跳机制来保活。
心跳机制的设计
心跳(Heartbeat)是长连接系统中最核心的保活手段。它的原理很简单:定期在连接上发送一个小小的探测包,对方收到后回复一个确认包,以此来确认连接仍然有效。
心跳间隔的选择是一个需要权衡的问题。间隔太长,可能在连接已经断开很久后才发现;间隔太短,又会浪费带宽和电量(尤其在移动端)。常见的做法是设置 25 到 30 秒的心跳间隔,这个值的考量依据是:运营商 NAT 的超时通常在 5 分钟左右,30 秒的间隔留下了充足的安全余量,同时也不会造成太大的额外开销。
心跳超时的设置通常是心跳间隔的 2 到 2.5 倍,也就是 60 到 70 秒左右。这样可以容忍一到两个心跳包因为网络抖动而丢失,不至于误判为连接断开。
具体实现上,心跳可以由客户端发起也可以由服务器发起。客户端发送心跳的方案更常见,因为客户端的数量远多于服务器,由客户端分散地发送心跳可以避免服务器端的集中处理压力。在 [[WebSocket]] 协议中,有原生的 Ping/Pong 控制帧可以直接用来做心跳检测,非常方便。
服务端也要配合做超时检查。如果一个客户端连续多个心跳周期都没有响应,服务端应该主动关闭这条连接并释放资源。很多长连接系统出现的内存泄漏问题,根本原因就是服务端没有正确清理死掉的连接。
断线重连策略
有了心跳机制来发现连接断开,下一步就需要断线重连的策略。重连看起来简单,但如果处理不好,反而会带来更严重的问题。
最关键的一点是使用指数退避(Exponential Backoff)策略。第一次重连间隔 1 秒,第二次 2 秒,第三次 4 秒,依此类推,设一个上限(通常是 60 秒)。这样做的目的是防止"雷鸣群效应",当服务器重启或网络恢复时,如果所有客户端都在同一时间重连,瞬间涌入的连接请求可能直接把服务器打垮。
在指数退避的基础上,还应该加入随机抖动(Jitter),比如在每次计算出的延迟上加一个 0 到 1 秒的随机值。这样可以进一步打散重连的时间点,降低服务器的瞬时压力。
另一个实用的优化是监听页面可见性变化。在移动端浏览器中,当页面切到后台时,可以暂停心跳来节省电量;当页面重新回到前台时,立即检查连接状态并在需要时触发重连。这个小优化对移动端用户的电池续航有明显的帮助。
连接池的设计
长连接解决了连接复用的问题,但在高并发场景下,单条长连接仍然可能成为瓶颈。多个请求在同一条连接上排队等待,效率并不理想。连接池(Connection Pool)就是为了解决这个问题而设计的。
连接池的核心思路是预先创建并维护一组长连接,请求到来时从池中取出一条空闲连接使用,用完后归还到池中等待下一次使用。这样既享受了长连接的低开销,又具备了并行处理多个请求的能力。
连接池的几个关键参数需要根据实际场景来调优:
- 最小连接数(min_size):池中始终保持的连接数量,通常设为 3 到 5
- 最大连接数(max_size):池中允许的最大连接数量,数据库连接池常见的设置是 20
- 最大空闲时间(max_idle_time):空闲连接超过这个时间就会被关闭回收,通常设为 5 分钟
连接池还需要一个后台线程来定期检查连接的健康状态。不健康的连接要及时销毁并补充新的连接。很多生产环境中遇到的"连接超时"或"连接重置"问题,根本原因就是连接池中存在已经失效但尚未被清理的"僵尸连接"。
长连接技术选型对比
说完了长连接的通用机制,来看看实际开发中有哪些具体的技术选择。
短轮询
短轮询(Short Polling)是最简单也最粗暴的方案。客户端每隔一段时间(比如 1 秒)向服务器发一次请求,问一句"有没有新数据"。实现起来几乎没有难度,但缺点也很明显:大量的请求可能都是空的,浪费带宽和服务器资源,而且实时性取决于轮询间隔,间隔越短开销越大。除非更新频率极低的场景,否则不推荐使用。
长轮询
长轮询(Long Polling)是短轮询的改良版。客户端发送请求后,服务器不会立即响应,而是持有这个请求,直到有新数据产生或者超时(通常 30 秒)才返回。客户端收到响应后立即发起下一个长轮询请求。这种方式比短轮询高效很多,因为只有在真正有数据时才会传输。缺点是每次响应后都需要重新建立 HTTP 请求,带有完整的 HTTP 头部开销。在 [[WebSocket]] 出现之前,很多实时应用(包括早期的 Web 聊天室)都采用这种方案。
Server-Sent Events
[[Server-Sent Events]](SSE)是 HTML5 引入的一种服务器推送技术。它基于 HTTP 协议,建立一条从服务器到客户端的单向通道,服务器可以持续不断地向客户端推送数据。SSE 的一大优势是浏览器原生支持自动重连,开发者不需要自己写重连逻辑。另外,SSE 走的是标准 HTTP 协议,对代理服务器和防火墙更友好。
SSE 在近两年变得特别流行,因为 AI 领域的流式响应(比如 [[ChatGPT]]、[[Claude]] 的逐字输出效果)基本都是用 SSE 实现的。对于只需要服务器向客户端单向推送的场景,SSE 往往是比 [[WebSocket]] 更轻量也更合适的选择。
WebSocket
[[WebSocket]] 是真正的全双工通信协议。通过一次 HTTP Upgrade 握手后,客户端和服务器之间就建立了一条持久的双向通道,双方可以随时互相发送数据。WebSocket 的帧头只有 2 到 4 字节,远小于 HTTP 的数百字节头部,延迟也可以做到 5 毫秒以内。
WebSocket 适合需要频繁双向交互的场景,比如在线聊天、协作编辑、多人游戏。但它也有代价:需要自己实现心跳和重连逻辑,某些代理服务器和企业防火墙可能不支持 WebSocket 连接,部署和运维的复杂度也更高。
各技术横向对比
| 特性 | 短轮询 | 长轮询 | SSE | WebSocket |
|---|---|---|---|---|
| 通信方向 | 客户端→服务器 | 客户端→服务器 | 服务器→客户端 | 双向 |
| 延迟 | 高(依赖轮询间隔) | 中等 | 低 | 最低 |
| 自动重连 | 需手动实现 | 需手动实现 | 浏览器原生支持 | 需手动实现 |
| 帧开销 | 完整 HTTP 头 | 完整 HTTP 头 | 约 5 字节 | 2-4 字节 |
| 代理穿透 | 好 | 好 | 好 | 一般 |
| 适用场景 | 极低频更新 | 中等频率更新 | 单向推送 | 高频双向交互 |
一个简单的选型思路:如果只需要服务器向客户端推数据,优先选 SSE;如果需要双向实时通信,选 WebSocket;如果兼容性要求特别高(比如需要支持非常老的浏览器),长轮询是保底方案。
部署时的超时配置
长连接在实际部署中,最容易踩的坑就是超时配置。从客户端到服务器之间,可能经过 CDN、[[Nginx]] 反向代理、负载均衡器等多个中间层,每一层都有自己的超时设置,任何一个配置不当都会导致连接被意外断开。
以 [[Nginx]] 为例,代理 WebSocket 连接时,有几个参数必须注意:
proxy_read_timeout:这是最关键的参数。Nginx 默认值是 60 秒,意味着如果 60 秒内没有收到后端的数据,Nginx 就会关闭连接。对于长连接来说,这个值必须设置得足够大(比如 3600 秒),或者确保心跳间隔小于这个超时时间。proxy_buffering:对于 WebSocket 和 SSE,需要关闭代理缓冲(设为 off),否则数据会被 Nginx 缓存而不是实时转发给客户端。proxy_http_version:必须设为 1.1,因为 WebSocket 升级需要 HTTP/1.1 的 Upgrade 机制。
一个常见的错误是只调了 Nginx 的超时,忘了上游的负载均衡器也有自己的超时设置。AWS 的 ALB 默认 60 秒空闲超时,Google Cloud 的 Load Balancer 默认 30 分钟。部署长连接服务时,需要把整条链路上所有组件的超时都检查一遍。
常见的坑
在实际开发中,长连接相关的问题通常集中在几个地方。
心跳间隔设置不合理。见过有人把心跳间隔设为 1 秒,这等于是在做短轮询,完全失去了长连接的意义,还白白增加了服务器的处理负担。也见过心跳间隔设为 5 分钟的,结果 NAT 超时比心跳间隔短,连接早就被中间设备断开了,心跳包根本发不出去。
服务端缺少超时清理逻辑。客户端可能因为网络切换、进程崩溃等原因突然消失,如果服务端没有主动检测和清理这些"幽灵连接",随着时间推移,内存占用会持续增长,最终导致服务不可用。
忘记在页面卸载时关闭连接。浏览器中如果不在 beforeunload 事件中主动关闭 WebSocket 连接,就会留下孤儿连接。虽然服务端最终会通过超时机制清理它们,但在这之前它们会占用服务器资源。
连接池大小配置不当。连接池太小(比如 max=1),所有请求都在排队等待同一条连接,吞吐量上不去;连接池太大(比如 max=1000),大量空闲连接浪费内存,而且可能超出数据库的最大连接数限制。
最后
长连接并不是一个单一的技术,而是一套从协议层到应用层的完整方案。无论底层使用的是 TCP、HTTP 还是 WebSocket,长连接系统都离不开三个核心要素:连接复用、保活检测和故障恢复。连接复用通过连接池来实现,保活检测依靠心跳机制,故障恢复则需要指数退避重连策略。
在技术选型上,我的建议是:先想清楚自己的需求到底是单向推送还是双向通信。如果只是服务器向客户端推送数据(这涵盖了大部分实时更新的场景),SSE 几乎总是比 WebSocket 更好的选择,它更轻量、更易部署、浏览器原生支持自动重连。只有在真正需要高频双向交互的场景下,才值得引入 WebSocket 的额外复杂度。
理解了这些基础概念之后,再去看各种框架和库的实现(比如 [[Socket.IO]]、[[gRPC]] 的流式调用),就会发现它们本质上都是在这些基础机制上做的封装和优化。掌握了底层原理,选型和调优时就不会迷失在各种术语中了。

