项目实战 # 后端 # 网络通信已发布

WebSocket 技术详解:从通信协议原理到 Node.js 实战

在传统的 Web 应用中,HTTP 是最常见的通信协议。客户端发送请求,服务端处理请求并返回响应,这种模式非常适合查询数据、提交表单和调用业务接口...

2025.09.2126 分钟阅读
WebSocket 技术详解:从通信协议原理到 Node.js 实战
WebSocket 技术详解:从通信协议原理到 Node.js 实战 · Cover

在传统的 Web 应用中,HTTP 是最常见的通信协议。客户端发送请求,服务端处理请求并返回响应,这种模式非常适合查询数据、提交表单和调用业务接口。

但随着实时通信需求越来越多,传统 HTTP 请求响应模式逐渐显现出局限性。例如,聊天应用需要实时接收消息,在线游戏需要频繁同步状态,管理后台需要实时展示任务进度,监控系统需要持续推送服务指标。如果所有场景都依赖客户端定时轮询,不仅会产生大量无效请求,还会增加服务端的处理压力。

WebSocket 正是为这类双向实时通信场景提供的一种解决方案。它允许客户端与服务端在建立连接后,持续通过同一条 TCP 连接双向传输数据,而不必每次通信都重新发起 HTTP 请求。

本文将从协议本身出发,逐步介绍 WebSocket 的工作原理、通信机制、工程实践,以及在 Node.js 和 NestJS 项目中的应用。

一、为什么需要 WebSocket

1. HTTP 的请求响应模式

HTTP 通常采用客户端主动发起请求、服务端返回响应的通信方式。

例如,前端查询用户信息:

GET /api/user/123 HTTP/1.1
Host: example.com

服务端返回:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "123",
  "name": "张三"
}

在这种模式下,客户端发起请求后才能获取对应的响应。如果服务端稍后产生了新的事件,例如订单状态发生变化,服务端无法直接通过这次已经结束的普通 HTTP 请求通知客户端。

为了实现近似实时的效果,常见的方式包括轮询、长轮询、Server-Sent Events(SSE)和 WebSocket。

2. 轮询:简单,但存在浪费

轮询是指客户端按照固定间隔重复发送 HTTP 请求。

例如,每隔 3 秒查询一次订单状态:

setInterval(async () => {
  const response = await fetch('/api/order/123');
  const order = await response.json();

  console.log(order.status);
}, 3000);

这种方式实现简单,但存在几个问题:

  • 即使数据没有变化,也会持续发送请求。
  • 数据更新后,客户端可能要等到下一次轮询才能发现变化。
  • 用户数量较多时,会产生大量重复请求。
  • 请求间隔越短,服务端压力通常越大。

假设有 10,000 个在线客户端,每 3 秒轮询一次,那么平均每秒就会产生约 3,333 次请求。即使没有任何业务数据变化,这些请求仍然需要经过网络、网关和应用服务。

3. 长轮询:减少无效请求,但仍然是 HTTP

长轮询会让服务端在数据尚未准备好时暂时不返回响应,而是在数据更新、超时或连接异常时结束请求。

客户端收到响应后,再发起下一次请求。

这种方式能够减少一部分无效请求,但每轮通信仍然需要新的 HTTP 请求。大量长轮询连接也会占用服务端资源,并增加连接管理的复杂度。

4. WebSocket:建立持续的双向通信通道

WebSocket 的核心思路是:先通过 HTTP 完成连接协商,然后将连接升级为 WebSocket 连接。

连接建立之后,双方都可以主动发送数据:

客户端                          服务端
   |                               |
   | -------- HTTP 握手 ----------> |
   | <------- 101 Switching ------ |
   |                               |
   | ===== WebSocket 连接建立 ===== |
   |                               |
   | -------- 发送消息 ----------> |
   | <------- 返回消息 ------------ |
   | <------- 主动推送事件 -------- |
   | -------- 发送消息 ----------> |
   |                               |
   | ===== 连接持续保持中 ======== |

与轮询相比,WebSocket 不需要为了每次业务消息重新建立 HTTP 请求,因此特别适合需要持续双向通信的应用。

需要注意的是,WebSocket 并不是所有场景下都优于 HTTP。普通 CRUD 接口、低频查询以及一次性的文件下载,通常继续使用 HTTP 就足够了。

二、WebSocket 协议的基本原理

1. WebSocket 是什么

WebSocket 是一种应用层通信协议,标准定义在 RFC 6455 中。

它运行在 TCP 之上,提供全双工通信能力。所谓全双工,就是客户端和服务端可以同时发送数据,而不是严格按照一问一答的方式进行通信。

WebSocket 的 URL 通常有两种形式:

ws://example.com/socket
wss://example.com/socket

其中:

  • ws://:普通 WebSocket 连接。
  • wss://:通过 TLS 加密的 WebSocket 连接。

可以将 ws:// 理解为与 HTTP 类似的非加密连接,将 wss:// 理解为与 HTTPS 类似的加密连接。

生产环境通常应该优先使用 wss://,尤其是通信内容包含身份凭证、用户消息或业务数据时。

2. WebSocket 与 TCP、HTTP 的关系

理解 WebSocket 时,需要区分不同协议所处的层次。

业务层
  └── 聊天消息、订单事件、实时通知
        |
应用层协议
  ├── HTTP
  └── WebSocket
        |
传输层
  └── TCP
        |
网络层
  └── IP

WebSocket 并不是 TCP 的替代品,而是建立在 TCP 之上的应用层协议。

TCP 负责可靠、有序的字节流传输;WebSocket 则在 TCP 字节流之上定义了消息边界、数据帧、控制帧和连接关闭规则。

HTTP 和 WebSocket 都属于应用层协议,但二者的通信模型不同。

HTTP 更侧重于请求与响应,WebSocket 则在连接建立之后提供持续的双向消息传输能力。

还需要注意,WebSocket 并不意味着 TCP 永远不会断开。网络切换、代理超时、服务重启、防火墙策略等,都可能导致连接中断。

3. WebSocket 握手过程

WebSocket 并不是一开始就使用专门的 WebSocket 帧进行通信。经典的 WebSocket 建连流程,是先发送 HTTP Upgrade 请求,再将连接切换为 WebSocket 协议。

例如,客户端发送以下握手请求:

GET /socket HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com

几个关键字段的含义如下:

字段作用
GET /socket请求 WebSocket 服务端点
Upgrade: websocket请求将连接升级为 WebSocket
Connection: Upgrade表明这是一次协议升级请求
Sec-WebSocket-Key客户端生成的随机 Base64 编码值
Sec-WebSocket-Version: 13指定 WebSocket 协议版本
Origin浏览器环境下用于标识发起请求的来源

服务端接受升级请求后,会返回类似下面的响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

其中 101 Switching Protocols 表示服务端同意切换协议。

Sec-WebSocket-Accept 并不是服务端随意生成的字符串,而是根据客户端提供的 Sec-WebSocket-Key,结合 WebSocket 规范中定义的固定 GUID,通过 SHA-1 和 Base64 运算得到的。

其计算过程可以表示为:

Sec-WebSocket-Accept =
  Base64(
    SHA1(
      Sec-WebSocket-Key + WebSocket GUID
    )
  )

这个机制主要用于确认服务端正确理解了 WebSocket 握手协议,并不是用户身份认证,也不是对通信数据进行加密。

握手成功之后,连接就进入 WebSocket 通信阶段。后续数据不再使用普通 HTTP 请求和响应的格式传输,而是使用 WebSocket 帧。

需要注意,以上是 RFC 6455 经典的 HTTP/1.1 Upgrade 握手形式。在 HTTP/2 中,WebSocket 可以通过 RFC 8441 定义的 Extended CONNECT 机制建立,不能简单地认为所有 WebSocket 连接都一定会出现 101 Switching Protocols。

4. 为什么握手仍然使用 HTTP

使用 HTTP 完成握手有几个实际好处:

  • 能够复用现有 Web 基础设施中的部分连接管理和代理能力。
  • 服务端可以在握手阶段检查请求路径、来源、身份凭证和权限。
  • 浏览器可以通过标准的 WebSocket API 发起连接。
  • 连接建立后再切换到更适合持续双向通信的数据传输模式。

但是,握手阶段的 HTTP 请求并不意味着后续每条 WebSocket 消息都是 HTTP 请求。协议升级成功之后,两者的数据格式和通信机制就不同了。

三、WebSocket 帧结构与通信机制

理解握手之后,还需要了解 WebSocket 如何在一条 TCP 连接中区分不同消息。

TCP 本身只提供有序的字节流,并不保留应用层消息边界。假设客户端连续发送两条消息:

hello
world

服务端从 TCP 读取数据时,可能一次读到全部内容,也可能分几次读取。TCP 不会自动告诉应用程序哪一段字节属于哪一条业务消息。

WebSocket 通过定义帧格式解决了这个问题。

1. WebSocket 帧的主要字段

一个 WebSocket 帧包含多个字段,其中比较重要的有:

字段含义
FIN标记当前帧是否为一条消息的最后一个帧
Opcode标识帧的类型
MASK表示帧的数据载荷是否经过掩码处理
Payload Length表示数据载荷长度
Masking Key对经过掩码处理的载荷进行还原所需的密钥
Payload Data实际的数据载荷

其中,Opcode 用于区分不同类型的帧。

Opcode类型作用
0x0Continuation延续前面的分片消息
0x1Text文本消息
0x2Binary二进制消息
0x8Close关闭连接
0x9Ping心跳探测
0xAPong心跳响应

文本消息通常承载 JSON 字符串,二进制消息则适合传输二进制数据。

例如,应用程序发送:

socket.send(JSON.stringify({
  type: 'chat',
  content: '你好'
}));

浏览器会将字符串作为 WebSocket 文本消息发送。服务端收到的通常是文本载荷,再由应用代码解析 JSON。

应用程序一般不需要手动拼装 WebSocket 帧,协议库会处理帧编码、解码以及底层网络读写。

2. 为什么浏览器发送的帧需要掩码

RFC 6455 规定,客户端发送到服务端的 WebSocket 帧必须使用掩码;服务端发送到客户端的帧则不能使用掩码。

掩码操作会使用一个 4 字节的 Masking Key,对载荷中的字节执行异或运算。接收方可以使用相同的密钥恢复原始内容。

需要特别说明的是:掩码不是加密。

它的主要目的是降低特定代理场景下的安全风险,避免某些中间设备将看似普通的 WebSocket 数据错误地解释成其他协议内容。

任何能够获得相关数据和掩码密钥的一方,都可以恢复原始内容。因此,涉及敏感数据时应该使用 wss://,而不是依赖掩码保护数据。

3. 分片与消息边界

WebSocket 支持将一条较大的消息拆分成多个帧传输。

例如:

第一帧:FIN = 0,Opcode = Text
第二帧:FIN = 0,Opcode = Continuation
第三帧:FIN = 1,Opcode = Continuation

当接收方收到最后一个分片时,才能确定这条消息已经结束。

但在普通业务开发中,开发者通常只需要关心完整消息,不必直接操作帧。WebSocket 库会负责处理分片与消息重组。

此外,Ping、Pong 和 Close 等控制帧有各自的协议规则,并且可以在分片消息传输过程中出现。

四、连接管理:心跳、断线与重连

WebSocket 的一个重要特点是连接会持续存在。但持续连接也带来了新的问题:服务端如何判断连接是否还有效?网络中断之后如何恢复?客户端退出时又应该如何清理资源?

1. 为什么需要心跳

TCP 连接在某些异常场景下可能不会立即向应用程序报告断开。例如,客户端设备突然断网,服务端未必能够立刻发现。

如果服务端一直认为连接有效,就可能持续占用连接对象、用户会话和其他相关资源。

WebSocket 定义了 Ping 和 Pong 控制帧,可以用于检测连接状态。

典型流程如下:

服务端                         客户端
  |                              |
  | -------- Ping --------------> |
  | <------- Pong --------------- |
  |                              |
  | -------- Ping --------------> |
  | <------- Pong --------------- |
  |                              |

服务端发送 Ping 后,如果在合理时间内没有收到对应的 Pong,就可以认为连接可能已经失效,并执行清理操作。

需要注意,浏览器原生 WebSocket API 不提供直接发送协议级 Ping 帧的方法。浏览器应用如果需要自行实现心跳,通常会发送普通业务文本消息,例如:

{
  "type": "heartbeat"
}

服务端收到后,再返回一条普通的 JSON 响应。

这与协议级 Ping/Pong 是两种不同机制。使用 Node.js 的 WebSocket 服务端库时,可以通过库提供的底层接口处理真正的 Ping/Pong 控制帧。

2. 服务端心跳实践

下面以 Node.js 常用的 ws 库为例,实现一个基础的服务端心跳机制。

import { WebSocketServer, WebSocket } from 'ws';

const wss = new WebSocketServer({
  port: 3001,
});

function heartbeat() {
  this.isAlive = true;
}

wss.on('connection', (socket) => {
  socket.isAlive = true;
  socket.on('pong', heartbeat);

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

const timer = setInterval(() => {
  for (const socket of wss.clients) {
    if (socket.isAlive === false) {
      socket.terminate();
      continue;
    }

    socket.isAlive = false;
    socket.ping();
  }
}, 30_000);

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

这段代码的逻辑是:

  1. 新连接建立时,将 isAlive 设置为 true。
  2. 每隔 30 秒发送一次 Ping。
  3. 发送 Ping 前,将状态设置为 false。
  4. 收到 Pong 后,重新将状态设置为 true。
  5. 下一次检查时,如果仍然没有收到 Pong,就终止连接。

这里的 30 秒只是示例值。实际间隔和超时时间需要结合负载均衡器、反向代理、客户端网络情况以及服务端负载确定。

生产环境还需要在服务关闭时清理定时器,并考虑服务进程优雅退出、连接上限和心跳任务的资源消耗。

3. 客户端断线重连

客户端断线后,通常需要重新建立连接。

但是,不应该在连接失败后立即无限重试。否则,当服务端发生故障时,所有客户端可能同时发起大量重连请求,进一步加重服务端压力。

常见的解决方案是指数退避。

let socket;
let reconnectAttempts = 0;
let reconnectTimer;

const MAX_DELAY = 30_000;

function connect() {
  socket = new WebSocket('wss://example.com/socket');

  socket.onopen = () => {
    reconnectAttempts = 0;
    console.log('WebSocket 已连接');
  };

  socket.onmessage = (event) => {
    console.log('收到消息:', event.data);
  };

  socket.onclose = () => {
    scheduleReconnect();
  };

  socket.onerror = (error) => {
    console.error('WebSocket 错误:', error);
    // 浏览器中通常还会触发 close,
    // 由 close 事件统一安排重连。
  };
}

function scheduleReconnect() {
  clearTimeout(reconnectTimer);

  const baseDelay = 1000;
  const exponentialDelay = Math.min(
    baseDelay * 2 ** reconnectAttempts,
    MAX_DELAY
  );

  // 增加随机抖动,避免大量客户端同时重连
  const delay = exponentialDelay * (0.5 + Math.random());

  reconnectAttempts += 1;

  reconnectTimer = setTimeout(() => {
    connect();
  }, delay);
}

connect();

这段代码展示了基本思路,但还需要根据业务完善:

  • 页面关闭或用户主动退出时,停止自动重连。
  • 区分正常关闭和异常关闭,避免不必要的无限重试。
  • 网络恢复后,允许重新连接。
  • 重新连接成功后,恢复必要的订阅。
  • 对重要消息实现确认、重放或补偿机制。

还要注意,WebSocket 连接重建后,并不会自动恢复之前的业务状态。连接丢失期间的消息也不会因为重连而自动补发。

如果业务不能丢消息,就需要设计应用层的消息 ID、序列号、确认机制和离线消息存储,而不能仅仅依赖 WebSocket 本身。

五、浏览器端通信实践

先从一个最小可用的浏览器客户端开始。

const socket = new WebSocket(
  'ws://localhost:3001'
);

socket.addEventListener('open', () => {
  console.log('连接成功');

  socket.send(JSON.stringify({
    type: 'hello',
    content: '你好,服务端',
  }));
});

socket.addEventListener('message', (event) => {
  console.log('收到服务端消息:', event.data);
});

socket.addEventListener('error', (event) => {
  console.error('连接出现错误:', event);
});

socket.addEventListener('close', (event) => {
  console.log('连接关闭:', {
    code: event.code,
    reason: event.reason,
    wasClean: event.wasClean,
  });
});

浏览器原生 WebSocket API 提供了几个核心事件:

事件说明
open连接建立成功
message收到服务端消息
error连接发生错误
close连接关闭

发送消息前,还应该检查连接状态:

if (socket.readyState === WebSocket.OPEN) {
  socket.send(JSON.stringify({
    type: 'chat',
    content: '这是一条消息',
  }));
}

readyState 常见状态包括:

  • CONNECTING:正在连接。
  • OPEN:连接已经建立。
  • CLOSING:正在关闭。
  • CLOSED:连接已关闭。

如果在连接尚未建立时发送消息,浏览器可能抛出异常。因此,实际项目通常会封装连接状态、待发送队列、重连逻辑和消息分发器,而不是让每个业务组件直接管理 WebSocket 实例。

六、Node.js 使用 ws 实现 WebSocket 服务端

在 Node.js 后端,可以使用 ws 这个轻量级 WebSocket 库。

安装依赖:

npm install ws
npm install -D @types/ws

下面实现一个简单的服务端,支持连接、接收 JSON 消息和返回响应。

import { WebSocketServer, WebSocket } from 'ws';

const wss = new WebSocketServer({
  port: 3001,
});

wss.on('connection', (socket, request) => {
  console.log(
    '客户端已连接:',
    request.socket.remoteAddress
  );

  socket.send(JSON.stringify({
    type: 'connected',
    message: '连接成功',
  }));

  socket.on('message', (data, isBinary) => {
    if (isBinary) {
      console.log('收到二进制消息:', data.length);
      return;
    }

    const raw = data.toString();

    let message;

    try {
      message = JSON.parse(raw);
    } catch {
      socket.send(JSON.stringify({
        type: 'error',
        message: '消息格式必须是合法 JSON',
      }));
      return;
    }

    console.log('收到客户端消息:', message);

    if (message.type === 'hello') {
      socket.send(JSON.stringify({
        type: 'reply',
        message: `服务端已收到:${message.content ?? ''}`,
      }));
    }
  });

  socket.on('close', (code, reason) => {
    console.log(
      '客户端断开:',
      code,
      reason.toString()
    );
  });

  socket.on('error', (error) => {
    console.error('WebSocket 错误:', error);
  });
});

console.log('WebSocket 服务已启动: ws://localhost:3001');

启动服务后,可以使用前面的浏览器代码进行测试。

这只是一个基础示例。正式环境还应该增加请求认证、消息大小限制、频率限制、异常处理和连接资源清理。

1. 实现服务端广播

实时通知、在线状态和监控数据通常需要向多个客户端推送消息。

可以遍历 wss.clients,向当前所有已连接的客户端广播:

function broadcast(data) {
  const payload = JSON.stringify(data);

  for (const client of wss.clients) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(payload);
    }
  }
}

例如:

broadcast({
  type: 'system_notice',
  message: '系统将在 5 分钟后维护',
});

但广播只是最基础的实现。随着业务复杂度增加,通常需要按照用户、组织、项目、房间或订阅主题进行定向推送,而不是把所有消息发给所有连接。

七、在 NestJS 中集成 WebSocket

如果项目本身使用 NestJS,可以使用官方 WebSocket 网关机制。它把 WebSocket 事件处理与 NestJS 的模块、依赖注入、守卫和业务服务体系结合起来。

安装依赖:

npm install @nestjs/websockets @nestjs/platform-socket.io
npm install socket.io

需要区分一个概念:NestJS Gateway 默认常见的 Socket.IO 方案,并不等同于直接使用原生 WebSocket 协议库。

Socket.IO 是建立在底层实时传输能力之上的通信库,拥有自己的事件协议、连接管理和重连机制。它通常会使用 WebSocket 作为传输方式,但也可能使用 HTTP 长轮询等传输方式。因此,原生 WebSocket 客户端不能直接当作 Socket.IO 客户端使用,反过来也一样。

1. 创建 Gateway

创建一个网关:

// chat.gateway.ts

import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';

import { Server, Socket } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: ['http://localhost:5173'],
  },
})
export class ChatGateway {
  @WebSocketServer()
  server: Server;

  handleConnection(client: Socket) {
    console.log('客户端连接:', client.id);

    client.emit('connected', {
      message: '连接成功',
    });
  }

  handleDisconnect(client: Socket) {
    console.log('客户端断开:', client.id);
  }

  @SubscribeMessage('chat')
  handleChat(
    @MessageBody() data: { content: string },
    @ConnectedSocket() client: Socket,
  ) {
    return {
      event: 'chatReply',
      data: {
        clientId: client.id,
        content: `服务端收到:${data.content}`,
      },
    };
  }
}

在模块中注册网关:

// chat.module.ts

import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';

@Module({
  providers: [ChatGateway],
})
export class ChatModule {}

然后将 ChatModule 引入应用模块即可。

这里的 @SubscribeMessage('chat') 表示监听名为 chat 的 Socket.IO 事件。

客户端发送:

socket.emit('chat', {
  content: '你好',
});

网关收到后,返回 chatReply 事件。

以上示例采用 NestJS 常见的 Socket.IO 集成方式。不同 NestJS 版本的依赖要求可能有所不同,应以项目实际版本和官方文档为准。

2. 如何选择原生 WebSocket 与 Socket.IO

如果需要严格使用标准 WebSocket 协议,可以使用 ws,或者在 NestJS 中选择支持原生 WebSocket 的适配器。

如果希望直接使用 NestJS Gateway、房间、事件名、自动重连等功能,Socket.IO 往往更加方便。

简单来说:

  • 原生 WebSocket:协议简单、控制力强,适合自定义通信协议和跨语言服务间通信。
  • Socket.IO:开发体验更完整,适合需要事件模型、房间和自动重连的业务系统。

选择时需要考虑客户端类型、协议兼容性、部署方式以及是否需要 Socket.IO 特有的能力。

八、WebSocket 在生产环境中的设计

本地跑通连接只是第一步。真正上线后,更重要的是连接管理、权限控制、消息可靠性和横向扩展。

1. 身份认证与权限校验

WebSocket 连接建立之后会持续存在,因此需要在握手阶段或连接建立后完成身份认证。

浏览器原生 WebSocket 构造函数不能像 fetch 一样直接设置任意请求头,所以浏览器端的认证方式需要特别设计。

常见方案包括:

  • 使用现有登录 Cookie,并在服务端校验会话。
  • 使用短期有效的连接凭证,在建立连接时验证。
  • 建立连接后,通过第一条应用层消息提交凭证,再完成身份认证。

如果采用 Cookie 认证,必须正确校验 Origin,防止恶意网站利用浏览器自动携带的 Cookie 发起跨站 WebSocket 连接。仅依赖 CORS 配置不能替代完整的 WebSocket 来源校验。

如果采用短期令牌,也要避免将长期访问令牌直接放在 URL 查询参数中,因为 URL 可能被代理日志、监控系统或其他基础设施记录。

完成身份认证之后,还必须验证每条业务消息对应的操作权限。例如,用户成功连接,并不代表他有权订阅任意组织的消息或读取任意订单。

2. 消息格式与协议设计

实际业务中,不建议让所有消息都只包含一段没有结构的字符串。

可以统一定义消息格式:

{
  "type": "chat",
  "requestId": "req-001",
  "timestamp": 1791500000000,
  "data": {
    "content": "你好"
  }
}

字段含义:

字段作用
type消息类型,用于分发业务逻辑
requestId请求标识,用于关联请求和响应
timestamp消息生成时间
data业务数据

服务端可以根据 type 分发消息:

switch (message.type) {
  case 'chat':
    // 处理聊天消息
    break;

  case 'subscribe':
    // 处理订阅请求
    break;

  case 'heartbeat':
    // 处理应用层心跳
    break;

  default:
    // 拒绝未知消息类型
    break;
}

对于大型项目,可以进一步设计消息版本、错误码、消息确认和幂等处理机制。

不过,requestId 和 timestamp 本身并不会自动提供可靠投递能力。如果业务需要可靠性,还需要服务端持久化消息、确认状态和重放规则等机制。

3. 消息可靠性:连接成功不代表消息一定被处理

WebSocket 基于 TCP,因此在连接正常时,底层能够提供有序的字节流传输。但 TCP 的可靠性不能直接等同于业务消息的可靠处理。

例如:

  1. 客户端发送一条创建订单的消息。
  2. 服务端成功处理并创建订单。
  3. 服务端发送响应之前,网络突然断开。
  4. 客户端无法判断订单是否已经创建成功。
  5. 客户端重连后再次发送消息,可能重复创建订单。

这种问题无法仅靠 WebSocket 解决。

常见方案包括:

  • 为业务请求设置唯一的 requestId 或幂等键。
  • 服务端记录已经处理的请求,避免重复执行。
  • 重要消息使用持久化队列或数据库保存。
  • 对关键消息实现 ACK 确认机制。
  • 重连之后根据消息序列号补拉遗漏事件。

例如,服务端可以为每条事件分配递增的序列号:

{
  "type": "order.updated",
  "sequence": 1024,
  "data": {
    "orderId": "order-123",
    "status": "paid"
  }
}

客户端记录已经处理到的序列号。重新连接后,可以向服务端查询 1024 之后是否有遗漏的事件。

如果业务要求消息不能丢失,通常需要将持久化消息存储与 WebSocket 推送结合起来。WebSocket 负责实时通知,数据库或消息系统负责保存可恢复的业务状态。

4. 服务端如何管理大量连接

WebSocket 适合持续连接,但每条连接仍然需要消耗一定的内存、文件描述符和其他资源。

连接数量增加之后,服务端需要考虑:

  • 操作系统文件描述符上限。
  • 每条连接的内存占用。
  • 心跳和定时器的调度成本。
  • 消息发送速度与客户端接收速度之间的差异。
  • 慢客户端导致的发送缓冲区积压。
  • 连接数和消息速率的监控。

尤其要注意背压问题:如果服务端持续向某个客户端发送数据,而客户端处理速度很慢,发送队列就可能不断增长。

因此,应当限制消息大小、检查缓冲区积压、限制单连接发送速率,并根据业务重要程度选择丢弃、合并或持久化消息。

对于高频实时指标,可以只保留最新状态;对于聊天消息、支付结果等重要事件,则通常不能简单地静默丢弃。

5. 多实例部署与 Redis、Kafka

单实例 WebSocket 服务通常可以直接维护本地连接集合。但在生产环境中,服务端往往会部署多个实例:

客户端 A ──> WebSocket 服务 1
客户端 B ──> WebSocket 服务 2
客户端 C ──> WebSocket 服务 3

假设用户 A 连接到服务 1,而某个业务事件由服务 2 处理。如果服务 2 直接遍历自己的本地连接集合,就无法向服务 1 上的用户 A 推送消息。

这时就需要引入跨实例的消息分发机制。

一种常见架构是:

业务服务
   |
   v
Redis / Kafka
   |
   +------> WebSocket 服务 1 ----> 客户端 A
   |
   +------> WebSocket 服务 2 ----> 客户端 B
   |
   +------> WebSocket 服务 3 ----> 客户端 C

Redis 和 Kafka 都可以参与实时消息架构,但它们的定位并不完全相同。

Redis Pub/Sub 适合低延迟的实时分发。它不负责持久化离线消息,订阅者断开期间的消息通常无法补收。因此,它适合允许丢失部分实时通知、可以通过其他方式恢复状态的场景。

Kafka 更适合需要持久化、消费组、消息重放和流式处理的业务。它可以保存事件,供消费者在故障恢复后继续处理。不过,Kafka 消费到消息之后,仍然需要 WebSocket 服务根据用户连接关系进行路由,Kafka 并不会自动知道某个用户连接在哪个实例上。

在 NestJS 项目中,可以让业务模块通过 Kafka 发布事件,再由各 WebSocket 实例订阅事件并向本地连接推送。但需要设计好用户路由、消费组策略、事件去重和离线补偿,避免所有实例重复广播或某些实例收不到所需事件。

如果项目已经使用 Redis、BullMQ 和 Kafka,可以根据业务特性分工:队列处理异步任务,Kafka 保存并分发业务事件,WebSocket 负责向在线客户端实时推送。它们可以协作,但不是互相替代的关系。

6. 反向代理与负载均衡

生产环境通常不会让浏览器直接连接 Node.js 进程,而是通过 Nginx、Caddy 或云负载均衡器接入。

代理需要正确支持 WebSocket 协议升级,并设置合理的连接超时时间。

例如,Nginx 的基础配置可以写成:

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

server {
    listen 443 ssl;
    server_name example.com;

    location /socket {
        proxy_pass http://127.0.0.1:3001;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_set_header Host $host;
        proxy_read_timeout 75s;
        proxy_send_timeout 75s;
    }
}

这只是基础示例,实际生产环境还需要配置 TLS 证书、可信来源、访问控制、日志和资源限制。

proxy_read_timeout 等超时参数需要结合心跳间隔设置。如果代理超时时间过短,而连接长时间没有任何数据传输,就可能被代理提前关闭。

同时,多实例部署时需要考虑负载均衡和连接粘性。WebSocket 连接建立之后,通常会由同一个后端实例持续处理该连接,但这并不意味着所有实例之间自动共享会话和业务状态。

九、WebSocket、SSE、HTTP 与消息队列的区别

这些技术经常一起出现在实时系统中,但它们解决的问题不同。

技术通信方式主要特点典型场景
HTTP请求响应简单、通用、适合普通接口CRUD、查询、提交表单
轮询客户端重复请求实现简单,但存在无效请求低频状态查询
SSE服务端向客户端推送基于 HTTP,支持事件流和自动重连通知、日志流、AI 文本生成
WebSocket双向通信持续连接,双方均可主动发送消息聊天、在线协作、游戏
Redis Pub/Sub发布订阅延迟低,但不提供离线消息持久化实例间实时通知
Kafka持久化消息流支持消费组、消息保留与重放业务事件、异步处理、数据管道

选择技术时,不应只关注延迟或连接方式,还要考虑消息可靠性、部署复杂度和业务方向。

如果只需要服务端持续向浏览器输出内容,例如 AI 模型生成文本,SSE 通常已经足够。如果客户端和服务端都需要频繁发送消息,WebSocket 更合适。如果还需要跨实例传递业务事件,可能需要 Redis 或 Kafka 等消息基础设施配合。

十、常见问题与排查思路

1. 握手返回 401、403 或 404

可能原因包括:

  • 身份凭证无效。
  • 服务端没有正确处理 WebSocket 升级请求。
  • 请求路径配置错误。
  • 反向代理没有正确转发连接升级。
  • Origin 校验不通过。
  • 网关或鉴权中间件拒绝了连接。

建议先在浏览器开发者工具的 Network 面板中检查 WebSocket 请求和握手响应,再查看代理及服务端日志。

2. 连接成功,但服务端收不到消息

检查客户端是否在 OPEN 状态下发送数据,服务端是否监听正确的消息事件,以及客户端和服务端使用的协议是否一致。

例如,原生 WebSocket 客户端不能直接连接到只接受 Socket.IO 协议的服务端。两者都可能使用 WebSocket 传输,但应用层协议不同。

3. 连接过一段时间后自动断开

常见原因包括:

  • 代理或负载均衡器的空闲超时。
  • 心跳没有正确执行。
  • 客户端网络切换。
  • 服务端重启或资源不足。
  • 网络设备主动清理空闲连接。

可以通过检查关闭码、服务端日志、心跳记录以及代理超时配置定位问题。

4. 重连之后收不到之前的消息

WebSocket 本身不提供自动离线消息补发。需要根据业务要求设计消息持久化、序列号、ACK、重放或状态补偿机制。

如果消息只是用于显示当前在线人数,重新查询一次当前状态可能就够了。如果消息涉及支付、订单或任务状态,则应考虑更可靠的事件恢复方案。

总结

WebSocket 的核心价值,是在 TCP 连接之上提供持续的双向通信能力。它通过 HTTP 握手完成协议协商,随后使用 WebSocket 帧承载文本、二进制数据以及 Ping、Pong 和 Close 等控制信息。

在开发过程中,掌握基本的连接和消息收发只是入门。要构建稳定的实时系统,还需要关注身份认证、心跳检测、断线重连、消息可靠性、连接背压和多实例部署。

对于 Node.js 开发者,可以先使用 ws 理解原生 WebSocket 协议,再根据项目需要使用 NestJS Gateway 和 Socket.IO 等工具提高开发效率。

最后需要记住:WebSocket 解决的是实时通信通道的问题,并不自动解决消息持久化、业务幂等、跨实例路由和可靠投递问题。将 WebSocket 与数据库、Redis 或 Kafka 等基础设施合理组合,才能构建真正适合生产环境的实时通信系统。

#后端 #网络通信
ZZC

ZZC

写代码,也写一些生活。