20、信令服务器集群设计:水平扩展思路、Redis 作为状态存储、消息广播
说实话,单机信令服务器撑个几百人同时在线,问题不大。但一旦用户量上来,比如几千、几万并发,单点瓶颈就暴露了。我最早做的一个视频会议项目,上线第一天就被用户量打懵了——服务器直接挂掉,所有通话中断。嗯,从那以后,我再也不敢忽视集群设计。
这一章,我们就聊聊信令服务器怎么水平扩展。说白了,就是一台不够,那就加两台、三台、几十台。但问题来了——WebRTC 信令是状态相关的,用户 A 和用户 B 必须在同一个房间,消息不能乱发。怎么保证多台服务器之间协同工作?
水平扩展的核心思路
先想一个问题:如果我有 3 台信令服务器,用户 A 连上了服务器 1,用户 B 连上了服务器 2。A 想邀请 B 加入房间,这条信令消息怎么从服务器 1 传到服务器 2?
答案很简单——需要一个共享的状态层。所有服务器都从这个共享层读写数据,而不是各自维护独立的内存状态。
核心原则:信令服务器本身应该是无状态的。所有房间信息、用户连接、会话状态,都存到外部存储里。这样任何一台服务器挂了,其他服务器可以无缝接管。
我个人习惯把信令服务器的集群架构分成三层:
- 接入层:负载均衡器(Nginx、HAProxy),负责把用户请求分发到不同的信令服务器节点。
- 逻辑层:信令服务器节点,处理信令消息,但自身不保存状态。
- 存储层:Redis 集群,保存房间信息、用户列表、会话映射。
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 加入房间:
- 用户 A 发送邀请消息到服务器 1。
- 服务器 1 收到消息,先存到 Redis 的房间消息队列里。
- 服务器 1 通过 Redis Pub/Sub 发布广播消息。
- 服务器 2 收到广播,检查用户 B 是否在自己节点上。
- 如果是,就把消息推送给用户 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 本身也需要集群化,那就是另一个话题了。