WebSocket 水平扩展
单台 WebSocket 服务受限于内存和文件描述符,通常连接数上限为数万到十万级。超过这个规模需要横向扩展,但 WebSocket 的有状态特性让扩展比 HTTP 服务复杂得多。
核心问题:跨服务器通信
用户 A 连接到 Server 1
用户 B 连接到 Server 2
用户 A 给用户 B 发消息 → 消息只在 Server 1
→ Server 2 收不到,B 永远看不到
Note
本质
HTTP 服务无状态,任意实例都能处理任意请求。WebSocket 连接绑定到具体实例——跨实例协作必须显式解决。
三个典型挑战
| 挑战 | 说明 |
|---|---|
| 跨实例广播 | 实例 A 的事件要推送到实例 B、C… 上的相关连接 |
| 连接粘性 | 同一用户的重连要回到同一实例?还是任意实例都能接? |
| 连接数可见性 | 统计”当前在线用户”需要汇总所有实例,不能只看单机 |
方案 1:消息中间件(跨实例广播)
最常用方案:用 Redis Pub/Sub 做所有实例间的消息总线。
┌────────────┐ publish ┌─────────────┐
│ Server 1 │────────────────────▶│ │
│ (user A) │ │ │
└────────────┘ │ Redis │
│ Pub/Sub │
┌────────────┐ subscribe │ │
│ Server 2 │◀────────────────────│ │
│ (user B) │ └─────────────┘
└────────────┘ ▲
│
┌────────────┐ subscribe │
│ Server 3 │◀────────────────────────────
│ (user C) │
└────────────┘
Socket.IO 官方 Adapter
Socket.IO 提供 @socket.io/redis-adapter,接入一行代码:
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
之后 io.to("room").emit(...) 会自动广播到所有实例。
自己实现(ws 库)
ws 库没有官方 Adapter,可以手动搭:
import Redis from "ioredis";
const pub = new Redis();
const sub = new Redis();
sub.subscribe("broadcast");
sub.on("message", (channel, raw) => {
if (channel !== "broadcast") return;
const { roomId, payload, sourceInstanceId } = JSON.parse(raw);
broadcastToRoom(roomId, payload); // 本地的房间广播函数
});
function crossInstanceBroadcast(roomId, payload) {
// 本地先广播
broadcastToRoom(roomId, payload);
// 再通过 Redis 通知其他实例
pub.publish(
"broadcast",
JSON.stringify({
roomId,
payload,
sourceInstanceId: process.env.INSTANCE_ID,
}),
);
}
Note
陷阱:消息回环
简单订阅会让发布者也收到自己发的消息,导致重复广播。解决方案:
- 给每个实例生成唯一 ID,在 payload 里带上
sourceInstanceId - 订阅回调收到消息时,如果
sourceInstanceId === process.env.INSTANCE_ID,跳过本地广播
方案 2:粘性会话(Sticky Session)
让负载均衡器把同一用户始终路由到同一服务器。
用户 A ──┐
├─▶ LB ──▶ Server 1 (A 永远走 Server 1)
│
用户 B ──┘─▶ LB ──▶ Server 2 (B 永远走 Server 2)
实现方式
| 负载均衡器 | 粘性策略 |
|---|---|
| Nginx | ip_hash 或 hash $cookie_id |
| AWS ALB | 内置 Sticky Sessions(Cookie 或客户端 IP) |
| Cloudflare | 需企业版 |
作用边界
Note
要点:粘性只解决重连,不解决跨实例通信
- 粘性保证用户 A 重连后仍回到 Server 1,避免握手阶段的鉴权 / 会话状态丢失
- 但用户 A 给用户 B 发消息时,A 在 Server 1、B 在 Server 2,仍然需要 Redis 做跨实例广播
- 粘性会话和消息中间件通常同时使用
方案 3:消息中间件选型
| 方案 | 适用 | 优势 | 劣势 |
|---|---|---|---|
| Redis Pub/Sub | 大多数场景 | 简单、低延迟、Socket.IO 官方支持 | 不持久化,订阅者断线期间的消息会丢 |
| Redis Streams | 需要消息持久化 | 支持消费组、回溯 | 复杂度更高 |
| NATS | 低延迟微服务通信 | 轻量、集群简单 | 生态比 Redis 小 |
| Kafka | 大数据管道、事件溯源 | 高吞吐、持久化、多订阅者 | 运维成本高 |
| RabbitMQ | 复杂路由规则(fan-out、topic) | 成熟、灵活 | 延迟略高于 Redis |
Note
通用判断
- 默认选 Redis Pub/Sub(与 Socket.IO 天然集成)
- 消息必须不丢(离线消息、通知历史)→ Redis Streams 或 Kafka
- 需要复杂路由(按标签、通配符订阅) → RabbitMQ 或 NATS
连接数可见性
“当前在线用户数” 不能只看单机。常见方案:
Redis 维护在线集合
用”集合存所有用户 + 单独 key 做 TTL 心跳”的组合,主动断线和异常掉线都能清理:
// 握手成功后
await redis.sadd("online_users", userId);
await redis.set(`online:${userId}`, "1", "EX", 60); // 心跳 key,60 秒 TTL
// 客户端每 30 秒心跳一次:刷新 TTL
await redis.expire(`online:${userId}`, 60);
// 主动断线
await redis.srem("online_users", userId);
await redis.del(`online:${userId}`);
// 查询在线数(集合里但心跳 key 已过期的视为掉线,按需清理)
const count = await redis.scard("online_users");
Note
要点
online_users集合用于快速统计在线数和枚举在线用户online:{userId}带 TTL 的 key 作为”心跳凭据”:异常掉线时自动过期,避免集合里堆积幽灵用户- 定期任务扫描集合,对
EXISTS online:{userId}为 0 的条目从集合里SREM清理
按实例维度
每个实例定期把本地连接数写入 Redis,中控聚合:
setInterval(async () => {
await redis.hset("instance_stats", process.env.INSTANCE_ID, wss.clients.size);
}, 5_000);
// 查询全量
const stats = await redis.hgetall("instance_stats");
const total = Object.values(stats).reduce((a, b) => a + Number(b), 0);
部署架构参考
┌─────────┐
│ LB │ ← 粘性会话(ip_hash 或 cookie)
└────┬────┘
┌─────┼─────┐
▼ ▼ ▼
┌────┐┌────┐┌────┐
│ S1 ││ S2 ││ S3 │ ← 每实例维护本地连接 + 房间
└──┬─┘└─┬──┘└──┬─┘
└────┼──────┘
▼
┌─────────┐
│ Redis │ ← Pub/Sub(跨实例广播)
│ Cluster │ + 在线集合
└─────────┘
常见坑
| 问题 | 修复 |
|---|---|
| 单实例连接数打爆文件描述符 | 调整 ulimit -n,服务端代码层面也要限制单连接资源 |
| Redis Pub/Sub 在 Redis 重启时消息丢失 | 关键消息改用 Redis Streams 或独立消息队列 |
| 实例挂掉时用户看到”对方离线”但重连后仍在 | 粘性 + Redis 维护”最近心跳时间”做软判断 |
| 节点扩容 / 缩容时连接被强制断开 | 优雅关闭:先从 LB 摘除,等现有连接自然断开,再退出 |
回到 WebSocket 总览