在传统的 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 | 类型 | 作用 |
|---|---|---|
0x0 | Continuation | 延续前面的分片消息 |
0x1 | Text | 文本消息 |
0x2 | Binary | 二进制消息 |
0x8 | Close | 关闭连接 |
0x9 | Ping | 心跳探测 |
0xA | Pong | 心跳响应 |
文本消息通常承载 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);
});
这段代码的逻辑是:
- 新连接建立时,将
isAlive设置为true。 - 每隔 30 秒发送一次 Ping。
- 发送 Ping 前,将状态设置为
false。 - 收到 Pong 后,重新将状态设置为
true。 - 下一次检查时,如果仍然没有收到 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 的可靠性不能直接等同于业务消息的可靠处理。
例如:
- 客户端发送一条创建订单的消息。
- 服务端成功处理并创建订单。
- 服务端发送响应之前,网络突然断开。
- 客户端无法判断订单是否已经创建成功。
- 客户端重连后再次发送消息,可能重复创建订单。
这种问题无法仅靠 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 等基础设施合理组合,才能构建真正适合生产环境的实时通信系统。
