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…],消息投递到该用户所有在线连接;同时用 “单调序号 + 每设备已读位置” 处理多端同步。冲突场景(两端同时发消息)用业务规则(最后写入胜/服务端定序),不要指望连接层解决。
关联
- 前置:8.1 TCP/IP 与三次握手、8.3 连接复用与 TIME_WAIT
- 并列:8.5 HTTP/1.1、HTTP/2、HTTP/3 (QUIC)、8.10 反向代理与转发策略
- 下游:43.6 可靠投递与幂等消费
- 待补:66.1 长连接网关、66.3 多端同步