WebSocket 服务端工程:连接管理、鉴权与水平扩展
本文目录
把 WebSocket 接口做成"能收发消息"很容易,难的是把它放进生产环境:连接什么时候鉴权、怎么踢掉假在线的僵尸连接、广播怎么不被慢客户端拖垮、多进程部署时消息怎么跨节点路由、重启时在线用户怎么无感恢复。这篇文章按一个长连接服务的生命周期展开,每一节都是可以直接对照代码检查的实践规则。示例基于 Node.js 的 ws 库,机制对其他实现同样成立。
连接鉴权:时机决定安全模型
WebSocket 的握手是一个 HTTP Upgrade 请求,之后协议切换,浏览器不再自动带任何业务头部。鉴权时机有三个选择:
| 时机 | 方式 | 优点 | 风险 |
|---|---|---|---|
| 握手时(服务端可读 Cookie/header) | 升级请求解析 Cookie 或 Authorization 头 | 鉴权失败直接拒绝升级,未授权连接根本建立不起来 | 自定义 header 浏览器 WebSocket API 加不了,只能靠 Cookie |
| 握手时(query 参数) | wss://host/ws?token=Spxq9v... | 实现简单 | token 进访问日志与代理日志,泄漏面大 |
| 首条消息 | 连接后客户端先发 auth 消息 | 灵活,可支持多租户选择 | 存在一段"已连接未鉴权"窗口,其他消息必须排队 |
规则:Web 场景优先用 Cookie(握手时服务端可读),App 与服务间场景用 Authorization 头或首条消息 auth;query token 只用于无法带头的环境,且必须是短时效的一次性票据——即便如此也要在日志层脱敏。未鉴权窗口必须显式处理:连接后开启一个 10 秒的鉴权计时器,超时未通过就断开,通过之前丢弃一切业务消息。
服务端构造 WebSocketServer 时就要把防御性上限写进配置,而不是事后在业务层补救:
const wss = new WebSocketServer({
port: 8080,
maxPayload: 64 * 1024, // 单帧消息上限:超限连接直接断开,防巨型帧打爆内存
perMessageDeflate: false, // 关闭 permessage-deflate 压缩:省 CPU,多数场景延迟更差不开
backlog: 128, // 等待完成握手的队列长度上限
});
maxPayload 是第一道资源防线:客户端 bug 或恶意客户端发一个 1 GiB 帧,没有上限时服务端会先把它完整收下来再解析。压缩扩展(permessage-deflate)在广播场景把单次压缩变 N 次,CPU 收益通常为负,关闭是合理默认。
回到鉴权窗口本身,"首条消息 auth"的完整形态如下——注意三个硬规则:鉴权计时器、未鉴权消息一律断开、鉴权通过才进在线表:
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
ws.isAlive = false;
ws.isAuthed = false;
ws.authTimer = setTimeout(() => ws.close(4001, "auth timeout"), 10_000);
ws.on("message", async (raw: RawData) => {
const msg = JSON.parse(String(raw));
if (msg.type === "auth") {
const claims = await verifyToken(msg.token); // 失败即 close(4003)
ws.userId = claims.sub;
ws.isAuthed = true;
clearTimeout(ws.authTimer);
onlineRegistry.set(claims.sub, ws); // 鉴权通过才进在线表
ws.send(JSON.stringify({ type: "auth_ok" }));
return;
}
if (!ws.isAuthed) return ws.close(4001, "unauthenticated"); // 未鉴权先说话:直接断
});
});
连接管理:在线表、心跳与僵尸清理
TCP 半开连接(对端断电、网络静默丢失)不会触发任何事件,服务端若不主动探测,在线表会慢慢堆满假数据,业务层"在线人数"随之虚高。三个组件缺一不可:
- 在线表:
userId -> ws的注册表。同一用户多设备连接时,值用 Set 或加设备维度,不能简单覆盖,否则后连接会"挤掉"先连接的会话记录。 - 心跳(ping/pong):服务端定时 ping,客户端 pong。服务端视角:发出 ping 后把
isAlive置 false,收到 pong 置 true,周期检查没有回 pong 的连接直接terminate()。 - 被动上限:单用户连接数、单 IP 连接数设上限,超限拒绝,防止客户端 bug 打爆服务端文件句柄。
const HEARTBEAT_MS = 30_000;
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) return ws.terminate(); // 僵尸:两次周期无 pong,强制断
ws.isAlive = false;
ws.ping(); // 主动探测,等下一轮检查结果
}
}, HEARTBEAT_MS);
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
ws.on("close", () => {
onlineRegistry.delete(ws.userId); // 断开必须清理在线表与所有房间订阅
for (const room of ws.rooms) leaveRoom(room, ws);
});
});
周期 30 秒是常见起点:比 60 秒更早发现假在线,又不至于给服务端带来可观开销。心跳方向是服务端 ping 客户端 pong——反过来(客户端探测服务端)无法帮服务端清理僵尸连接。
单机广播的扇出成本
广播看起来是"一条消息发给所有人",实际成本是 N 份序列化加 N 次 TLS 加密与写出。1 万连接、单条消息 2 KiB,就是每次广播约 20 MB 的写流量,高频房间下这个数字最先撑爆 CPU 与带宽。两条规则:
- 消息体序列化一次,写多次。对每条广播消息先
JSON.stringify得到 Buffer,然后循环ws.send(buffer)——每连接都各自序列化是最常见的隐性 CPU 浪费。 - 能合并就合并。高频事件(如进度条)按 100–250 ms 窗口合并后再推,业务上几乎无感,服务端写次数按比例下降。
function broadcast(room: Set<WebSocket>, event: RoomEvent): void {
const payload = Buffer.from(JSON.stringify(event)); // 序列化一次
for (const ws of room) {
if (ws.readyState !== WebSocket.OPEN) continue;
ws.send(payload); // 写 N 次
}
}
水平扩展:Redis pub/sub 广播与粘性会话
单进程撑不住连接量时上多进程、多节点,但消息路由立即出现两个问题。
广播问题:节点 A 上的用户发消息,节点 B 上的用户也该收到。解法是把"发消息"与"投递消息"拆开——业务层只把事件发布到 Redis pub/sub(或 Kafka、NATS),每个节点订阅频道,收到事件后向自己持有的本地连接投递:
import { createClient } from "redis";
const sub = createClient({ url: "redis://127.0.0.1:6379" });
await sub.connect();
await sub.subscribe("chat:room:1001", (raw: string) => {
const event = JSON.parse(raw);
const room = localRooms.get(event.roomId); // 只投递给本节点持有的连接
if (room) broadcast(room, event);
});
// 发布侧:任何节点收到用户消息,统一发到 Redis,本地不直接广播
async function publishToRoom(roomId: string, event: RoomEvent): Promise<void> {
await pub.publish(`chat:room:${roomId}`, JSON.stringify({ roomId, ...event }));
}
注意 pub/sub 不是持久队列:发布瞬间没有订阅者的节点收不到,节点重启期间的消息会丢,这与下文的消息可靠性小节直接相关。
粘性会话问题:如果用 Socket.IO 这类带握手与状态恢复的库,一个客户端的后续请求必须落回同一节点,负载均衡要按客户端标识(如 IP hash 或 cookie)做粘性;纯 ws 实现无会话状态,服务端各节点等价,不需要粘性。规则:要不要粘性取决于框架是否在节点上保存连接状态,把这一点写进部署文档。使用消息队列做事件源时,任何节点消费都正确,广播正确性不依赖粘性。
房间模型:订阅表与消息路由
房间是"给连接分组"的标准抽象。实现上维护双向映射:roomId -> Set<ws>(投递用)与 ws.rooms(清理用),两处必须同时更新,断连时双向清理,否则就是内存泄漏加重复投递:
const localRooms = new Map<string, Set<WebSocket>>();
function joinRoom(roomId: string, ws: AuthedWs): void {
if (localRooms.size >= MAX_ROOMS) evictInactiveRooms(); // 房间数上限
const room = localRooms.get(roomId) ?? new Set();
room.add(ws);
localRooms.set(roomId, room);
ws.rooms.add(roomId);
}
function leaveRoom(roomId: string, ws: AuthedWs): void {
const room = localRooms.get(roomId);
if (!room) return;
room.delete(ws);
if (room.size === 0) localRooms.delete(roomId); // 空房间即回收
ws.rooms.delete(roomId);
}
进房间的入口要鉴权:房间 ID 本身不能当权限依据,加入前校验"该用户是否属于该房间",不通过返回一个明确的错误码并保持连接存活(而不是直接断开,让客户端能重试其他房间)。
消息可靠性:ack 与重投递
WebSocket 默认是"至多一次":TCP 保证到达内核缓冲区,不保证应用层处理成功,断线重连后客户端也不知道错过了什么。可靠投递需要自己搭三件东西:
- 消息 ID 与去重:服务端每条消息带递增
seq或消息 ID,客户端去重,重连后带上last_seq请求增量。 - ack 机制:客户端收到后回 ack,服务端记录投递水位;未 ack 的消息在重连时按
last_ack_seq重投。服务端不重投、客户端不补拉,可靠性就只是口号。 - 离线补拉:房间消息写进有界的历史存储(Redis list 或消息表),重连后先补拉历史再订阅实时,弥补 pub/sub 非持久部分。
// 重连首条消息:客户端上报进度,服务端从 history 补发缺口
{ "type": "resume", "room_id": "1001", "last_ack_seq": 8123 }
// 服务端:从 history 缓存中查出 seq > 8123 的消息,先补发,再进入实时订阅
边界:重投递要设窗口上限(比如最多补 5 分钟或 1000 条),超出让客户端走 HTTP 接口全量拉取——长连接不是大数据同步通道。
背压:缓冲区水位与慢消费者
慢消费者是广播系统里最危险的角色:某客户端网络差,内核写缓冲堆积,服务端内存随之上涨,最后 OOM 拖死所有连接。ws 库暴露 bufferedAmount(已写入但尚未发出 TCP 的字节数),这是唯一可靠的背压信号:
const MAX_BUFFERED = 1_048_576; // 1 MiB:单连接水位线
function sendWithBackpressure(ws: AuthedWs, payload: Buffer): void {
if (ws.bufferedAmount > MAX_BUFFERED) {
slowClientsCounter.inc();
ws.close(4008, "slow consumer"); // 超水位直接断开:保服务,弃慢连接
return;
}
ws.send(payload);
}
处理策略分三档:超水位断开(最简单,适合通知类消息);降频只送关键帧(适合行情类,可丢中间值只保最新);服务端暂存等恢复(复杂,需要每连接队列与超时)。原则是必须显式选一档——"什么都不做"等于选择让最慢的客户端决定服务端的内存上限,这不是策略而是事故预案。断开的客户端由重连机制恢复,这与 ack/重投递小节形成闭环。
资源回收:定时器与监听器泄漏
长连接服务最容易泄漏的资源有三类,且都不会报错,只会让进程慢慢变重:
| 资源 | 泄漏方式 | 回收方式 |
|---|---|---|
| 定时器 | 每个连接注册 setTimeout/setInterval,close 时未清理 | close 回调里统一 clearTimeout |
| 事件监听器 | 往 req/res/其他 emitter 上挂监听,连接断开不摘除 | 用 once 或在 close 里 removeEventListener |
| Map/Set 引用 | 在线表、房间表只加不删 | close 回调双向清理;全局心跳遍历兜底 |
Node 对单个 emitter 的监听器数量默认告警阈值是 10,ws 的连接对象内部也会挂多个监听器——依赖默认告警不如把清理写进 close 回调的固定清单。验证泄漏的手段是可观测的:进程启动后定期记录 wss.clients.size、localRooms.size 与进程 RSS,连接数回落而房间数不回落,就是订阅表在漏。
Nginx wss 代理配置
外网到 Node 服务间通常有一层 Nginx,WebSocket 需要显式透传 Upgrade 头并拉长超时,否则握手成功后 60 秒被默认超时掐断,表现为"连接总是莫名断开、客户端不断重连":
upstream ws_nodes {
server 10.0.0.1:8080; # 文档示例地址,实际部署替换为自有服务器地址
server 10.0.0.2:8080;
# 如需粘性会话(Socket.IO 场景):ip_hash;
}
server {
listen 443 ssl;
server_name ws.example.com; # 示例域名
location /ws {
proxy_pass http://ws_nodes;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # 缺这两行,握手 400
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # 大于心跳周期,避免空闲被掐
proxy_send_timeout 300s;
proxy_buffering off; # 事件流场景关闭缓冲,降低延迟
}
}
proxy_read_timeout 必须大于心跳周期(30 秒心跳配 300 秒超时是安全余量),否则"心跳防僵尸"的机制反而被代理的超时策略绕过。TLS 终结在 Nginx 时,Node 侧监听 HTTP 即可,证书维护集中一处。
注意事项与边界
- WebSocket 不替代轮询的一切场景:低频更新(每分钟级)用轮询 + ETag 更省资源,长连接的价值在高频与低延迟。
- 浏览器同域 WebSocket 连接数受限(通常 6 个/域),把系统拆成多条长连接前先确认产品形态是否需要。
- 消息 JSON 解析要防御:字段类型校验、消息大小上限(如单帧 64 KiB)、未知 type 记 WARN 不崩溃——长连接服务被一条畸形消息打挂是常见事故。
- Redis pub/sub 的容量边界:单 Redis 节点的发布吞吐约在几万 QPS 量级,超大规模要评估分片或换 Kafka/NATS,监控发布延迟是扩容依据。
- 重连风暴:节点重启后上万客户端同时重连,加随机退避(0–5 秒抖动)摊平峰值,否则重启会引发第二轮雪崩。
总结
WebSocket 生产化的核心是三张表加两条防线:在线表与房间表管理"谁在哪",心跳与背压防线保住"服务不被僵尸与慢客户端拖垮";跨节点靠 Redis pub/sub 把广播变成"发布-本地投递",可靠性靠 seq/ack/重投递补上"至多一次"的缺口。每一项机制都对应一个可观测指标——连接数、房间数、缓冲水位、重连率。上线前把指标接好,故障时才能靠数据定位而不是靠猜。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。