Skip to content

WebSocket

为什么需要 WebSocket

HTTP 是请求-响应模型——客户端不发请求,服务器就无法主动推送数据。对于需要实时更新的场景(聊天、股票行情、协同编辑),传统方案存在明显的局限:

短轮询(Short Polling):
  客户端每隔 N 秒发一次请求,询问"有新数据吗?"

  客户端        服务器
    |── GET /messages ──→|  "有新消息吗?"
    |←── 200 (无新数据) ──|
    |                      |  等待 3 秒...
    |── GET /messages ──→|  "有新消息吗?"
    |←── 200 (无新数据) ──|
    |                      |  等待 3 秒...
    |── GET /messages ──→|  "有新消息吗?"
    |←── 200 (有新数据!) ──|

  问题:
    - 大量无效请求(90% 以上返回"无新数据")
    - 实时性差(最坏延迟 = 轮询间隔)
    - 每次请求都带完整 HTTP Header(~800 字节),浪费带宽

长轮询(Long Polling):
  客户端发请求,服务器有数据才响应,否则 hold 住连接

  客户端        服务器
    |── GET /messages ──→|  挂起,等待新数据...
    |         (30s hold)  |
    |←── 200 (新数据!) ──|  有数据了,立即返回
    |── GET /messages ──→|  立刻发起下一次请求
    |         (hold...)   |

  改进:减少了无效请求
  仍存在的问题:
    - 每次响应后必须重新建立连接(HTTP 开销)
    - 服务器维护大量挂起连接,占用资源
    - 单向通信——服务器无法随时向客户端推送

WebSocket 的出发点:让浏览器和服务器之间建立一条持久的、双向的通信通道

WebSocket:
  客户端        服务器
    |── HTTP 升级请求 ──→|  "我想升级到 WebSocket"
    |←── 101 Switching ──|  "同意,升级成功"
    |                      |
    |══ 全双工通信通道 ══|  TCP 连接保持
    |                      |
    |──→ 发送消息          |  客户端随时发
    |          ←── 推送消息|  服务器随时推
    |──→ 发送消息          |
    |          ←── 推送消息|
    |          ←── 推送消息|  服务器可以连续推送
    |                      |

  优势:
    - 全双工:双方随时发送数据,无需等待
    - 低开销:握手后数据帧最小只有 2 字节头部(vs HTTP ~800 字节)
    - 实时性:数据即时到达,无轮询延迟
    - 持久连接:一次握手,长期通信

握手流程

WebSocket 复用 HTTP 的端口(80/443),通过 HTTP Upgrade 机制完成协议升级:

客户端                                              服务器
  |                                                   |
  |── HTTP GET + Upgrade 请求 ──────────────────→   |
  |   GET /chat HTTP/1.1                              |
  |   Host: example.com                               |
  |   Upgrade: websocket                              |
  |   Connection: Upgrade                             |
  |   Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==    |
  |   Sec-WebSocket-Version: 13                       |
  |   Origin: https://example.com                     |
  |                                                   |
  |←────────────────── 101 Switching Protocols ──   |
  |   HTTP/1.1 101 Switching Protocols                |
  |   Upgrade: websocket                              |
  |   Connection: Upgrade                             |
  |   Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= |
  |                                                   |
  |═══════════ WebSocket 连接已建立 ════════════════|
  |         此后不再使用 HTTP 协议                      |
  |         双方通过 WebSocket 帧通信                   |

请求头详解

客户端发送的关键请求头:

Upgrade: websocket
  → 告诉服务器希望升级到 WebSocket 协议

Connection: Upgrade
  → 告诉服务器这是一个升级连接的请求

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
  → 客户端随机生成的 16 字节 Base64 编码值
  → 用于防止缓存代理误将 WebSocket 握手当作普通 HTTP 缓存
  → 不是加密用途,不提供安全保障

Sec-WebSocket-Version: 13
  → WebSocket 协议版本(当前标准版本是 13)

Origin: https://example.com
  → 来源页面地址(服务器可用于验证来源,防止跨站 WebSocket 劫持)

Sec-WebSocket-Protocol: chat, superchat(可选)
  → 子协议协商,客户端提供支持的子协议列表

Sec-WebSocket-Extensions: permessage-deflate(可选)
  → 扩展协商,如消息压缩

响应头详解

服务器返回的关键响应头:

HTTP/1.1 101 Switching Protocols
  → 状态码 101 表示协议切换成功

Upgrade: websocket
Connection: Upgrade
  → 确认协议升级

Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  → 服务器对 Sec-WebSocket-Key 的校验响应
  → 计算方式:
     1. 拼接:Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
        → "dGhlIHNhbXBsZSBub25jZQ==" + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
     2. SHA-1 哈希
     3. Base64 编码
     → 结果:"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

  → 客户端收到后也做同样计算,比对结果
  → 确保服务器真的理解 WebSocket 协议,而不是一个普通 HTTP 服务器意外返回 101

Sec-WebSocket-Accept 计算过程

js
const crypto = require('crypto');

const key = 'dGhlIHNhbXBsZSBub25jZQ==';
const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

const accept = crypto
  .createHash('sha1')
  .update(key + MAGIC_STRING)
  .digest('base64');

// accept === 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='

数据帧格式

握手完成后,通信不再使用 HTTP,而是通过 WebSocket 帧(Frame) 传输数据:

WebSocket 帧结构(RFC 6455):

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data (continued)                  |
 +---------------------------------------------------------------+

各字段含义

FIN(1 bit):
  1 = 这是消息的最后一个帧(或唯一帧)
  0 = 后面还有后续帧(消息分片)

RSV1/RSV2/RSV3(各 1 bit):
  保留位,默认为 0
  扩展(如 permessage-deflate 压缩)使用 RSV1

opcode(4 bit):帧类型
  0x0  继续帧(Continuation)—— 分片消息的后续帧
  0x1  文本帧(Text)—— UTF-8 编码的文本数据
  0x2  二进制帧(Binary)—— 任意二进制数据
  0x8  关闭帧(Close)—— 请求关闭连接
  0x9  Ping 帧 —— 心跳探测
  0xA  Pong 帧 —— 心跳响应

MASK(1 bit):
  客户端 → 服务器的帧必须设为 1(必须掩码)
  服务器 → 客户端的帧必须设为 0(不掩码)
  掩码用于防止缓存污染攻击(恶意数据被代理服务器缓存)

Payload length(7 bit / 7+16 bit / 7+64 bit):
  0~125     → 直接表示长度
  126       → 后续 2 字节表示实际长度(最大 65535 字节)
  127       → 后续 8 字节表示实际长度(最大 2^63 字节)

Masking-key(0 或 4 字节):
  MASK=1 时存在,4 字节的掩码密钥
  解码算法:payload[i] XOR mask[i % 4]

Payload Data:
  实际传输的数据

帧类型分类

数据帧:
  ┌────────────────────────────────────────────────┐
  │ 文本帧(opcode=0x1)                            │
  │   传输 UTF-8 文本(JSON、纯文本等)              │
  │   最常用的帧类型                                 │
  │                                                  │
  │ 二进制帧(opcode=0x2)                           │
  │   传输任意二进制数据(图片、音频、Protobuf 等)    │
  └────────────────────────────────────────────────┘

控制帧:
  ┌────────────────────────────────────────────────┐
  │ Close 帧(opcode=0x8)                          │
  │   请求关闭连接,可携带状态码和原因               │
  │   收到 Close 帧后必须回复 Close 帧               │
  │                                                  │
  │ Ping 帧(opcode=0x9)                           │
  │   心跳探测,可携带应用数据(≤125 字节)           │
  │   收到 Ping 必须回复 Pong                        │
  │                                                  │
  │ Pong 帧(opcode=0xA)                           │
  │   心跳响应,必须携带与 Ping 相同的应用数据       │
  └────────────────────────────────────────────────┘

消息分片(Fragmentation):
  大消息可以拆分为多个帧发送:

  第一帧:FIN=0, opcode=0x1(文本帧,表示消息类型)
  中间帧:FIN=0, opcode=0x0(继续帧)
  最后帧:FIN=1, opcode=0x0(继续帧,FIN=1 表示结束)

  接收方将所有帧的 payload 拼接后得到完整消息

浏览器 API

WebSocket 构造函数

js
const ws = new WebSocket('wss://example.com/chat');

// ws:// → 非加密(类似 HTTP)
// wss:// → TLS 加密(类似 HTTPS),生产环境必须使用

readyState 状态

WebSocket.CONNECTING  = 0  连接正在建立(握手中)
WebSocket.OPEN        = 1  连接已建立,可以通信
WebSocket.CLOSING     = 2  连接正在关闭
WebSocket.CLOSED      = 3  连接已关闭

事件与方法

js
const ws = new WebSocket('wss://example.com/chat');

ws.onopen = (event) => {
  console.log('连接已建立');
  ws.send('Hello Server!');
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));

  const buffer = new ArrayBuffer(8);
  ws.send(buffer);
};

ws.onmessage = (event) => {
  if (typeof event.data === 'string') {
    const message = JSON.parse(event.data);
    console.log('收到文本消息:', message);
  } else if (event.data instanceof Blob) {
    console.log('收到二进制消息:', event.data);
  }
};

ws.onerror = (event) => {
  console.error('连接错误');
};

ws.onclose = (event) => {
  console.log('连接关闭:', event.code, event.reason);
  // event.code → 状态码(1000=正常关闭, 1006=异常断开)
  // event.reason → 关闭原因(字符串)
  // event.wasClean → 是否正常关闭
};

// 主动关闭
ws.close(1000, '用户主动断开');

常见关闭状态码

1000  正常关闭
1001  端点离开(页面关闭、服务器关机)
1002  协议错误
1003  收到不支持的数据类型
1005  未收到状态码(保留,不应出现在 Close 帧中)
1006  异常关闭(未收到 Close 帧,如网络断开)
1007  数据类型不一致(如文本帧中非 UTF-8 数据)
1008  策略违规
1009  消息过大
1010  客户端期望的扩展未被服务器支持
1011  服务器内部错误

完整聊天室示例

js
class ChatClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.messageHandlers = new Set();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log('[Chat] 已连接');
    };

    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.messageHandlers.forEach((handler) => handler(message));
    };

    this.ws.onclose = (event) => {
      console.log(`[Chat] 连接关闭: ${event.code}`);
    };

    this.ws.onerror = () => {
      console.error('[Chat] 连接错误');
    };
  }

  send(type, payload) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, payload, timestamp: Date.now() }));
    }
  }

  onMessage(handler) {
    this.messageHandlers.add(handler);
    return () => this.messageHandlers.delete(handler);
  }

  disconnect() {
    this.ws?.close(1000, '用户离开');
  }
}

const chat = new ChatClient('wss://example.com/chat');
chat.connect();
chat.onMessage((msg) => console.log('收到:', msg));
chat.send('chat', { text: '你好!' });

心跳保活机制

为什么需要心跳

WebSocket 连接建立后,如果长时间没有数据传输,连接可能被中间设备断开:

1. NAT 超时
   家用路由器/运营商 NAT 设备维护连接映射表
   如果一段时间(通常 1~5 分钟)没有数据包经过,NAT 条目被清除
   → 后续数据包无法路由 → 连接实质断开,但双方不知道

2. 代理/负载均衡超时
   Nginx 默认 proxy_read_timeout 60s
   AWS ALB 默认 idle timeout 60s
   如果超时未收到数据,代理主动关闭连接

3. 检测断线
   TCP 层的 keep-alive 探测间隔太长(默认 2 小时)
   应用层无法及时发现"连接已经死了"的情况
   → 用户看到界面正常,但消息发不出去也收不到

解决方案:定期发送心跳包,保持连接活跃 + 检测连接状态

协议层心跳:Ping/Pong

WebSocket 协议内建了 Ping/Pong 机制:

服务器 → 客户端:发送 Ping 帧(opcode=0x9)
客户端 → 服务器:自动回复 Pong 帧(opcode=0xA)

浏览器的 WebSocket API 会自动响应 Ping 帧(无需开发者处理)
但浏览器不暴露发送 Ping 帧的 API

→ 协议层 Ping/Pong 通常由服务器端发起

Node.js 服务端示例(使用 ws 库):
js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  ws.isAlive = true;

  ws.on('pong', () => {
    ws.isAlive = true;
  });
});

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) {
      return ws.terminate();
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => {
  clearInterval(interval);
});

应用层心跳

浏览器端无法发送协议层 Ping,因此实践中常用"应用层心跳":
通过 send() 发送自定义心跳消息,约定消息格式

优势:
  - 客户端和服务端都可以主动发起
  - 可以携带业务数据(如在线状态、延迟检测)
  - 不依赖底层协议实现
js
class HeartbeatWebSocket {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.heartbeatTimer = null;
    this.heartbeatTimeout = null;
    this.HEARTBEAT_INTERVAL = 30000;
    this.HEARTBEAT_TIMEOUT = 5000;
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.startHeartbeat();
    };

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'pong') {
        this.resetHeartbeatTimeout();
        return;
      }
      this.handleMessage(data);
    };

    this.ws.onclose = () => {
      this.stopHeartbeat();
    };
  }

  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
        this.heartbeatTimeout = setTimeout(() => {
          console.warn('心跳超时,关闭连接');
          this.ws.close();
        }, this.HEARTBEAT_TIMEOUT);
      }
    }, this.HEARTBEAT_INTERVAL);
  }

  resetHeartbeatTimeout() {
    if (this.heartbeatTimeout) {
      clearTimeout(this.heartbeatTimeout);
      this.heartbeatTimeout = null;
    }
  }

  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
    this.resetHeartbeatTimeout();
  }

  handleMessage(data) {
    console.log('业务消息:', data);
  }
}

断线重连策略

网络波动、服务器重启等都会导致 WebSocket 断线。生产环境必须实现自动重连

指数退避算法(Exponential Backoff)

为什么不能立即重连?
  服务器宕机后,如果所有客户端同时重连 → 瞬间涌入大量连接 → 服务器雪崩

指数退避:每次重连等待时间翻倍 + 随机抖动

  第 1 次重连:等待 1s  + 随机 0~500ms
  第 2 次重连:等待 2s  + 随机 0~500ms
  第 3 次重连:等待 4s  + 随机 0~500ms
  第 4 次重连:等待 8s  + 随机 0~500ms
  第 5 次重连:等待 16s + 随机 0~500ms
  第 6 次重连:等待 30s(达到上限)+ 随机 0~500ms
  ...
  
  公式:delay = min(baseDelay × 2^attempt, maxDelay) + random(0, jitter)

  随机抖动(Jitter)的作用:
    打散重连时间点,避免"惊群效应"(所有客户端同一时刻重连)

完整重连实现

js
class ReconnectWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
    this.baseDelay = options.baseDelay ?? 1000;
    this.maxDelay = options.maxDelay ?? 30000;
    this.jitter = options.jitter ?? 500;
    this.forceClosed = false;
    this.messageQueue = [];
    this.onMessageCallback = null;
    this.onStateChange = null;
  }

  connect() {
    this.forceClosed = false;
    this._updateState('CONNECTING');
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      this._updateState('OPEN');
      this._flushMessageQueue();
    };

    this.ws.onmessage = (event) => {
      this.onMessageCallback?.(JSON.parse(event.data));
    };

    this.ws.onclose = (event) => {
      this._updateState('CLOSED');
      if (!this.forceClosed && event.code !== 1000) {
        this._reconnect();
      }
    };

    this.ws.onerror = () => {
      this.ws.close();
    };
  }

  send(data) {
    const message = JSON.stringify(data);
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(message);
    } else {
      this.messageQueue.push(message);
    }
  }

  close() {
    this.forceClosed = true;
    this.ws?.close(1000, '主动关闭');
  }

  _reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      this._updateState('FAILED');
      console.error(`已达最大重试次数 (${this.maxReconnectAttempts}),停止重连`);
      return;
    }

    const delay = Math.min(
      this.baseDelay * Math.pow(2, this.reconnectAttempts),
      this.maxDelay
    );
    const actualDelay = delay + Math.random() * this.jitter;

    this.reconnectAttempts++;
    this._updateState('RECONNECTING');
    console.log(`第 ${this.reconnectAttempts} 次重连,等待 ${Math.round(actualDelay)}ms`);

    setTimeout(() => {
      if (!this.forceClosed) {
        this.connect();
      }
    }, actualDelay);
  }

  _flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      this.ws.send(this.messageQueue.shift());
    }
  }

  _updateState(state) {
    this.onStateChange?.(state);
  }
}

const ws = new ReconnectWebSocket('wss://example.com/chat', {
  maxReconnectAttempts: 10,
  baseDelay: 1000,
  maxDelay: 30000,
});

ws.onMessageCallback = (data) => console.log('收到:', data);
ws.onStateChange = (state) => console.log('状态:', state);
ws.connect();
ws.send({ type: 'chat', text: '你好' });

与其他实时通信方案对比

特性WebSocketSSE (Server-Sent Events)HTTP 长轮询HTTP 短轮询
通信方向全双工(双向)单向(服务器→客户端)单向(服务器→客户端)单向(服务器→客户端)
底层协议独立协议(ws://)HTTPHTTPHTTP
连接数1 个持久连接1 个持久连接每次响应后重建每次请求新连接
数据格式文本 / 二进制纯文本(UTF-8)任意任意
浏览器支持所有现代浏览器除 IE 外所有所有浏览器所有浏览器
自动重连❌ 需手动实现✅ 浏览器原生支持❌ 需手动实现❌ 需手动实现
跨域需服务器验证 Origin遵循 CORS遵循 CORS遵循 CORS
代理/防火墙兼容⚠️ 可能被拦截✅ 普通 HTTP✅ 普通 HTTP✅ 普通 HTTP
服务器资源消耗低(帧开销小)中(挂起连接)高(频繁建连)
适用场景聊天、游戏、协同编辑通知推送、实时数据流简单通知、兼容性要求高简单场景、低频更新
选型建议:

需要双向通信(聊天、游戏、协同编辑)
  → WebSocket

只需服务器推送(新闻推送、股票行情、日志流)
  → SSE(更简单,原生支持重连和事件 ID)

需要最大兼容性(老旧浏览器/企业环境/严格防火墙)
  → 长轮询

更新频率低、实时性要求不高
  → 短轮询(实现最简单)

SSE 简要对比示例

js
// SSE —— 只需 3 行代码
const source = new EventSource('/api/notifications');
source.onmessage = (event) => {
  console.log('通知:', JSON.parse(event.data));
};
// 浏览器自动重连、自动从 Last-Event-ID 恢复

// WebSocket —— 需要手动管理连接
const ws = new WebSocket('wss://example.com/notifications');
ws.onmessage = (event) => {
  console.log('通知:', JSON.parse(event.data));
};
// 需要手动实现重连、心跳、状态管理

Socket.IO

Socket.IO 是基于 WebSocket 的实时通信库,提供了大量生产级功能。

核心功能

Socket.IO 在原生 WebSocket 之上提供:

1. 自动降级(Transport Fallback)
   WebSocket 不可用时自动降级为 HTTP 长轮询
   → 适应严格防火墙/老旧代理环境

2. 自动重连
   内建指数退避重连策略
   断线期间的消息会在重连后自动发送(buffered events)

3. 房间(Rooms)
   服务端可以将 socket 加入/移出房间
   支持向特定房间广播消息

4. 命名空间(Namespaces)
   在同一个连接上隔离不同的通信通道
   如 /chat 和 /notifications 使用不同的命名空间

5. 确认机制(Acknowledgements)
   发送消息后可以收到对方的确认回调
   类似 HTTP 的请求-响应模式

6. 二进制支持
   自动处理 ArrayBuffer / Blob 的序列化

与原生 WebSocket 的区别

                    原生 WebSocket              Socket.IO
                    ─────────────              ─────────
协议                 标准 WebSocket 协议         自定义协议(基于 WebSocket + 降级)
传输层               仅 WebSocket               WebSocket + HTTP 长轮询
互操作性             任何 WebSocket 服务端        必须使用 Socket.IO 服务端
消息格式             自定义                      内建事件系统(emit/on)
重连                 手动实现                    自动
房间/命名空间         无                          内建
包体积               0(浏览器原生)             ~48KB (gzip ~16KB)

选择建议:
  - 需要与第三方 WebSocket 服务通信 → 原生 WebSocket
  - 对包体积敏感的轻量场景 → 原生 WebSocket
  - 需要房间/命名空间/可靠投递 → Socket.IO
  - 需要兼容严格网络环境 → Socket.IO

基本使用示例

js
// 客户端
import { io } from 'socket.io-client';

const socket = io('wss://example.com', {
  reconnection: true,
  reconnectionAttempts: 10,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 30000,
  auth: { token: 'jwt-token-here' },
});

socket.on('connect', () => {
  console.log('已连接:', socket.id);
});

socket.emit('join-room', { room: 'general' });

socket.emit('chat-message', { text: '你好!' }, (ack) => {
  console.log('服务器确认:', ack);
});

socket.on('chat-message', (data) => {
  console.log('收到消息:', data);
});

socket.on('disconnect', (reason) => {
  console.log('断开:', reason);
});
js
// 服务端(Node.js)
const { Server } = require('socket.io');
const io = new Server(3000, {
  cors: { origin: 'https://example.com' },
});

io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (verifyToken(token)) {
    next();
  } else {
    next(new Error('认证失败'));
  }
});

io.on('connection', (socket) => {
  console.log('新连接:', socket.id);

  socket.on('join-room', ({ room }) => {
    socket.join(room);
    socket.to(room).emit('user-joined', { userId: socket.id });
  });

  socket.on('chat-message', (data, callback) => {
    const room = [...socket.rooms][1];
    socket.to(room).emit('chat-message', {
      ...data,
      from: socket.id,
      timestamp: Date.now(),
    });
    callback({ status: 'ok' });
  });

  socket.on('disconnect', () => {
    console.log('断开:', socket.id);
  });
});

安全

wss:// 加密

ws://  → 明文传输,类似 HTTP
wss:// → TLS 加密传输,类似 HTTPS

生产环境必须使用 wss://,原因:
  1. 防止中间人窃听/篡改 WebSocket 数据
  2. 很多代理/防火墙会拦截非加密的 ws:// 连接
  3. 如果页面是 HTTPS,浏览器会阻止连接 ws://(Mixed Content)

wss:// 的 TLS 握手和 HTTPS 完全相同
  → 复用 443 端口
  → 使用同一张 SSL 证书
  → 对中间设备来说,wss 流量和 HTTPS 流量无法区分

Origin 验证

WebSocket 握手是一个 HTTP 请求,浏览器会自动附带 Origin 头:

GET /chat HTTP/1.1
Origin: https://evil-site.com    ← 浏览器自动设置,JS 无法伪造

服务器必须验证 Origin:

const allowedOrigins = ['https://example.com', 'https://app.example.com'];

wss.on('headers', (headers, req) => {
  const origin = req.headers.origin;
  if (!allowedOrigins.includes(origin)) {
    req.destroy();  // 拒绝连接
  }
});

为什么 Origin 验证很重要:
  - 防止跨站 WebSocket 劫持(Cross-Site WebSocket Hijacking, CSWSH)
  - 恶意网站可以在用户不知情的情况下向你的 WebSocket 服务发起连接
  - 如果不验证 Origin,恶意站点可以利用用户的 Cookie 建立认证连接

Token 认证

WebSocket 不能使用标准 HTTP 认证头的原因:

浏览器的 WebSocket API 不支持设置自定义请求头:
  new WebSocket('wss://example.com/chat');
  // 没有办法传入 Authorization: Bearer <token>
  // 这不是 fetch,没有 headers 选项

Cookie 方案的问题:
  - 跨域时受 SameSite 策略限制
  - 容易被 CSWSH 攻击利用(恶意站点可以借用用户 Cookie)
  - 需要额外的 CSRF 防护

推荐方案:通过 URL 参数或首条消息传递 Token

方案一:URL 参数传递(最常用)

js
const token = getAuthToken();
const ws = new WebSocket(`wss://example.com/chat?token=${token}`);
js
// 服务端验证
wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'wss://example.com');
  const token = url.searchParams.get('token');

  try {
    const user = verifyJWT(token);
    ws.userId = user.id;
  } catch {
    ws.close(1008, '认证失败');
  }
});
注意:Token 会出现在 URL 中
  - 可能被记录到服务器访问日志
  - 使用短期 Token(如 5 分钟有效)降低泄露风险
  - 或者使用一次性 Token(用后即失效)

方案二:连接后首条消息认证

js
const ws = new WebSocket('wss://example.com/chat');

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
};
js
// 服务端:在认证完成前拒绝其他消息
wss.on('connection', (ws) => {
  ws.isAuthenticated = false;

  const authTimeout = setTimeout(() => {
    ws.close(1008, '认证超时');
  }, 5000);

  ws.on('message', (data) => {
    const message = JSON.parse(data);

    if (!ws.isAuthenticated) {
      if (message.type === 'auth') {
        try {
          const user = verifyJWT(message.token);
          ws.userId = user.id;
          ws.isAuthenticated = true;
          clearTimeout(authTimeout);
          ws.send(JSON.stringify({ type: 'auth_ok' }));
        } catch {
          ws.close(1008, '认证失败');
        }
      } else {
        ws.close(1008, '未认证');
      }
      return;
    }

    handleMessage(ws, message);
  });
});

方案三:Socket.IO 的 auth 选项

js
const socket = io('wss://example.com', {
  auth: { token: getAuthToken() },
});

// 服务端通过中间件验证
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  try {
    socket.user = verifyJWT(token);
    next();
  } catch {
    next(new Error('认证失败'));
  }
});

面试高频题

1. WebSocket 和 HTTP 有什么关系?WebSocket 连接是如何建立的?

WebSocket 和 HTTP 是两个不同的协议,但 WebSocket 的连接建立依赖 HTTP。握手流程:客户端发送一个带有 Upgrade: websocketConnection: Upgrade 头的 HTTP GET 请求,同时携带 Sec-WebSocket-Key(随机 Base64 值)和 Sec-WebSocket-Version: 13。服务器如果同意升级,返回 101 Switching Protocols,并在响应中包含 Sec-WebSocket-Accept(将客户端的 Key 拼接魔术字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 后做 SHA-1 + Base64)。握手完成后,底层 TCP 连接保持不变,但协议从 HTTP 切换为 WebSocket 的二进制帧协议。之所以复用 HTTP 握手,是为了兼容现有的 80/443 端口和代理基础设施。

2. WebSocket 的心跳机制是怎么实现的?为什么需要心跳?

心跳的目的有三个:①保持 NAT/代理的连接映射不超时(NAT 通常 1~5 分钟超时、Nginx 默认 60 秒超时);②及时检测死连接(TCP keep-alive 默认 2 小时太慢);③确认对端仍然存活。实现分两层:协议层——WebSocket 内建 Ping/Pong 帧,服务器发送 Ping,客户端自动回复 Pong;应用层——通过 send() 发送自定义心跳消息(如 {type:'ping'}),适用于浏览器端(浏览器 API 无法主动发 Ping 帧)。实践中常用应用层心跳,配合超时检测:发送 ping 后启动定时器,若超时未收到 pong 则判定连接已断,主动关闭并触发重连。心跳间隔通常 15~30 秒。

3. WebSocket 断线重连如何实现?为什么要用指数退避?

断线重连核心:监听 onclose 事件,如果不是主动关闭(code !== 1000)则自动重连。使用**指数退避(Exponential Backoff)的原因:如果服务器宕机后所有客户端同时重连,瞬间的连接风暴会导致服务器再次崩溃。指数退避让每次重连等待时间翻倍(如 1s → 2s → 4s → 8s),再加上随机抖动(Jitter)**打散客户端的重连时间点,避免惊群效应。实现要点:①记录重试次数,计算 min(base × 2^attempt, maxDelay) + random(jitter);②设置最大重试次数(如 10 次),超过则停止重连并通知用户;③连接成功后重置计数器;④断线期间的消息放入队列,重连成功后按序发送。

浏览器的 WebSocket API 不支持设置自定义请求头(没有 headers 选项),因此无法通过 Authorization 头传 Token。Cookie 虽然会自动携带,但有安全隐患:①跨站场景下受 SameSite 限制;②容易被 **CSWSH(跨站 WebSocket 劫持)**利用——恶意网站打开到你服务器的 WebSocket 连接,浏览器自动附带用户 Cookie,攻击者就能以用户身份通信。推荐方案:URL 参数传 Tokenwss://example.com/chat?token=xxx),服务端在握手阶段验证 Token 有效性,无效则拒绝连接。注意 URL Token 可能被日志记录,应使用短期或一次性 Token。另一种方案是连接建立后通过首条消息传 Token,服务端在认证前拒绝其他消息。

5. WebSocket 与 SSE(Server-Sent Events)如何选型?各自的优劣是什么?

SSE 是基于 HTTP 的单向推送协议,服务器通过持久的 HTTP 连接持续发送事件流。优势:①浏览器原生支持自动重连和 Last-Event-ID 恢复;②基于 HTTP,对代理/防火墙友好;③API 极简(3 行代码)。劣势:①只支持服务器→客户端的单向通信;②只支持 UTF-8 文本;③IE 不支持。WebSocket 支持全双工双向通信和二进制数据,适合需要客户端频繁发消息的场景。选型原则:只需服务器推送(通知、行情、日志流)选 SSE;需要双向通信(聊天、游戏、协同编辑)选 WebSocket。如果对兼容性和简单性要求高,且不需要客户端发消息,SSE 几乎总是更好的选择。


追问思考

  1. WebSocket 握手时的 Sec-WebSocket-KeySec-WebSocket-Accept 的设计目的是什么?它能防止什么攻击?为什么说它不提供安全保障?
  2. WebSocket 帧中为什么要求客户端→服务器的数据必须掩码(Masking),而服务器→客户端不需要?这个设计解决了什么问题?
  3. 在微服务架构中,WebSocket 连接如何处理负载均衡?如果用户被分配到不同的 WebSocket 服务器实例,消息如何路由?
  4. WebSocket 的 permessage-deflate 扩展是什么?它如何压缩数据?什么时候应该开启,什么时候不应该?
  5. 如果在一个大规模系统中需要支持百万级 WebSocket 长连接,从服务端架构角度需要考虑哪些问题(连接数限制、内存、消息广播效率)?

用心学习,用代码说话 💻