8.11 长连接与 WebSocket

一句话

长连接不是 “打开就不用管”,而是三件事——谁维持、怎么探活、断了怎么重连;WebSocket 只是在 HTTP 上做一次协议升级(101),升级之后就是双向字节流,缓存、重试、幂等这些 HTTP 语义全部不再适用。

四种 “推送” 形态

形态 方向 延迟 连接成本 适合
短轮询 客户端拉 秒级 每次一个新请求 兼容性要求极高、量很小
长轮询 客户端拉(挂起) 秒级 每次占用一个连接 过渡方案
SSE(Server-Sent Events) 服务端推(单向) 毫秒级 一条 HTTP 长连接(复用语义见 8.5 HTTP/1.1、HTTP/2、HTTP/3 (QUIC)) 通知、进度、行情(只下行)
WebSocket 双向 毫秒级 一条 TCP 连接 聊天、协同编辑、游戏

选择依据是 “要不要双向”:只要下行,SSE 更简单(复用 HTTP 的鉴权、压缩、代理支持);要上行(打字、光标),才用 WebSocket。

握手:一次 HTTP,之后换协议

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

两个工程后果:

  • 路径上每一跳都要支持 Upgrade:Nginx 需要显式转发 Upgrade/Connection 头(配置方式见 8.10 反向代理与转发策略),否则握手失败(返回 400/426);四层 LB 天然支持,七层 LB 要配置。
  • 鉴权只能发生在握手时:Sec-WebSocket-Protocol、Cookie、URL 上的短时 token 都可以,但连接建立后没有 “再鉴权” 的机会,所以 token 有效期要覆盖连接生命周期,或做 “服务端主动断开重鉴权”。

容量:连接数是要花钱的资源

资源 量级 说明
单机文件描述符 ulimit -n 默认 1024 长连接服务必须调到 10 万级
单连接内存 约 10–100KB(含读写缓冲与 TLS 状态) 10 万连接 ≈ 数 GB,要按内存选机器
内核参数 somaxconn、tcp_max_syn_backlog、net.ipv4.ip_local_port_range 建连风暴时最先撞到;TIME_WAIT 占用见 8.3 连接复用与 TIME_WAIT
中间设备 idle 超时 NAT 常见 300 秒、LB 60 秒 没有心跳就会被静默断开
ulimit -n                                  # 单进程 fd 上限
ss -s | head -3                            # 全局连接统计
ss -tan state established | wc -l          # 当前已建立连接数
ss -tan state established '( sport = :8080 )' | wc -l   # 只看某个服务

心跳与超时:三层,缺一不可

层 机制 建议值 作用
TCP SO_KEEPALIVE 默认 2 小时,太长 只探测对端宕机,探测不到 “中间设备断链”
协议 WebSocket ping/pong 20–30 秒 保活 + 探测半开连接
应用 业务心跳(带 seq) 30–60 秒 及时发现 “连接在但消息不通”

只靠 TCP keepalive 是最常见的错误:NAT/LB 的空闲超时(60–300 秒)远短于 2 小时,用户会遇到 “看起来连着、其实早断了”。

重连:一定要有退避 + 抖动

delay = min(cap, base × 2^attempt) × random(0.5, 1.5)
反模式 后果
固定 1 秒重连 服务端重启后,10 万客户端每秒重连一次,直接把刚恢复的服务再打挂
无抖动 所有客户端同相位重连,形成周期性尖峰
重连即重放全量 断线 30 秒恢复要拉几年消息,带宽与 CPU 双爆

正确的重连还要带 “续传点”:客户端记住最后收到的消息序号(或时间戳),重连后只拉增量。服务端要能按序号补发。

消息可靠性:连接可靠 ≠ 消息不丢

环节 风险 对策
发送 写入 socket 成功 ≠ 对端收到 应用层 ack + 重发(幂等)
顺序 重连后新旧消息交错 服务端带单调序号,客户端按序号丢弃重复/乱序
离线 断线期间的消息 服务端保存离线消息(有上限),上线后按 seq 补发
多端 同一用户多设备在线 每设备一条连接,广播到所有端;冲突用 “最后写入胜” 或业务规则

连接的可靠性只保证 “字节按序到达”,业务消息的可靠性必须自己在应用层做(ack、重发、去重),这与消息队列的投递语义是同一套问题(见 43.6 可靠投递与幂等消费)。

反模式

反模式 后果
无应用层心跳,只靠 TCP keepalive 经过 NAT/LB 后连接被静默断开,客户端以为在线
重连无退避 服务重启时被重连风暴二次打挂
把会话状态放进程内存 Pod 重启/缩容即丢,用户被踢下线
在 WebSocket 上做大文件传输 单连接阻塞后续消息,且无法断点续传
七层代理没转发 Upgrade 头 握手失败(400/426),表现为 “本地能连、线上不能”
只在握手时鉴权、token 无限期 一旦 token 泄露,攻击者可长期占用连接
用轮询模拟推送 延迟高、QPS 成本高,且移动端耗电

三条铁律

🔑 心跳要打在应用层;TCP keepalive 探测不到中间设备的空闲超时。

🔑 重连必须退避 + 抖动 + 续传点;否则 “服务恢复即被重连风暴打挂”。

🔑 连接可靠 ≠ 消息可靠;ack、序号、去重三件套要在应用层做。

自检

Q1 用户反馈 “页面显示在线,但收不到消息”,服务端日志没有任何报错。最可能的原因?

▸ 答案:连接已被中间设备静默断开(NAT 空闲超时 300 秒、LB 60 秒),而两端都没做应用层心跳,所以都不知道。表现为 “TCP 状态还在、数据发不出去”。修法是加 ping/pong(20–30 秒)+ 应用层心跳,并在连续几个心跳未响应时主动重连。

Q2 服务重启后立刻被打挂,为什么?

▸ 答案:重连风暴。10 万客户端如果都按固定 1 秒重连,服务恢复的第一秒就要处理 10 万次握手(每次还带 TLS 与鉴权),CPU 与连接表瞬间打满。修法是客户端指数退避 + 随机抖动(0.5–1.5 倍),服务端再加连接限速与排队。

Q3 为什么 WebSocket 场景不能沿用 “HTTP 的重试与幂等”?

▸ 答案:因为 WebSocket 升级后是双向帧流,没有请求-响应的配对、没有状态码、没有 Retry-After。消息是否送达、是否重复,都要在应用层用 “序号 + ack + 去重” 自己实现。把 HTTP 客户端那套重试逻辑硬套过来,会得到重复消息或无法判断的 “半发送” 状态。

Q4 单机 10 万长连接,最可能先撞到哪个限制?

▸ 答案:顺序通常是 fd 上限(ulimit -n 默认 1024/65535)→ 内存(每连接 10–100KB,含缓冲区与 TLS 状态)→ 内核参数(somaxconn、端口范围)→ 中间设备的连接数上限。所以容量规划要按 “连接数 × 单连接内存” 算,而不是按 QPS 算。

Q5 同一用户手机与网页同时在线,消息怎么处理?

▸ 答案:按设备建连接,按用户做广播:服务端维护 userId → [connId…],消息投递到该用户所有在线连接;同时用 “单调序号 + 每设备已读位置” 处理多端同步。冲突场景(两端同时发消息)用业务规则(最后写入胜/服务端定序),不要指望连接层解决。

关联