跳至正文

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)

实现方式

负载均衡器粘性策略
Nginxip_hashhash $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 总览