做全栈这五年,我经手过不少实时交互的需求。从最开始的管理后台通知,到后来的低代码协同画布,再到这两年常见的 AI 流式输出。
很多初学者一听到“实时通信”,第一反应往往是直接上 Socket.io 或者原生 WebSocket。但在真实工程场景里,技术选型从来不是看谁的协议更底层,而是看场景的匹配度与维护成本。
今天把前后端实时通信的这套工程逻辑拆开聊聊,谈谈选型取舍,也给出一套可落地的轻量实现方案。
一、 技术选型:没有银弹,只有权衡
实时通信主要有四种手段:短轮询、长轮询、Server-Sent Events (SSE) 和 WebSocket。
- 短/长轮询 (Polling / Long Polling) 本质上还是普通的 HTTP 请求。适合请求频率极低、或者历史包袱重到无法改动基础设施的场景。它的缺点很明显:头部开销大、服务端并发压力大。通常只作为降级方案。
- Server-Sent Events (SSE) 基于 HTTP 协议的长连接,只支持服务端向客户端单向推送。 如果你做的是监控大盘、系统通知,或是 ChatGPT 类的流式响应,SSE 是极好的选择。它原生支持断线重连,走的是标准 HTTP/HTTPS 端口,能完美穿透大部分代理和防火墙,省去了维护长连接协议升级的心智负担。
- WebSocket 在单个 TCP 连接上提供全双工通信。 适合真正的双向高频交互,比如多人协同编辑、实时对战或即时通讯。它的优势是消息帧开销极小、延迟低,但代价是后端必须维护有状态的连接,且水平扩容时架构复杂度会陡增。
我的建议是:单向推送优先考虑 SSE,双向交互再上 WebSocket。
二、 全栈落地:轻量双向通信实践
这里以典型的双向通信场景为例,后端采用 Node.js(基于 ws 库),前端做一层具备心跳保活和自动重连的封装。
1. 后端实现:连接管理与心跳检测
后端最怕的是僵尸连接(Dead Connections)占用系统句柄。靠 TCP 本身的 Keep-Alive 周期太长,应用层必须做自己的心跳机制。
// server.ts
import { WebSocketServer, WebSocket } from 'ws';
interface ExtWebSocket extends WebSocket {
isAlive: boolean;
userId?: string;
}
const wss = new WebSocketServer({ port: 8080 });
// 30 秒一次的心跳检查
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
const extWs = ws as ExtWebSocket;
if (!extWs.isAlive) {
extWs.terminate();
return;
}
extWs.isAlive = false;
extWs.ping();
});
}, 30000);
wss.on('connection', (ws: ExtWebSocket, req) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data: string) => {
try {
const payload = JSON.parse(data.toString());
handleMessage(ws, payload);
} catch (err) {
console.error('Payload parse error:', err);
}
});
ws.on('close', () => {
// 处理清理逻辑,比如通知协同房间其他人
});
});
wss.on('close', () => {
clearInterval(interval);
});
function handleMessage(ws: ExtWebSocket, payload: any) {
// 简易的消息分发逻辑
if (payload.type === 'chat') {
// 广播给其他在线客户端
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(payload));
}
});
}
}
2. 前端封装:重连退避与消息队列
前端如果直接用裸的 new WebSocket(),在弱网环境下体验会非常糟糕。生产环境里,前端重点要解决两件事:断线后的指数退避重连,以及未连上时的消息缓存。
// client.ts
type MessageHandler = (data: any) => void;
export class RealtimeClient {
private url: string;
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private baseDelay = 1000;
private handlers: Set<MessageHandler> = new Set();
private messageQueue: string[] = [];
constructor(url: string) {
this.url = url;
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.flushQueue();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handlers.forEach((handler) => handler(data));
} catch (e) {
console.error('Failed to parse message', e);
}
};
this.ws.onclose = () => {
this.handleReconnect();
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
this.ws?.close();
};
}
private handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn('Max reconnect attempts reached.');
return;
}
// 指数退避算法 + 随机抖动,防止服务端雪崩
const delay = Math.min(
this.baseDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 500,
10000
);
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, delay);
}
public send(payload: any) {
const raw = JSON.stringify(payload);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(raw);
} else {
this.messageQueue.push(raw);
}
}
private flushQueue() {
while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
const msg = this.messageQueue.shift();
if (msg) this.ws.send(msg);
}
}
public subscribe(handler: MessageHandler) {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
}
三、 生产环境必须面对的现实问题
上面这套代码在单机环境下跑得很好,但一旦放到真实的生产集群,有几个坎必须迈过去:
1. 水平扩容与多实例广播
单台服务器能撑的 TCP 连接数是有限的,后端早晚要上多实例负载均衡。 一旦上了 Nginx 转发到多台机器,用户 A 连在 Server 1,用户 B 连在 Server 2,A 发的消息 B 就收不到了。 解决这个问题的标准做法是引入发布/订阅(Pub/Sub)中间件,最常见的就是 Redis。所有服务端实例订阅同一个 Redis 通道,任何一台机器收到消息就发布到 Redis,由 Redis 分发给所有实例,再各自推给对应的客户端。
2. 网关超时与长连接反代
如果你前面挂了 Nginx 或云厂商的 API Gateway,默认的 HTTP 超时时间(比如 60 秒)会掐断闲置的 WebSocket 连接。
- 在 Nginx 配置中,必须显式升级协议头:
nginx
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; # 根据业务需要调大 - 即便调大了超时时间,也别省掉业务层的心跳。网络链路里的路由器、NAT 网关可能会在静默期直接丢弃路由表。
3. 消息可靠性:要不要做 ACK
WebSocket 只保证传输层可靠(TCP),不保证业务层可靠。
如果你在做一个金融交易系统或核心客服系统,光 ws.send() 是不够的,需要设计类似 TCP 的确认机制:前端发送带 msgId 的包,后端落库后回一个 ack,如果在指定时间内没收到 ack,前端重新发,后端做幂等去重。
但对于普通的看板推送、鼠标指针同步等弱一致性场景,就没必要加这层逻辑,直接丢弃即可。
写在最后
全栈开发做得越久,越会明白一件事:能用简单的方案解决问题,就不要把系统做复杂。
如果产品经理只是要一个简单的“任务完成弹窗”,一个 10 秒拉一次的轮询或者 SSE 足够好用,发布部署省心省力;如果确实需要强实时的双向通信,再去设计 WebSocket、心跳、重连与分布式广播。
把系统的复杂度压在刚好满足业务边界的位置,是每个工程师都需要反复权衡的修行。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


