20、信令服务器集群设计:水平扩展思路、Redis 作为状态存储、消息广播

说实话,单机信令服务器撑个几百人同时在线,问题不大。但一旦用户量上来,比如几千、几万并发,单点瓶颈就暴露了。我最早做的一个视频会议项目,上线第一天就被用户量打懵了——服务器直接挂掉,所有通话中断。嗯,从那以后,我再也不敢忽视集群设计。

这一章,我们就聊聊信令服务器怎么水平扩展。说白了,就是一台不够,那就加两台、三台、几十台。但问题来了——WebRTC 信令是状态相关的,用户 A 和用户 B 必须在同一个房间,消息不能乱发。怎么保证多台服务器之间协同工作?

水平扩展的核心思路

先想一个问题:如果我有 3 台信令服务器,用户 A 连上了服务器 1,用户 B 连上了服务器 2。A 想邀请 B 加入房间,这条信令消息怎么从服务器 1 传到服务器 2?

答案很简单——需要一个共享的状态层。所有服务器都从这个共享层读写数据,而不是各自维护独立的内存状态。

核心原则:信令服务器本身应该是无状态的。所有房间信息、用户连接、会话状态,都存到外部存储里。这样任何一台服务器挂了,其他服务器可以无缝接管。

我个人习惯把信令服务器的集群架构分成三层:

  • 接入层:负载均衡器(Nginx、HAProxy),负责把用户请求分发到不同的信令服务器节点。
  • 逻辑层:信令服务器节点,处理信令消息,但自身不保存状态。
  • 存储层:Redis 集群,保存房间信息、用户列表、会话映射。
负载均衡器(Nginx / HAProxy) 接入层 信令服务器 1 信令服务器 2 信令服务器 3 逻辑层(无状态) Redis 集群(状态存储) 房间信息 | 用户列表 | 会话映射 | 消息队列 存储层 Redis Pub/Sub 消息广播 跨节点消息分发

Redis 作为状态存储

为什么选 Redis?说白了,就两个原因:快、数据结构丰富。

信令服务器的状态数据,无非就是房间、用户、会话这些。Redis 的哈希表、集合、有序集合,正好能完美映射这些数据结构。

房间信息存储

我用 Redis 的哈希表来存房间信息。每个房间一个 key,字段包括房间名、创建时间、房间类型等。

// 创建房间
HMSET room:room123 name "测试会议室" created_at "2024-01-15 10:00:00" type "video" max_users 8

// 读取房间信息
HGETALL room:room123

用户列表存储

房间里的用户列表,用集合(Set)来存。每个用户是集合里的一个元素,加入房间就添加,离开房间就删除。

// 用户加入房间
SADD room:room123:users user_001 user_002

// 获取房间所有用户
SMEMBERS room:room123:users

// 用户离开房间
SREM room:room123:users user_001

小技巧:我习惯在用户加入房间时,同时维护一个反向映射——用户当前在哪个房间。这样用户断线重连时,能快速恢复他的房间状态。

// 用户 -> 房间映射
SET user:user001:current_room room123
// 设置过期时间,防止用户异常断开后数据残留
EXPIRE user:user001:current_room 3600

会话映射存储

每个 WebSocket 连接都有一个唯一的 session ID。我需要知道这个 session 属于哪个用户、在哪个房间。用哈希表存最合适。

// 存储会话信息
HMSET session:sess_abc123 user_id "user_001" room_id "room123" connected_at "2024-01-15 10:05:00"

// 根据 session 查找用户和房间
HGETALL session:sess_abc123

消息广播:跨节点通信

这是集群设计里最核心的部分。用户 A 在服务器 1 上发了一条信令消息,怎么让服务器 2 上的用户 B 收到?

我推荐用 Redis 的 Pub/Sub 机制。每个信令服务器节点都订阅同一个频道,当某个节点需要广播消息时,就把消息发布到这个频道上,所有节点都能收到。

// 订阅频道(每个信令服务器启动时执行)
SUBSCRIBE signaling_broadcast

// 发布消息(当需要广播时)
PUBLISH signaling_broadcast '{
  "type": "offer",
  "from": "user_001",
  "to": "user_002",
  "room": "room123",
  "sdp": "..."
}'

注意:Redis Pub/Sub 是「即发即忘」模式。如果某个节点在消息发布时断线了,它就收不到这条消息。对于关键信令(比如 SDP 交换),我建议配合 Redis 的 List 做消息持久化,确保消息不丢失。

消息广播的完整流程

举个例子,用户 A 想邀请用户 B 加入房间:

  1. 用户 A 发送邀请消息到服务器 1。
  2. 服务器 1 收到消息,先存到 Redis 的房间消息队列里。
  3. 服务器 1 通过 Redis Pub/Sub 发布广播消息。
  4. 服务器 2 收到广播,检查用户 B 是否在自己节点上。
  5. 如果是,就把消息推送给用户 B 的 WebSocket 连接。
// 服务器 1:处理邀请消息
function handleInvite(ws, message) {
  const { from, to, room } = message;
  
  // 1. 存储消息到队列
  redis.lpush(`room:${room}:messages`, JSON.stringify(message));
  
  // 2. 发布广播
  redis.publish('signaling_broadcast', JSON.stringify({
    type: 'invite',
    from,
    to,
    room,
    serverId: 'server_1'
  }));
}

// 服务器 2:接收广播
redis.subscribe('signaling_broadcast', (channel, message) => {
  const data = JSON.parse(message);
  
  // 检查目标用户是否在本节点
  if (userConnections.has(data.to)) {
    const ws = userConnections.get(data.to);
    ws.send(JSON.stringify(data));
  }
});

负载均衡策略

有了共享存储和消息广播,接下来就是怎么把用户请求分发到不同的服务器节点。

我个人建议用 IP 哈希一致性哈希。为什么?因为 WebSocket 是长连接,一旦建立,后续所有信令消息都走这个连接。如果负载均衡器把同一个用户的请求分发到不同服务器,那连接就断了。

策略 优点 缺点 适用场景
轮询 实现简单,负载均匀 无法保证同一用户固定到同一节点 短连接、无状态服务
IP 哈希 同一 IP 固定到同一节点 用户 IP 变化时可能重新分配 WebSocket 长连接
一致性哈希 节点增减影响小,缓存命中率高 实现稍复杂 大规模集群、动态扩缩容

我的经验:对于中小规模集群(10 台以内),IP 哈希就够用了。Nginx 配置一行搞定:

upstream signaling_servers {
    ip_hash;
    server 192.168.1.10:3000;
    server 192.168.1.11:3000;
    server 192.168.1.12:3000;
}

避坑指南

我曾经踩过一个坑:Redis 的 Pub/Sub 消息体太大,导致网络拥堵。信令消息里如果带了完整的 SDP 信息,一个 offer 消息可能几 KB。如果房间里有 100 个人,一次广播就是几百 KB 的数据量。

后来我改了策略:广播只发消息的元信息(类型、发送者、接收者),真正的 SDP 数据存到 Redis 里,接收方通过消息 ID 去拉取。

// 广播消息(只含元信息)
{
  "type": "offer",
  "from": "user_001",
  "to": "user_002",
  "msgId": "msg_abc123"  // 消息 ID,用于拉取完整数据
}

// 完整数据存到 Redis
SET msg:msg_abc123 '{"sdp": "...", "ice": "..."}'
EXPIRE msg:msg_abc123 60

嗯,这样改完之后,广播效率提升了不少。你想想看,如果每个节点都要转发完整的 SDP 数据,那带宽消耗是很可观的。

总结

信令服务器的集群设计,说白了就三件事:

  • 状态外移:用 Redis 存房间、用户、会话信息,服务器本身无状态。
  • 消息广播:用 Redis Pub/Sub 做跨节点通信,配合消息队列保证可靠性。
  • 负载均衡:用 IP 哈希或一致性哈希,确保长连接不中断。

这套方案我用了好几年,从几百并发到几万并发都扛得住。当然,再往上走,Redis 本身也需要集群化,那就是另一个话题了。

公众号:蓝海资料掘金营,微信deep3321