要做什么:一个人动,所有人几乎同时看到
多人实时协作(共同编辑文档、看板拖拽、共享光标)的核心诉求是低延迟双向推送:HTTP 轮询在这个场景里既慢又浪费。这篇用 ws 库从零写服务端——连接管理、心跳保活、房间广播,最后诚实地讨论横向扩展时最容易被忽略的坑。
| 指标 | 目标值 | 怎么测 |
|---|---|---|
| 死连接清理 | 客户端异常掉线后 60 秒内被服务端剔除 | 用防火墙丢包模拟半开连接,观察在线数变化 |
| 广播延迟 | 同机房 P99 小于 50ms(估算口径,未实测) | 消息带时间戳,接收端计算差值分布 |
| 房间隔离 | A 房间的消息绝不出现在 B 房间 | 两个房间各连一个客户端交叉验证 |
| 消息有序 | 同一连接发出的消息按序到达 | 客户端带 seq 序号,服务端与对端校验连续性 |
选型:ws 还是 socket.io
| 维度 | ws | socket.io |
|---|---|---|
| 定位 | 薄封装:WebSocket 协议本身加少量便利 | 在 WebSocket(必要时降级轮询)上再造一层协议 |
| 重连/房间/心跳 | 自己写(本文就写这些) | 内建 |
| 客户端 | 标准浏览器 WebSocket API 即可 | 必须配套 socket.io-client |
| 适合 | 明确自己要什么、依赖最少 | 快速起步、兼容老环境 |
结论:现代浏览器对 WebSocket 的支持早已不是问题,”降级到轮询”的需求基本消失;房间和重连自己写也就百来行,换来对每个字节的理解。本文选 ws;赶原型用 socket.io 完全成立,两者切换成本不高。
与 HTTP 服务共存:upgrade 只发生一次
WebSocket 连接从一次 HTTP 请求开始:客户端发带 Upgrade: websocket 的请求,服务端回 101 切换协议。ws 可以独占端口,也可以挂在已有的 http.Server 上——协作服务通常和页面接口同域,选后者:
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
const server = createServer(); // HTTP 部分(页面、REST API)照常
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname !== '/ws') { socket.destroy(); return; } // 不认识的路径直接断
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
server.listen(8080);
HTTP 侧的路由、校验分层见《Node.js HTTP 与路由》——WebSocket 的”路由”只有 upgrade 时的这一次 pathname 判断,之后这条连接上跑的是自定义消息协议。
鉴权提醒:浏览器发起 WebSocket 时会自动带上同域 cookie,upgrade 请求是做身份校验最自然的位置;连上之后再想回答”这条连接是谁的”就晚了。
难点一:心跳——TCP 活着不代表连接活着
拔网线、NAT 超时、代理静默断开:这些情况下服务端收不到 close 事件,连接在服务端眼里”还活着”。半开连接积累的后果是内存泄漏和向死人广播的浪费。标准解法是应用层 ping/pong:
const INTERVAL = 30_000;
function heartbeat() { this.isAlive = true; } // 收到 pong 时标记存活
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
const timer = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; } // 上轮 ping 没等到 pong:判死
ws.isAlive = false;
ws.ping(); // 本轮探测
}
}, INTERVAL);
wss.on('close', () => clearInterval(timer));
为什么不用 TCP 层 keepalive:操作系统默认两小时才动手,且很多 NAT 网关不透传;应用层 ping/pong 的间隔由你定,30 到 60 秒是常见取值。浏览器端对服务端 ping 的 pong 响应是协议层自动的,不经过页面脚本,正好省心。
客户端侧的重连要自己做退避,否则服务端重启的瞬间所有客户端同时砸回来(重连风暴):
function connect(attempt = 0) {
const ws = new WebSocket('wss://example.com/ws');
ws.onopen = () => { attempt = 0; }; // 成功后重置退避
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** attempt, 30_000)
+ Math.random() * 1000; // 随机抖动,错开重连时刻
setTimeout(() => connect(attempt + 1), delay);
};
return ws;
}
反模式一:只做服务端心跳不做客户端重连(或反之)。心跳解决”服务端发现死连接”,重连解决”客户端恢复会话”,是两个问题,漏了任何一半,用户的体验都是”偶尔要刷新页面才正常”。
难点二:房间广播——别让一个慢客户端拖垮全场
const rooms = new Map(); // roomId -> Set<ws>
function join(roomId, ws) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.roomId = roomId;
}
function leave(ws) {
const set = rooms.get(ws.roomId);
if (set) {
set.delete(ws);
if (set.size === 0) rooms.delete(ws.roomId); // 空房间及时回收
}
}
wss.on('connection', (ws) => {
ws.on('message', (data) => {
let msg;
try { msg = JSON.parse(data); } // 畸形消息不允许炸掉进程
catch { return; }
if (msg.type === 'join') return join(String(msg.room), ws);
if (msg.type === 'edit') broadcast(ws.roomId, { type: 'edit', from: ws.id, seq: msg.seq, body: msg.body });
});
ws.on('close', () => leave(ws));
});
function broadcast(roomId, msg) {
const text = JSON.stringify(msg);
for (const client of rooms.get(roomId) ?? []) {
if (client.readyState !== 1) continue; // 1 = OPEN
client.send(text); // send 只入本地缓冲区,不等对端
}
}
消息入口必须有校验:JSON.parse 可能抛异常,msg.room 可能是任意字符串——信任客户端输入的进程,一条畸形消息就能打穿。生产环境用 schema 校验库把消息形状钉死。
慢客户端问题:send 只是写入本机发送缓冲区,对端收不动时缓冲会一直涨。一个掉进弱网环境的客户端能让服务端内存缓慢上涨。检测与处置:
if (client.bufferedAmount > 1_000_000) { // 本地积压超过 1MB
client.terminate(); // 宁可踢掉让他重连补差,不当无底洞
}
反模式二:广播被最慢客户端绑架。逐个 await 或对慢连接重试发送,等于把全房间的节奏绑在弱网用户身上。广播的正确心智是”尽力而为加 seq 补差”:丢了消息的客户端重连后按序号向服务端要缺口,实时性优先于单点完整性。
消息协议:type、seq、ack 三件套
连接建立之后跑的是自定义消息协议,协议里最值得提前设计的是三个字段:type 区分消息种类,seq 是发送方维护的单调序号,ack 是对”已处理到哪”的回报。用一张小表钉死第一版协议:
| 方向 | type | 携带 | 用途 |
|---|---|---|---|
| 上行 | join | room | 加入房间(服务端按此分发) |
| 上行 | edit | seq、body | 编辑操作,seq 用于服务端去重与回放 |
| 下行 | edit | from、seq、body | 广播给房间内其他人 |
| 下行 | sync | last_seq | 重连后服务端告知的进度,客户端据此补差 |
为什么 seq 值得在第一版就有:网络层保证的是”这条连接内消息有序”,但不保证”不丢”——重连之后旧连接与新连接之间更是必然有缺口。有 seq,客户端重连后就能准确知道自己缺了哪几条;没有 seq,重连后只能全量拉一次,协作场景下全量拉取的成本随文档大小线性涨。协议字段向后加容易,向前删难——第一版多留一个 seq,比第二版硬补一个便宜得多。
横向扩展的坑:广播只到自己进程(诚实边界)
单实例 Node 进程撑万级空闲连接没有问题(定性判断,未实测;真正吃资源的是每条消息的扇出,不是连接数本身)。但只要起了第二个实例,问题立刻出现:
客户端 A 连实例 1,客户端 B 连实例 2
A 发消息 → 实例 1 广播 → 只有实例 1 上的连接收到
B 永远收不到 —— 两个进程的 rooms 是两座孤岛
这背后是两层独立的问题:
- 负载均衡层:WebSocket 是长连接,轮询式负载均衡会把重连后的连接随机分到不同实例。必须配 sticky session(源 IP 哈希或 cookie 亲和),保证同一客户端稳定落在同一实例——但这只解决”分配”,不解决”跨实例通信”。
- 进程间通信:跨实例广播需要公共信道,标准做法是 Redis pub/sub——每个实例订阅同一频道,广播时 publish 一份,各实例收到后扇出给自己名下的连接。socket.io 的 Redis adapter 就是这个方案的成品。
诚实边界:本文不实现 Redis pub/sub。单实例撑不住之前它是伪需求;真到那一天,先想想能不能按房间分片——一个房间的所有连接路由到同一实例(sticky 的另一种用法),就天然不需要跨实例广播。扩展的动机真实出现时再回来做这道题,比预先背上复杂度划算。
验证
npm install ws
node server.js
1. 另开两个终端,用 wscat 模拟两个客户端
npx wscat -c ws://localhost:8080/ws
> {"type":"join","room":"r1"}
> {"type":"edit","seq":1,"body":"hello"}
| 检查项 | 操作 | 期望 |
|---|---|---|
| 心跳清理 | 连上后用防火墙丢弃该连接的包 | 约两轮 INTERVAL 内服务端在线数减一 |
| 房间隔离 | r1、r2 各连一个客户端,r1 发消息 | r2 收不到任何东西 |
| 断线重连 | kill 服务端再重启 | 客户端按退避节奏自动重连成功 |
| 广播扇出 | 脚本开 1000 个连接进同一房间发消息 | 观察内存与 bufferedAmount(方法如上,数据未实测) |
总结
- WebSocket 服务的可靠性大头在”断线”而不是”连线”:服务端心跳、客户端退避重连、seq 补差,三件配齐才算实时
- upgrade 请求是鉴权最自然的时机,cookie 随 upgrade 一起到达,过期不候
- 广播的心智是尽力而为:慢客户端踢掉重连补差,不绑全场节奏
- 单实例万级连接不是瓶颈,跨实例广播才是——sticky session 解决分配,Redis pub/sub 解决孤岛,都在扩展动机真实出现之后再做
大消息的解析与 CPU 密集处理不要压在连接回调线程里做,《Node.js 流与 Worker》讲了怎么拆出去;协作服务长到需要工程化组织时,重架构路线看《NestJS 入门》,轻量高性能路线看《Fastify 入门》。
版本核实(以官方文档为准):写作时点(2025 年 9 月)Node.js 22 为 Active LTS,Node 24 为 Current(计划 2025 年 10 月转入 LTS,以官方发布公告为准);ws 当前稳定线为 8.18.x;socket.io 为 4.x 线;wscat 为常用调试客户端。安装命令均未钉版本号。