Fadey's Blog

arrow_back 返回首页
WebSocket封面

WebSocket 实战:握手、心跳与断线重连

做聊天、协作编辑、实时推送时,第一个要回答的问题就是「用什么通道」。很多教程讲完 API 就结束了,但真正上线后翻车的点几乎全在心跳、重连、鉴权和代理配置上。这篇文章从握手讲到生产加固,目标是让你一次做对。

为什么是 WebSocket:四种通道对比

方案方向实时性开销适用场景
短轮询客户端拉差(间隔决定)高(大量空请求)兼容性兜底、低频状态
长轮询客户端拉较好较高(请求生命周期长)旧浏览器、消息频率低
SSE服务端推(单向)好低通知流、日志流、AI 流式输出
WebSocket双向好低(一次握手长连接)聊天、协作、游戏、行情

选型口径:只需要服务端往下推就用 SSE(自动重连、走普通 HTTP 基础设施);需要双向低延迟交互才上 WebSocket。

握手:从 HTTP 升级到 101

WebSocket 的起点是一个普通的 HTTP 请求,带上特殊的 Upgrade 头。服务器同意升级时返回 101 Switching Protocols,之后这条 TCP 连接就不再说 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=

所以 WebSocket 的连接地址是 ws://(加密版 wss://),但底层握手复用了 80/443 端口,能穿过大多数只有 HTTP 出入口的网络。

浏览器 API:四行核心代码

const ws = new WebSocket('wss://example.com/ws?token=' + encodeURIComponent(token));

ws.addEventListener('open', () => {
    console.log('已连接');
    ws.send(JSON.stringify({ type: 'hello', room: 'lobby' }));
});

ws.addEventListener('message', (event) => {
    const msg = JSON.parse(event.data);   // 约定 JSON 协议,别裸传字符串
    switch (msg.type) {
        case 'chat': renderChat(msg.payload); break;
        case 'pong': lastPongAt = Date.now(); break;  // 心跳回执
        default: console.warn('未知消息类型', msg.type);
    }
});

ws.addEventListener('close', (event) => {
    console.log('连接关闭', event.code, event.reason);
    // 注意:这里不直接重连,交给重连管理器统一处理(见下文)
});

// ws.send() 只能在 open 之后调用;关闭后调用 send 会抛错

一个高频错误:在 open 之前调用 send。构造 WebSocket 是异步的,正确的做法是把「待发送队列」缓存起来,open 后统一 flush。

服务端:Node.js + ws 最小可用实现

// npm i ws
const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 3000, path: '/ws' });

wss.on('connection', (ws, req) => {
    // 每连接一个状态对象:用户、房间、心跳时间
    ws.meta = { userId: null, aliveAt: Date.now() };

    ws.on('message', (raw) => {
        let msg;
        try {
            msg = JSON.parse(raw);
        } catch {
            return ws.close(1008, 'invalid json');   // 协议错误直接断开
        }

        switch (msg.type) {
            case 'auth':
                ws.meta.userId = verifyToken(msg.token);  // 见下文鉴权
                break;
            case 'ping':
                ws.send(JSON.stringify({ type: 'pong' }));
                break;
            case 'chat':
                if (!ws.meta.userId) return ws.close(1008, 'unauthorized');
                broadcast(msg.room, { type: 'chat', payload: msg.payload });
                break;
        }
    });

    ws.on('close', () => cleanup(ws));
});

function broadcast(room, data) {
    const text = JSON.stringify(data);
    for (const client of wss.clients) {
        if (client.readyState === client.OPEN) client.send(text);
    }
}

生产环境还要补三块:房间关系表(谁在哪个房间)、消息限流(防刷屏)、背压处理(ws.bufferedAmount 过大说明客户端消费不过来,该丢推送或限速)。

心跳保活:让死连接暴露出来

TCP 连接可能因为网络切换、NAT 超时、代理静默丢弃而「假活」——双方都不知道对方已经没了。所以要有应用层心跳:

// 服务端:30 秒一轮,没回 pong 的直接终止
const HEARTBEAT_MS = 30000;

setInterval(() => {
    for (const ws of wss.clients) {
        if (Date.now() - ws.meta.aliveAt > HEARTBEAT_MS * 2) {
            ws.terminate();            // 强制断开,比 close 更彻底
            continue;
        }
        if (ws.readyState === ws.OPEN) {
            ws.ping();                 // 协议层 ping(浏览器不可见,自动回 pong)
        }
    }
}, HEARTBEAT_MS);

// 浏览器端看不到协议 ping,需要应用层心跳配合(或仅用服务端 ping 保活 + 客户端超时检测)
// 浏览器端:应用层心跳 + 超时判定(简单可靠)
function startHeartbeat(ws) {
    const HEARTBEAT_MS = 25000;
    let lastPongAt = Date.now();

    const timer = setInterval(() => {
        if (Date.now() - lastPongAt > HEARTBEAT_MS * 2.5) {
            ws.close(4000, 'heartbeat timeout');   // 触发重连流程
            return;
        }
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({ type: 'ping' }));
        }
    }, HEARTBEAT_MS);

    return {
        onPong: () => { lastPongAt = Date.now(); },
        stop: () => clearInterval(timer),
    };
}

参数经验:心跳间隔 20–30 秒;服务端判定超时 = 间隔 × 2;Nginx 的 proxy_read_timeout 必须大于心跳间隔,否则连接会被代理先静默掐掉(配置见下文)。

断线重连:指数退避 + 抖动

断线是常态(服务发布、网络切换、手机锁屏),重连逻辑必须写成「无限重试但不过载」:

function createReconnectingSocket(url) {
    let ws = null;
    let attempt = 0;
    let closedByUser = false;
    const listeners = { message: [] };

    function connect() {
        ws = new WebSocket(url);

        ws.addEventListener('open', () => {
            attempt = 0;                       // 成功后重置退避
            heartbeat = startHeartbeat(ws);
            flushQueue();                      // 补发断线期间排队的消息
        });

        ws.addEventListener('message', (e) => {
            const msg = JSON.parse(e.data);
            if (msg.type === 'pong') return heartbeat.onPong();
            listeners.message.forEach((fn) => fn(msg));
        });

        ws.addEventListener('close', () => {
            heartbeat && heartbeat.stop();
            if (closedByUser) return;
            scheduleReconnect();
        });
    }

    function scheduleReconnect() {
        attempt += 1;
        // 1s, 2s, 4s, 8s... 封顶 30s;加 0~1s 抖动避免惊群
        const base = Math.min(1000 * 2 ** (attempt - 1), 30000);
        const delay = base + Math.random() * 1000;
        console.log(`第 ${attempt} 次重连将在 ${Math.round(delay)}ms 后`);
        setTimeout(connect, delay);
    }

    let queue = [];
    function send(obj) {
        const text = JSON.stringify(obj);
        if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
        else queue.push(text);                 // 断线期间进队列,重连后补发
    }
    function flushQueue() {
        const pending = queue;
        queue = [];
        pending.forEach((text) => ws.send(text));
    }

    connect();
    return {
        send,
        onMessage: (fn) => listeners.message.push(fn),
        close: () => { closedByUser = true; ws && ws.close(1000, 'bye'); },
    };
}

三个注意点:退避封顶(不然重试间隔会涨到几天)、加随机抖动(服务端重启时避免所有客户端同一毫秒回归)、区分主动关闭和被动断开(用户主动离开就不该重连)。

鉴权与安全

  • 用 wss://:明文 ws 会被中间人完整读取;安静信道上也要加密,别省这一步;
  • Token 放在首条消息里(推荐)或 URL query(会进日志,注意脱敏);浏览器 API 无法自定义握手头,这是现实约束;
  • 握手后立即校验:约定 5 秒内没完成 auth 的连接直接断开,防止匿名连接占资源;
  • 校验 Origin:WebSocket 不受同源策略约束,攻击者页面可以直连你的服务,必须用 Origin 白名单挡住;
  • 消息也要校验:大小限制(如 64KB)、类型白名单、频率限制,别假设客户端会守规矩。
// Node 侧 Origin 校验示例
wss.on('connection', (ws, req) => {
    const origin = req.headers.origin || '';
    const allowed = ['https://example.com', 'https://www.example.com'];
    if (!allowed.includes(origin)) {
        ws.close(1008, 'origin not allowed');
        return;
    }
    // ...继续
});

部署:Nginx 必须放行的两个头

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

location /ws/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_read_timeout 300s;    # 要大于心跳间隔!
}

多实例部署时还有两个问题:粘性会话(同一用户要落到同一实例,或把广播改成 Redis Pub/Sub 跨实例分发)和优雅停机(收到 SIGTERM 先给所有连接发「即将重启」消息再关,客户端会平滑重连)。

总结

把生产环境必须做对的清单收个尾:wss 加密 → 首条消息鉴权 → Origin 白名单 → 20–30 秒心跳 → 指数退避 + 抖动重连 → 断线消息队列补发 → Nginx 升级头与超时 → 多实例广播方案。协议本身半小时能学会,把这份清单做完,才算真正「上线可用」。