在实时通信系统里,WebSocket连接的建立只是第一步,真正决定体验的是两件事:连接是否经过可靠鉴权,以及断开后能否自动恢复。鉴权如果只依赖登录阶段种下的Cookie,在跨域或移动端网络切换时很容易失效;重连如果采用固定间隔,遇到服务端短暂过载反而会加剧拥塞。本文从握手鉴权、心跳保活、退避重试和会话恢复四个环节展开,给出可直接落地的设计思路与代码示例。

握手阶段的鉴权设计
浏览器提供的 WebSocket API 无法像普通 HTTP 请求那样在 Header 中自定义 Authorization 字段,因此在建立连接前需要另想办法传递身份信息。常见做法有两种:一是把短期令牌放在 URL 查询参数中,例如 wss://ipipp.com/ws?ticket=xxx;二是利用 Sec-WebSocket-Protocol 子协议字段携带令牌。URL 参数实现简单,但可能被反向代理、访问日志或浏览器历史记录留存;子协议字段稍微隐蔽,不过客户端和服务端都要做额外解析。综合来看,短期一次性票据配合 HTTPS 加密是实际项目里性价比较高的方案。
令牌设计是鉴权环节的核心。不要直接复用用户登录后的长期 Token,因为 WebSocket 连接阶段如果泄露票据,攻击者可以在有效期内反复重放。推荐在客户端发起连接前,先通过普通 HTTP 接口申请一个几十秒到几分钟有效的独立票据。服务端生成票据时,将用户 ID、设备标识、随机数和过期时间一起签名,也可以直接写入 Redis 等缓存中。校验时只允许使用一次,验证成功立即删除或标记为已消费。这样即便 URL 被日志系统记录,票据也无法被再次使用。
服务端的校验时机必须放在 upgrade 事件中,而不是等 connection 建立后再做。因为一旦升级完成,连接已经进入业务层,此时再拒绝授权就会产生半初始化状态。校验失败时应直接返回 401 并销毁底层 socket,避免未授权连接占用资源。校验通过后,把 userId 或会话对象挂载到 WebSocket 实例上,方便后续消息路由和权限判断。下面是一个 Node.js 服务端的处理示例。
import { WebSocketServer } from 'ws';
import { verifyTicket } from './auth.js';
const wss = new WebSocketServer({ noServer: true });
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('ok');
});
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url, 'http://localhost');
const ticket = url.searchParams.get('ticket');
const session = await verifyTicket(ticket);
if (!session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.userId = session.userId;
wss.emit('connection', ws, req);
});
});
心跳保活与断线检测
WebSocket 协议本身支持 Ping/Pong 控制帧,但浏览器 JavaScript API 不能主动发送控制帧,只能由服务端发 Ping,浏览器自动回复 Pong。实际开发中更常用的是应用层心跳:客户端每隔一段时间发送一个精简的 JSON 消息,服务端收到后更新时间戳,并可选回复心跳确认。这样做的好处是不依赖底层协议实现,所有 WebSocket 库都能兼容。
心跳间隔的设置需要权衡。间隔太短会浪费带宽和 CPU,太长又无法及时发现连接假死。通常 30 秒到 60 秒比较合理。移动端页面切到后台后,浏览器定时器可能被系统冻结或延迟,此时如果继续按原间隔判断很容易误断。可以在 visibilitychange 事件中重置心跳计时器,并在页面重新可见时立即补发一次心跳。服务端一般设置一个空闲超时时间,例如连续 90 秒未收到任何消息就主动关闭连接,让客户端尽早进入重连流程。
客户端需要维护两个定时器:一个负责发送心跳,一个负责检测服务端是否仍然活跃。如果超过设定时间没有收到任何消息,包括心跳响应,就调用 close 并触发重连。心跳消息体积要尽量小,例如使用 { "type": "ping" } 这样的结构,避免把大对象或高频状态塞进去。下面代码展示了基础的心跳发送与超时检测逻辑。
let lastPongTime = Date.now();
let pingTimer;
let timeoutTimer;
function startHeartbeat(ws) {
pingTimer = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'ping' }));
}, 30000);
timeoutTimer = setInterval(() => {
if (Date.now() - lastPongTime > 60000) {
ws.close();
}
}, 10000);
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'pong') {
lastPongTime = Date.now();
}
};
断线重连状态机与退避策略
断线后不能立即重新建立连接。如果网络尚未恢复,频繁尝试只会消耗客户端资源,也可能在服务端恢复瞬间制造请求洪峰。常见的退避策略是从 1 秒开始,每次失败将间隔翻倍,并加入随机抖动,避免大量客户端同时重连。间隔序列可以是 1 秒、2 秒、4 秒、8 秒、16 秒,上限设为 30 秒。随机抖动可取计算结果的 0.5 到 1.5 倍,这样同一批断开连接的用户会分散在同一时间段内。
建议在客户端封装一个连接管理器,维护 idle、connecting、open、reconnecting、closed 五个状态。浏览器提供的 online 和 offline 事件可以作为辅助信号,当网络恢复时立即触发一次重连尝试,不必等待退避定时器自然结束。这对移动端网络切换、Wi-Fi 断开又恢复的场景尤其有效。
无限重连会耗电,也可能违反服务端策略。应设置最大重试次数或最长自动重连时间,达到上限后停止重试,提示用户手动刷新或重新登录。对于后台页面,可以结合 document.visibilityState 判断用户是否正在查看,若不可见则暂停重连,待页面重新可见时再恢复调度。以下是一个带指数退避与随机抖动的重连调度器示例。
class ReconnectScheduler {
constructor(connect, options = {}) {
this.connect = connect;
this.attempt = 0;
this.maxAttempts = options.maxAttempts ?? 10;
this.baseDelay = options.baseDelay ?? 1000;
this.maxDelay = options.maxDelay ?? 30000;
this.timer = null;
}
schedule() {
if (this.attempt >= this.maxAttempts) return;
const delay = Math.min(
this.baseDelay * Math.pow(2, this.attempt),
this.maxDelay
);
const jitter = delay * (0.5 + Math.random());
this.attempt += 1;
this.timer = setTimeout(() => {
this.connect();
}, jitter);
}
reset() {
this.attempt = 0;
clearTimeout(this.timer);
}
}
重连后的会话恢复与幂等处理
连接重建后,如果只是重新建立了传输通道,但业务状态没有同步,用户仍会发现订阅的频道丢失、离线期间的消息看不到。因此重连成功后不能只触发 open 回调,还要执行恢复流程:重新发送订阅指令、重新拉取离线消息、重新同步本地缓存。恢复逻辑应独立于初次连接逻辑,但可以被初次连接复用。
避免重复消费是重连过程中容易忽略的问题。断线前服务端可能已经处理了消息,但客户端没收到确认,重连后如果简单重发就会造成重复操作。可以为每条客户端消息生成唯一 ID,服务端按 ID 做幂等去重。或者在重连握手成功后,服务端返回一个 lastEventId,客户端据此增量拉取后续事件。对于聊天、订单通知等场景,记录每台设备已确认的消息 ID 是最稳妥的方式。
多标签页场景也需要纳入设计。同一浏览器多个标签页如果各自建立 WebSocket,会浪费连接资源,而且断线重连时多个标签页会同时重试,进一步放大退避策略的副作用。可以利用 SharedWorker 或 BroadcastChannel 共享一个连接,或者指定活动标签页管理连接,其他标签页通过 postMessage 通信。这样既减少服务端压力,也让重连行为更可控。下面是重连后恢复订阅的简化示例。
const subscribedChannels = new Set(['room:1001', 'room:1002']);
ws.onopen = () => {
for (const channel of subscribedChannels) {
ws.send(JSON.stringify({
action: 'subscribe',
channel,
requestId: crypto.randomUUID()
}));
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'subscribed') {
console.log(`已恢复订阅: ${msg.channel}`);
}
};
整体来看,WebSocket 连接管理并不是把鉴权、心跳和重连割裂开来,而是要让它们围绕会话生命周期协同工作。鉴权保证连接入口安全,心跳负责及时暴露异常,退避策略控制恢复节奏,恢复流程确保业务状态最终一致。把这些机制封装成独立的连接管理器后,上层业务只需要关心收到什么消息、发送什么指令,稳定性由底层统一保障。
WebSocket鉴权断线重连心跳检测修改时间:2026-10-01 02:48:47