WebSocket 协议深入:握手、心跳、重连与断线消息补偿
本文目录
多数前端对 WebSocket 的理解停留在 new WebSocket(url) 加四个事件回调。这在演示里够用,在生产环境里远远不够:连接为什么会"莫名其妙"断开、断开之后消息丢了多少、怎么补回来、几万个客户端同时断线重连会发生什么,这些问题都发生在协议层和网关层,浏览器 API 不会替你回答。这篇文章把 WebSocket 当作一个协议加一整套工程问题来讲:握手、帧、心跳、重连、消息补偿,以及中间设备(代理、网关、负载均衡)对连接的影响。
先看清协议的分层
WebSocket 不是"一个长连接 API",而是三层东西叠在一起:
| 层 | 职责 | 关键机制 | 前端可控性 |
|---|---|---|---|
| 传输层 | TCP 连接的建立与保活 | TCP keepalive、NAT 表项 | 几乎不可控 |
| 协议层 | RFC 6455 定义的帧协议 | 握手 Upgrade、帧、ping/pong、close | 部分(浏览器代管帧) |
| 应用层 | 业务消息的可靠性 | 序号、ack、重连补偿、心跳语义 | 完全可控 |
生产事故大多发生在第三层:协议层的连接断了可以重建,但"用户在断线期间少收了三条消息"这种问题,必须在应用层自己设计。后文的所有方案,本质上都是在把第三层补齐。
握手:一次 Upgrade 的完整生命周期
WebSocket 复用 HTTP 完成协商。客户端发一个特殊的 HTTP 请求:
GET /realtime HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
Sec-WebSocket-Protocol: chat.v1
服务端若同意升级,返回 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
两个细节值得知道:
Sec-WebSocket-Accept是服务端用Sec-WebSocket-Key拼上固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11后做 SHA-1 再 base64 的结果。它不是为了加密,而是防止普通 HTTP 服务器"碰巧"返回 101,也防止缓存中间件误缓存这条响应。- 握手是 HTTP 的,所以认证、限流、CORS 类校验都在这一步做。浏览器会自动带上 Cookie 和
Origin头;服务端必须校验Origin是否在白名单内,否则任何页面都能发起连接(跨站 WebSocket 劫持)。校验通过后这条 TCP 连接就不再讲 HTTP 了。
前端在浏览器里控制不了这些头,但要知道:握手失败时 onerror 与 onclose 会先后触发,close 事件里没有 HTTP 状态码,只有 WebSocket 的 close code(如 1006)。想拿到真实原因(401、403、404),要么让服务端握手失败时返回具体状态码并配合应用层接口查询,要么在建立连接前先用一次普通 HTTP 请求做预检。
帧类型与分片:协议层的最小知识
握手之后,TCP 上跑的都是二进制帧。每帧有个 opcode:
| opcode | 帧类型 | 说明 |
|---|---|---|
| 0x0 | continuation | 分片消息的后续帧 |
| 0x1 | text | UTF-8 文本,一条消息可能被拆成多帧 |
| 0x2 | binary | 二进制数据,同样可分片 |
| 0x8 | close | 关闭帧,可带状态码与原因,双方应各自回一个 close |
| 0x9 | ping | 心跳探测帧,payload 最多 125 字节 |
| 0xA | pong | 对 ping 的回应,必须回显 payload |
了解这些对前端有两个实际影响:
onmessage收到的永远是"一条完整消息"。分片与重组由浏览器完成,你不需要处理 continuation 帧;但单条消息体积要有上限(见后文边界)。- 客户端发出去的帧是带掩码的(RFC 6455 要求,防代理缓存污染攻击),服务端发的帧不带。前端无感知,但排查抓包时会看到客户端帧前几字节是随机掩码,这是正常现象不是加密。
关闭状态码也要认识几个:1000 正常关闭,1001 端点离开(页面刷新/关闭),1002 协议错误,1003 收到不能处理的数据类型,1011 服务端内部错误。1006 是"异常关闭"的保留值,不会出现在线上帧里——它只出现在浏览器给你的 CloseEvent.code 中,含义是"TCP 连接没经过 close 握手就断了",网络切换、网关超时、进程被杀都会导致 1006。
心跳保活:两层心跳的分工
连接会"悄悄死掉"有两个原因:NAT/防火墙的映射表项超时(典型 30 秒到几分钟,无流量即回收),以及中间网关的空闲连接清理。TCP keepalive 默认间隔在小时级,指望不上,所以需要应用层面的主动保活。
协议层 ping/pong
ping/pong 帧是标准机制,但浏览器 JS API 不能主动发 ping,也收不到 ping/pong 事件;浏览器只会自动回 pong。所以协议层心跳的发起方只能是服务端:服务端每 N 秒发 ping,客户端浏览器自动回 pong,服务端若连续若干个周期收不到 pong 就断开并回收资源。这条链路对前端透明。
应用层心跳:前端能做的部分
前端自己能做的是应用层心跳——用普通消息模拟:
class Heartbeat {
private timer = 0;
private lastPongAt = Date.now();
constructor(
private readonly send: (msg: string) => void,
private readonly onDead: () => void,
private readonly intervalMs = 15_000,
private readonly deadAfter = 3, // 连续 3 个周期没收到 pong 判死
) {}
start(): void {
this.lastPongAt = Date.now();
this.timer = window.setInterval(() => {
this.send(JSON.stringify({ type: 'ping', t: Date.now() }));
if (Date.now() - this.lastPongAt > this.intervalMs * this.deadAfter) {
this.stop();
this.onDead(); // 主动判定连接死亡,触发重连流程
}
}, this.intervalMs);
}
feed(msg: { type: string; t?: number }): void {
if (msg.type === 'pong') this.lastPongAt = Date.now();
}
stop(): void {
window.clearInterval(this.timer);
this.timer = 0;
}
}
要点:
- 心跳间隔要小于整条链路里最短的空闲超时(见下文 nginx 一节),常见取 15–30 秒。
- 判死阈值用"连续 N 个周期无响应"而不是单次超时,避免一次网络抖动误杀。
- 心跳消息绝不该走业务消息队列:如果心跳被排在 2000 条未处理消息后面,它测的是"客户端忙不忙"而不是"链路通不通"。
- pong 必须由服务端在进业务逻辑之前尽早回复,否则测的是服务端负载而不是连通性。
断线重连:指数退避、抖动与重连风暴
onclose 之后无脑 new WebSocket 重连,是最常见的生产事故来源。两个问题:服务端还没起来时客户端会以毫秒级频率疯狂重连,把自己和服务端一起打死;服务端重启恢复的瞬间,几万个客户端在同一秒集体重连(惊群),刚恢复的服务端立刻被打垮。
标准解法是指数退避 + 随机抖动 + 上限封顶:
function backoff(attempt: number, baseMs = 500, maxMs = 30_000): number {
const exp = Math.min(baseMs * 2 ** attempt, maxMs);
const jitter = exp * (0.5 + Math.random() * 0.5); // 抖动到 [50%, 100%)
return Math.round(Math.min(jitter, maxMs));
}
// attempt=0 → 250~500ms;1 → 500ms~1s;2 → 1~2s;…封顶 30s
抖动的意义看图中"下界与上界之间的区间":不同客户端的重连时刻被拉开,服务端重启后收到的是一条拉长的重连尾巴而不是一堵墙。
工程上还要补几件事:
| 机制 | 做法 | 目的 |
|---|---|---|
| 退避封顶 | 延迟最大 30–60s,不再翻倍 | 半夜挂掉的页面不至于数小时后仍按天级延迟重连 |
| 上限后降频 | 连续失败超过 N 次后,改为每 30–60s 一次的"巡检"重连 | 保护服务端,也让恢复后重连更快发生 |
| 页面可见性联动 | visibilitychange 隐藏时暂停心跳/降频,可见时立即重连 | 移动端后台页面被冻结,心跳 timer 根本不准 |
| 单飞重连 | 重连逻辑加锁,onclose 与判死回调竞争时只触发一次 | 避免"重连中的重连"叠加出多条连接 |
| 人工入口 | 网络恢复事件、online 事件、用户点击"重试"都能立即触发一次重连(绕过退避一次) | 弱网用户不必干等退避倒计时 |
多标签页场景更要注意:同一用户开了 5 个标签页就是 5 倍的重连压力,必要时让多个标签页共享一条连接(跨标签页协作的选型思路可参考浏览器跨标签页通信实战)。
断线消息补偿:序号、游标与断点续传
TCP 只保证"连着的时候不丢不乱",不保证"断开期间的消息还在"。用户切了 30 秒飞行模式再回来,这 30 秒里的消息需要补偿机制才补得回来。补偿必须由应用层设计,协议层没有断点续传。
设计基础是给每条消息一个单调递增的序号(服务端按连接或按会话分配):
| 方案 | 机制 | 适用 | 代价 |
|---|---|---|---|
| 序号 + 服务端回放窗口 | 客户端 ack 已收序号,服务端保留最近 N 条,重连后按序号补发 | 聊天、协作编辑、通知 | 服务端要维护 per-连接缓冲区 |
| 游标 + 事件日志 | 消息写入可重放的事件日志(按会话或全局),客户端带游标拉取 | 高扇出广播、行情推送 | 依赖存储系统,游标要持久化 |
| 重连全量刷新 | 重连后直接重新拉当前全量状态 | 状态小、消息只是触发刷新的信号 | 实现最简单,流量放大 |
前两种的握手协议大致是:
// 重连成功后客户端第一条消息:报告自己最后收到的位置
{ "type": "resume", "lastSeq": 4182, "sessionId": "u-10086-s-7" }
// 服务端两种回答
{ "type": "resume_ok", "replay": [ /* 4183..4200 */ ] }
{ "type": "resume_missed", "oldest": 4310 } // 回放窗口已滚过,只能全量刷新
客户端配套逻辑:
socket.onmessage = (ev) => {
const msg = JSON.parse(ev.data) as ServerMsg;
if (msg.seq !== undefined) {
if (msg.seq <= lastSeq) return; // 去重:重连窗口与回放可能交叠
if (msg.seq > lastSeq + 1) requestResync(); // 发现空洞:序号跳了,主动请求补齐
lastSeq = msg.seq;
}
handle(msg);
};
几个容易漏的点:
- ack 不能只存在内存里。页面刷新后
lastSeq归零,服务端会以为你要全量回放;把游标存在sessionStorage并在重连时上报,配合服务端会话保持(sessionId),才跨得过页面刷新。 - 回放与实时的边界。重连瞬间通常"先回放旧消息,再接实时流",两段之间服务端要做原子切换,否则会漏或重;客户端去重是最后一道防线。
- 不是所有消息都值得补偿。进度条动画、光标位置这类瞬时状态,重连后直接拉最新即可;只有"事件"(下单、审批、消息记录)才需要序号与回放。
- 窗口要有大小与时长上限。无限保留等于内存泄漏;窗口大小按业务能容忍的最大断线时长估算(30 分钟断线 × 平均 10 条/分钟 = 300 条保底)。
代理与网关:超时参数是断连的头号元凶
生产环境的 WebSocket 几乎必然经过 nginx 或同类网关。配置不当的经典症状是:连接每 60 秒整整齐齐断一次——这是 proxy_read_timeout 默认值在起作用:网关在 60 秒内没从上游读到任何数据就掐断连接,而且断得悄无声息(客户端只看到 1006)。
location /realtime {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s; # 上游无数据最长等待,必须 > 心跳间隔 × 判死阈值
proxy_send_timeout 300s; # 向上游写数据的超时
proxy_socket_keepalive on;
}
一个必须自查的不等式:心跳间隔 × 判死周期数 < proxy_read_timeout < 负载均衡空闲超时(如有)。例如心跳 15s、判死要 3 个周期,那么 read timeout 至少要 45s 以上,留出余量取 60–300s。反向错配(心跳 30s 但网关 20s 超时)会让连接永远活不过一个周期。
其他中间设备问题:
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 固定周期断线(60s/300s 整) | 网关空闲超时 | 核对 read/send timeout 与心跳间隔 |
| 切换 Wi-Fi/移动网络必断 | TCP 绑定旧网络接口 | 正常现象,依赖重连补偿机制 |
| 握手返回 200 而非 101 | 代理没透传 Upgrade 头 | 检查 proxy_set_header Upgrade/Connection |
| 某些企业网络握手被拒 | 中间防火墙只认白名单协议 | 提供降级通道(HTTP 轮询或 SSE) |
| 长连接被 LB 轮询摘掉 | 连接没注册到 LB 健康检查 | 让服务端对 LB 暴露 HTTP 健康端点 |
排查的基本功是分清断连发生在哪一段:浏览器控制台看 close code,服务端日志看是否收到 close 帧,网关日志看超时记录,tcpdump/ngrep 看最后一帧是谁发的。
多路复用与连接数预算
一个页面开了 8 个 WebSocket、每个组件各连一条,这种代码在压测时会先死。预算意识要建立三点:
- 单页连接数:浏览器对同源并发 WebSocket 有上限(历史实现约 255,但移动端浏览器与 WebView 更紧张),且每个连接都是一条 TCP + 服务端一个挂起对象。规则:一个页面一个业务域一条连接,消息里带 channel/routing 字段做应用层复用。
- 服务端连接数:每条连接占一个文件描述符与内存(几十 KB 起步的收发缓冲),10 万连接的网关规划要按"连接内存 + 帧处理吞吐"两本账算。
- 复用的代价:一条连接上慢消费者会拖累同连接的其他 channel(队头阻塞在应用层重现),高优先级消息要有应用层的优先队列或独立连接,这是"复用省连接"与"隔离保时效"之间的权衡。
// 应用层多路复用的最小形态:一个连接,多个订阅
function subscribe(ws: WebSocket, channel: string, cb: (data: unknown) => void): () => void {
const subId = crypto.randomUUID();
ws.send(JSON.stringify({ type: 'sub', channel, subId }));
handlers.set(subId, cb); // message 事件统一分发
return () => {
handlers.delete(subId);
ws.send(JSON.stringify({ type: 'unsub', subId }));
};
}
注意事项与边界
- 单条消息体积要有应用层上限(建议 1MB 以内):浏览器会强制拼接整个分片消息到内存,超大的 text 帧能直接把低配设备打 OOM;大文件走 HTTP 上传,WebSocket 只传信令。
onerror里基本拿不到错误信息,WebSocket 的错误对象刻意不含细节(防探测),别指望err.message排查。readyState只是快照:CONNECTING阶段调用send()会直接抛异常,所有发送前都要判态或入队。- 页面卸载时主动
close(1000)是好习惯,但移动端不可靠,服务端对"连接自然死亡"要有超时回收逻辑,不能依赖 close 帧。 - 重连后旧连接的未 ack 消息、新连接的实时消息在时间上可能乱序,业务层对"乱序容忍度"要有明确结论(协作编辑必须处理,行情展示通常只需最终一致)。
- 不要用 WebSocket 的连接状态当登录态。连接断 ≠ 会话失效,认证过期后服务端应回 close 带业务码,前端区分"网络断了要重连"和"鉴权失效要去登录"。
总结
WebSocket 生产化的完整清单:握手层把认证与 Origin 校验做在 101 之前;保活层用"服务端协议 ping + 客户端应用层心跳"双保险,并让心跳参数严格小于网关超时;重连层用指数退避加抖动加封顶,避免惊群;补偿层用序号/游标加回放窗口,把断线期间的损失变成可计算的有限缺口。协议层(RFC 6455)只给你一条会断的管道,可靠性从来都是应用层一行行代码挣出来的。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。