WebSocket 会话心跳保持 + 优雅关闭

WebSocket 是长连接,但网络波动、服务器超时、代理断开等问题会导致连接 "假死"(看似连接存在,实则无法通信);而直接断开连接也可能导致数据丢失。

一、先理解:为什么需要心跳保持?

WebSocket 长连接的 "痛点":

  1. 网络静默超时:很多中间节点(Nginx、防火墙、运营商网关)会断开 "长时间无数据传输" 的连接(比如 Nginx 默认 60s 超时);
  2. 连接假死:网络闪断后,客户端 / 服务器未感知,仍认为连接有效,发送消息时才发现失败;
  3. 资源浪费:服务器维护大量 "假死连接",占用文件描述符和内存。

心跳保持的核心目的:通过「定时双向交互的轻量帧(PING/PONG)」,确认连接的 "活性"------ 既让中间节点认为连接 "有数据传输",又能快速检测到连接异常,及时重连。

二、WebSocket 心跳机制的核心规则(协议层面)

WebSocket 协议内置了 PING/PONG 帧(Opcode 分别为 0x9、0xA),是专门用于心跳的轻量帧(无业务数据,开销极小):

  1. PING 帧:可由客户端 / 服务器发起,接收方必须在规定时间内返回 PONG 帧;
  2. PONG 帧:对 PING 帧的响应,必须携带和 PING 帧相同的「负载数据」(通常为空,仅做确认);
  3. 超时判定:若发起 PING 后,超过阈值(如 10s)未收到 PONG,则判定连接失效,触发重连。

心跳设计的最佳实践(生产级)

角色 职责 核心参数
客户端 主动发送 PING 帧(核心发起方) 心跳间隔:15~30s(小于中间节点超时时间)超时阈值:10s重连次数:3~5 次(避免无限重连)
服务器 被动响应 PONG 帧 + 检测超时 超时判定:超过 2 倍心跳间隔未收到 PING → 主动关闭连接
帧负载 建议为空或携带简单标识(如客户端 ID) 避免大负载,保持轻量

三、心跳保持的完整代码实现(基于之前的示例扩展)

我们在原有 WebSocket 示例基础上,添加「心跳检测 + 断线重连 + 超时关闭」逻辑,适配生产环境。

1. 服务端代码(Node.js + ws 库)

javascript 复制代码
const WebSocket = require('ws');
const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end('普通 HTTP 请求');
});

const wss = new WebSocket.Server({ 
  server,
  pingTimeout: 60000, // 服务器超时:60s 未收到客户端 PING → 关闭连接
  pingInterval: 0 // 服务器不主动发 PING(由客户端主导心跳)
});

// 监听新连接
wss.on('connection', (ws, req) => {
  console.log('客户端已建立 WebSocket 连接');
  const clientIp = req.socket.remoteAddress;
  let heartbeatTimer = null;

  // 1. 监听客户端 PING 帧 → 自动响应 PONG(ws 库已内置该逻辑,无需手动处理)
  // 2. 检测客户端心跳超时:自定义双层超时(兜底)
  heartbeatTimer = setInterval(() => {
    if (ws.isAlive === false) {
      console.log(`客户端 ${clientIp} 心跳超时,关闭连接`);
      return ws.terminate(); // 强制关闭
    }
    ws.isAlive = false; // 标记为未活跃,等待 PONG 重置
  }, 40000); // 40s 检测一次(大于客户端 30s 心跳间隔)

  // 3. 监听 PONG 帧 → 重置活跃状态
  ws.on('pong', () => {
    ws.isAlive = true;
    console.log(`收到 ${clientIp} 的 PONG 帧,心跳正常`);
  });

  // 4. 业务消息处理(原有逻辑)
  ws.on('message', (data) => {
    console.log('收到客户端消息:', data.toString());
    ws.send(`服务器回复:${data.toString()}`);
  });

  // 5. 连接关闭 → 清理定时器
  ws.on('close', (code, reason) => {
    clearInterval(heartbeatTimer);
    console.log(`WebSocket 连接关闭:${code} - ${reason.toString()}`);
  });

  // 6. 错误处理
  ws.on('error', (err) => {
    clearInterval(heartbeatTimer);
    console.error('WebSocket 错误:', err);
  });
});

server.listen(8080, () => {
  console.log('服务器运行在 http://localhost:8080');
  console.log('WebSocket 地址:ws://localhost:8080');
});

2. 客户端代码(浏览器 HTML)

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>WebSocket 心跳 + 关闭示例</title>
</head>
<body>
  <input type="text" id="msgInput" placeholder="输入消息">
  <button onclick="sendMsg()">发送</button>
  <button onclick="closeWS()">手动关闭连接</button>
  <div id="msgList"></div>

  <script>
    // 核心配置
    const WS_URL = 'ws://localhost:8080';
    const HEARTBEAT_INTERVAL = 30000; // 30s 发一次 PING
    const HEARTBEAT_TIMEOUT = 10000; // PING 后 10s 未收到 PONG → 重连
    let ws = null;
    let heartbeatTimer = null; // 心跳定时器
    let reconnectTimer = null; // 重连定时器
    let reconnectCount = 0; // 重连次数
    const MAX_RECONNECT = 5; // 最大重连次数

    // 初始化 WebSocket 连接
    function initWS() {
      // 关闭旧连接(避免重复)
      if (ws) {
        ws.close(1000, '重新初始化连接');
      }

      ws = new WebSocket(WS_URL);
      appendMsg('开始建立 WebSocket 连接...');

      // 1. 连接成功 → 启动心跳
      ws.onopen = () => {
        appendMsg('连接成功:可以发送消息了');
        reconnectCount = 0; // 重置重连次数
        startHeartbeat(); // 启动心跳
      };

      // 2. 接收服务器消息
      ws.onmessage = (event) => {
        appendMsg(`服务器:${event.data}`);
      };

      // 3. 连接关闭 → 尝试重连
      ws.onclose = (event) => {
        appendMsg(`连接关闭:${event.code} - ${event.reason}`);
        stopHeartbeat(); // 停止心跳
        // 非主动关闭(code!=1000)且未达最大重连次数 → 重连
        if (event.code !== 1000 && reconnectCount < MAX_RECONNECT) {
          reconnectCount++;
          appendMsg(`开始第 ${reconnectCount} 次重连...`);
          reconnectTimer = setTimeout(initWS, 5000); // 5s 后重连
        }
      };

      // 4. 错误处理
      ws.onerror = (error) => {
        appendMsg(`错误:${error.message}`);
        stopHeartbeat();
      };

      // 5. 监听 PONG 帧(确认心跳响应)
      ws.onpong = () => {
        appendMsg('收到服务器 PONG 帧,心跳正常');
        // 清除超时定时器(避免误判重连)
        if (reconnectTimer) {
          clearTimeout(reconnectTimer);
          reconnectTimer = null;
        }
      };
    }

    // 启动心跳:定时发送 PING 帧
    function startHeartbeat() {
      stopHeartbeat(); // 先停止旧定时器
      heartbeatTimer = setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
          appendMsg('发送 PING 帧...');
          // 发送 PING 帧,第二个参数为是否掩码(客户端必须掩码)
          ws.ping('', true, (err) => {
            if (err) {
              appendMsg(`发送 PING 帧失败:${err.message}`);
              // PING 发送失败 → 触发超时重连
              setTimeout(() => {
                if (ws.readyState === WebSocket.OPEN) {
                  ws.close(1011, 'PING 发送失败');
                }
              }, HEARTBEAT_TIMEOUT);
            }
          });
        }
      }, HEARTBEAT_INTERVAL);
    }

    // 停止心跳
    function stopHeartbeat() {
      if (heartbeatTimer) {
        clearInterval(heartbeatTimer);
        heartbeatTimer = null;
      }
    }

    // 发送业务消息(原有逻辑)
    function sendMsg() {
      const input = document.getElementById('msgInput');
      const msg = input.value.trim();
      if (!msg || ws.readyState !== WebSocket.OPEN) return;
      ws.send(msg);
      appendMsg(`客户端:${msg}`);
      input.value = '';
    }

    // 优雅关闭连接
    function closeWS() {
      if (ws && ws.readyState === WebSocket.OPEN) {
        // 1000 是正常关闭码,第二个参数是关闭原因(可选)
        ws.close(1000, '用户主动关闭连接');
        appendMsg('正在主动关闭连接...');
      }
    }

    // 辅助函数:追加消息
    function appendMsg(text) {
      const msgList = document.getElementById('msgList');
      const p = document.createElement('p');
      p.textContent = `[${new Date().toLocaleTimeString()}] ${text}`;
      msgList.appendChild(p);
      // 滚动到最新消息
      msgList.scrollTop = msgList.scrollHeight;
    }

    // 页面加载时初始化连接
    window.onload = initWS;

    // 页面关闭时优雅关闭连接
    window.onbeforeunload = () => {
      closeWS();
    };
  </script>
</body>
</html>

四、心跳机制核心逻辑拆解

1. 客户端核心动作

  • 连接成功启动心跳onopen 触发后,启动 30s 定时器,定时调用 ws.ping() 发送 PING 帧;
  • PING 发送失败处理 :若 ping() 回调报错,判定网络异常,触发关闭 / 重连;
  • PONG 响应确认onpong 触发后,确认连接正常,重置重连状态;
  • 断线重连onclose 触发后,若不是主动关闭且未达最大重连次数,5s 后重新初始化连接;
  • 页面卸载清理onbeforeunload 触发优雅关闭,避免连接残留。

2. 服务器核心动作

  • 自动响应 PONGws 库内置了 PING → PONG 的响应逻辑,无需手动写;
  • 双层超时检测
    • 内置 pingTimeout: 60000:60s 未收到 PING → 自动关闭;
    • 自定义 40s 定时器:标记 isAlive 状态,未收到 PONG 则强制关闭;
  • 连接关闭清理:清除定时器,避免内存泄漏。

五、WebSocket 会话关闭(优雅关闭 vs 强制关闭)

WebSocket 关闭分为「优雅关闭」和「强制关闭」,协议定义了标准化的关闭码,确保双方明确关闭原因。

1. 关闭的核心规则

  • 优雅关闭流程
    1. 发起方发送「关闭帧(Opcode=0x8)」,携带关闭码和原因;
    2. 接收方收到关闭帧后,回复确认关闭帧;
    3. 双方完成 TCP 四次挥手,连接断开。
  • 强制关闭 :直接终止 TCP 连接(如 ws.terminate()),无关闭帧交互,可能导致数据丢失。

2. 常用标准关闭码(RFC 6455 定义)

关闭码 含义 适用场景
1000 正常关闭(Normal Closure) 用户主动关闭、业务完成
1001 端点离开(Going Away) 客户端页面关闭、服务器重启
1002 协议错误(Protocol Error) 收到非法帧、掩码违规
1003 不支持的数据类型(Unsupported Data) 收到非文本 / 二进制帧
1006 连接异常关闭(Abnormal Closure) 网络中断、强制关闭(无关闭帧)
1007 数据格式错误(Invalid Payload Data) 文本帧非 UTF-8 编码
1008 策略违反(Policy Violation) 消息长度超限、业务规则违规
1011 服务器内部错误(Internal Error) 服务器处理消息异常

3. 关闭的代码实现

(1)优雅关闭(推荐)
javascript 复制代码
// 客户端主动优雅关闭
ws.close(1000, '用户主动退出');

// 服务器主动优雅关闭
ws.close(1001, '服务器维护,即将重启');
(2)强制关闭(仅应急使用)
javascript 复制代码
// 客户端强制关闭
ws.terminate();

// 服务器强制关闭
ws.terminate();
(3)监听关闭事件(获取原因)
javascript 复制代码
ws.onclose = (event) => {
  console.log(`关闭码:${event.code}`);
  console.log(`关闭原因:${event.reason}`);
  console.log(`是否干净关闭:${event.wasClean}`); // true=优雅关闭,false=强制/异常关闭
};

六、生产环境注意事项

  1. 心跳间隔配置:必须小于中间节点的超时时间(如 Nginx 默认 60s,心跳设为 30s);
  2. 重连策略:添加 "退避算法"(重连间隔逐渐增加:5s→10s→20s),避免频繁重连压垮服务器;
  3. 关闭码规范:严格使用标准关闭码,便于排查问题(避免自定义 1000 以内的码);
  4. 数据兜底:关闭前检查是否有未发送的消息,缓存后重连时补发;
  5. WSS 加密 :生产环境必须用 wss://(WebSocket over TLS),避免心跳帧 / 关闭帧被篡改。

七、核心总结

  1. 心跳保持
    • 核心是客户端定时发 PING 帧,服务器自动回 PONG 帧,通过超时检测确认连接活性;
    • 解决 "连接假死" 和 "中间节点超时断开" 问题,是长连接稳定运行的必备机制;
  2. 会话关闭
    • 优先用优雅关闭(ws.close(1000, 原因)),明确关闭码和原因;
    • 强制关闭(ws.terminate())仅用于应急,可能导致数据丢失;
  3. 代码核心:心跳需配合定时器、重连次数限制,关闭需清理资源(定时器、连接),避免内存泄漏。

0voice · GitHub

相关推荐
Geometry Fu4 分钟前
《无线传感器网络》WSN 第6讲 IEEE802.15.4通信标准 知识点总结+习题讲解
网络
徐同保22 分钟前
使用n8n中的HTTP Request节点清空pinecones向量数据库
数据库·网络协议·http
萧技电创EIIA22 分钟前
西门子modbus Tcp通讯并进行仿真(从站)
网络
勇气要爆发1 小时前
内网 IP 怎么访问互联网?NAT 技术与“小区保安”的比喻
网络·网络协议·tcp/ip·nat
树码小子1 小时前
网络原理(13):TCP协议十大核心机制 -- 确认应答 & 超时重传
服务器·网络·tcp/ip
Gofarlic_OMS1 小时前
协同设计平台中PTC许可证的高效调度策略
网络·数据库·安全·oracle·aigc
2501_915106321 小时前
iOS 安装了证书,HTTPS 还是抓不到
android·网络协议·ios·小程序·https·uni-app·iphone
谢平康1 小时前
通过nfs方式做目录限额方法
linux·服务器·网络
航Hang*1 小时前
第七章:综合布线技术 —— 设备间子系统的设计与施工
网络·笔记·学习·期末·复习
智链RFID2 小时前
RFID技术:企业效率革命新引擎
大数据·网络·人工智能·rfid