信令服务与房间水平扩展

讲透信令服务的水平扩展:从协议设计的可扩展性要求、房间状态与成员管理、多实例间的发布订阅分片与定向路由,到断线重连与消息补发、SFU 节点就近调度与房间亲和、房间容量与背压限流、灰度发布与协议版本兼容,并给出单机容量估算、扩容水位与故障演练的落地方法。

信令服务在原型阶段总是简单的:一个进程、一张内存里的房间表、按房间广播。这套代码能撑到几百个并发连接,然后在某个业务增长的节点突然失效——不是慢慢变慢,而是「一半用户能连上,另一半连上了却收不到对方的 SDP」。

这个断崖式失效的根因是状态。信令服务的每个连接都持有房间成员关系,一旦扩到多实例,同一个房间的两个人可能落在不同进程上,而内存里的房间表无法跨进程共享。这时要么把同一房间的用户粘到同一实例(有状态路由),要么引入共享状态与消息总线(无状态实例)。本文讲的就是这两条路怎么选、怎么落地。

内容按「协议要满足什么 → 状态放哪里 → 消息怎么路由 → 断了怎么恢复 → 节点怎么调度」的顺序展开,最后给出容量规划与故障演练的方法。基础的信令消息类型与最小实现见 WebRTC 架构与信令设计 ,本文不重复那部分。

目录

  1. 单实例信令为什么撑不住
  2. 协议设计的可扩展性要求
  3. 房间状态与成员管理
  4. 多实例路由:发布订阅与定向投递
  5. 断线重连与状态恢复
  6. SFU 节点选择与就近接入
  7. 房间容量与背压
  8. 灰度发布与协议版本兼容
  9. 可观测性与排障
  10. 容量规划与故障演练

1. 单实例信令为什么撑不住

信令服务的负载特征与普通 HTTP 服务完全不同:

特征普通 API信令服务
连接时长毫秒级小时级
状态无每连接一份
流量请求驱动事件驱动,突发
扩容加实例即可需解决状态与路由
故障影响单次请求失败整房间通话中断

单实例的瓶颈通常出现在三个地方:文件描述符上限(默认 1024,需调到 10 万以上)、内存(每连接约 20 到 50 KB 的缓冲与状态)、以及广播时的 CPU(N 人房间的消息扇出是 O(N²))。

扩容的第一反应是「加实例 + 负载均衡」,但立刻会遇到房间分裂问题。因此必须先决定状态的归属。

2. 协议设计的可扩展性要求

一份能支撑水平扩展的信令协议,必须满足以下几条:

  • 消息自描述:每条消息都携带 roomId、peerId、targetPeerId(可选)与 type,服务端不需要读连接上下文就能路由。裸转发的实现往往依赖连接上的隐含状态,扩到多实例就崩。
  • 顺序无关:除了 SDP 与候选必须有序,其余消息(成员变更、通知)应允许乱序,否则跨实例转发时不得不引入全局排序。
  • 幂等:重复的 join、重复的 candidate 都必须安全,因为网络抖动下的重发是常态而非异常。
  • 可版本化:消息带 v 字段或独立的协议版本号,便于灰度期间新旧客户端共存。
// 自描述的消息信封
const envelope = {
  v: 2,                        // 协议版本
  type: "candidate",           // 消息类型
  roomId: "room-9f3a",
  peerId: "peer-17",
  targetPeerId: "peer-42",     // 定向投递,为空表示广播
  seq: 10231,                  // 用于幂等与补发
  ts: 1759802400000,
  payload: { candidate: "...", sdpMid: "0", sdpMLineIndex: 0 },
};

3. 房间状态与成员管理

3.1 状态该放哪里

三种方案,各有明确的适用规模:

方案状态位置扩展方式适用规模
粘性路由实例内存同房间固定到同一实例中小规模
共享存储Redis / 数据库实例无状态大规模
分区归属按 roomId 哈希分片每个分片独立扩展超大规模

粘性路由实现最简单:负载均衡器按 roomId 做一致性哈希,把同一房间的连接全部路由到同一实例。代价是热点房间会把单个实例打满,且实例故障时整房间中断。

共享存储方案把房间成员表放进 Redis,每个实例都能读写。为了避免每个消息都查 Redis,实践中通常做「本地缓存 + 变更订阅」:实例只缓存自己连接着的房间成员,通过订阅变更事件保持新鲜。

3.2 成员表的数据结构

// Redis 中的房间成员表示
// Key:  room:{roomId}:members   → Hash,field = peerId,value = JSON
// Key:  room:{roomId}:meta      → Hash,房间元信息(创建时间、容量、SFU 节点)
// Key:  peer:{peerId}           → String,值为 roomId,用于快速反查

async function joinRoom(redis, { roomId, peerId, sessionId }) {
  const meta = await redis.hGetAll(`room:${roomId}:meta`);
  const size = await redis.hLen(`room:${roomId}:members`);
  if (Number(meta.capacity) && size >= Number(meta.capacity)) {
    throw new Error("ROOM_FULL");
  }
  await redis.hSet(`room:${roomId}:members`, peerId, JSON.stringify({ sessionId, joinedAt: Date.now() }));
  await redis.set(`peer:${peerId}`, roomId, { EX: 86400 });
  await redis.expire(`room:${roomId}:members`, 86400);
  await redis.publish("room-events", JSON.stringify({ kind: "join", roomId, peerId }));
}

注意两处 TTL:成员表与 peer 反查表都必须有过期时间。信令服务崩溃时来不及清理,靠 TTL 兜底能避免僵尸 peer 永久占用房间名额。

4. 多实例路由:发布订阅与定向投递

多实例之间的消息传递有两条路径,分别对应广播与定向:

广播(成员变更、房间通知):
  instance A --PUBLISH--> Redis Pub/Sub --> 所有实例 --过滤 roomId--> 本地连接

定向(SDP、ICE 候选):
  instance A --PUBLISH--> channel:peer-42 --> 订阅了该频道的实例 B --> 连接 42

定向投递的优化点在于「频道粒度」。如果所有实例都订阅同一个频道再过滤,N 个实例的每条消息都会被广播 N 次,浪费随实例数线性增长。更好的做法是按 peerId 或 roomId 分片频道:

// 每个实例只订阅自己关心的分片
const SHARDS = 64;
function shardOf(roomId) {
  let h = 0;
  for (let i = 0; i < roomId.length; i++) h = (h * 31 + roomId.charCodeAt(i)) | 0;
  return Math.abs(h) % SHARDS;
}

const myShards = new Set();
function watchRoom(roomId) {
  const shard = shardOf(roomId);
  if (myShards.has(shard)) return;
  myShards.add(shard);
  subscriber.subscribe(`sig:${shard}`);
}

分片数与实例数的比例建议在 4 到 16 之间:太少则过滤开销大,太多则订阅管理复杂。分片的思想与一致性哈希一致,只是这里的对象是「消息频道」而非数据。

Redis Pub/Sub 的语义是「至多一次」,实例重启期间的消息会丢。对 SDP 与候选这类关键消息,必须配合「重连补拉」而不是指望 Pub/Sub 不丢。若要求更高,可换成 NATS 或 Kafka,代价是延迟与运维复杂度上升。

5. 断线重连与状态恢复

移动端切网、地铁隧道、系统休眠都会造成 WebSocket 断开。信令层必须能回答三个问题:重连后如何识别是同一个人?断线期间的消息怎么办?旧连接的残留状态怎么清理?

5.1 稳定身份

连接层用一次性的 connectionId,业务层用稳定的 peerId + sessionId。重连时客户端携带上次的 sessionId,服务端据此把新连接绑定到同一 peer 上。

// 服务端处理重连:替换旧连接,保留 peer 身份
async function handleJoin(ws, { roomId, peerId, sessionId, lastSeq }) {
  const prev = localConnections.get(peerId);
  if (prev && prev.sessionId === sessionId) {
    prev.ws.close(4001, "replaced by new connection");
    console.log("同 session 重连,替换旧连接");
  }
  localConnections.set(peerId, { ws, sessionId, roomId });

  // 补发断线期间的消息
  const missed = await fetchMissedMessages(roomId, peerId, lastSeq);
  for (const m of missed) ws.send(JSON.stringify(m));

  broadcastToRoom(roomId, { type: "peer-rejoined", peerId });
}

5.2 消息补发

补发的实现方式是把房间内最近一段时间的关键消息缓存在一个有界队列(环形缓冲或 Redis Stream)里,客户端在 join 时上报 lastSeq,服务端从 lastSeq + 1 开始补发。

class RoomBuffer {
  constructor(capacity = 500) {
    this.capacity = capacity;
    this.items = [];   // { seq, message }
    this.nextSeq = 1;
  }

  push(message) {
    const seq = this.nextSeq++;
    this.items.push({ seq, message: { ...message, seq } });
    if (this.items.length > this.capacity) this.items.shift();
    return seq;
  }

  since(lastSeq) {
    // 若 lastSeq 已被挤出缓冲,返回 null 表示必须全量重建
    if (this.items.length && this.items[0].seq > lastSeq + 1) return null;
    return this.items.filter((it) => it.seq > lastSeq).map((it) => it.message);
  }
}

since 返回 null 是一个必须处理的分支:客户端离线太久,缺口已经超出缓冲,此时唯一的正确做法是让客户端销毁所有 RTCPeerConnection 并重新走一遍完整协商,而不是试图增量恢复。

5.3 僵尸连接清理

服务端必须能检测「连接还在但客户端其实已经死了」的情况。手段是双向心跳:服务端定期 ping,客户端必须在超时内 pong。心跳间隔与超时值的设置见 WebSocket 集群扩展 中的讨论,25 秒是常用值。

6. SFU 节点选择与就近接入

信令服务除了交换 SDP,还承担一个关键职责:告诉客户端该连哪个 SFU 节点。这个决策直接决定了媒体延迟与跨机房流量。

6.1 调度依据

依据数据来源权重
地理距离客户端 IP 归属地高
网络 RTT客户端上报的探测结果最高
节点负载节点心跳上报的 CPU、带宽、房间数高
房间亲和房间是否已在某节点最高

最可靠的做法是让客户端在加入房间前先做一次探测:并行连接几个候选 SFU 的探测端口,测量 RTT 与丢包,把结果上报给调度器。

async function probeNodes(nodes) {
  const results = await Promise.all(nodes.map(async (n) => {
    const t0 = performance.now();
    try {
      await fetch(`https://${n.host}/probe?ts=${Date.now()}`, { mode: "no-cors", cache: "no-store" });
      return { host: n.host, rtt: performance.now() - t0, ok: true };
    } catch {
      return { host: n.host, rtt: Infinity, ok: false };
    }
  }));
  return results.sort((a, b) => a.rtt - b.rtt);
}

6.2 房间亲和优先

一个容易忽略的约束是:同一房间的成员应尽量落在同一 SFU 节点。如果房间已经存在于节点 A,新成员即使离节点 B 更近,也通常应该进 A,否则需要启用 SFU 级联,跨节点转发的带宽与延迟代价远大于几十毫秒的就近收益。

调度器的决策顺序应是:房间亲和 → 节点健康 → 就近 → 负载均衡。只有在节点 A 已达容量上限或故障时,才考虑把新成员放到其他节点并触发级联。

7. 房间容量与背压

信令服务的背压来源有三处,处理方式各不相同:

  • 入向消息洪峰:客户端短时间内发送大量候选或自定义消息。用令牌桶按 peer 限流,超出直接丢弃并回 error。
  • 出向广播风暴:N 人房间的成员变更广播是 O(N),频繁的加入退出会造成 O(N²) 的消息量。优化手段是合并广播(在 100 ms 窗口内合并多次成员变更)与增量同步(只发变更的成员,而非全量列表)。
  • 跨实例转发积压:Pub/Sub 消费不过来时,实例的发送缓冲会膨胀。必须监控 bufferedAmount 并在超限时优先丢弃非关键消息。
// 按 peer 的令牌桶限流:rate 为每秒补充的令牌数,burst 为桶容量
// 候选消息突发性很强,rate 给宽一点(如 50/s),burst 给大一点(如 200)
function allow(bucket, cost = 1) {
  const now = Date.now();
  bucket.tokens = Math.min(bucket.burst, bucket.tokens + ((now - bucket.last) / 1000) * bucket.rate);
  bucket.last = now;
  if (bucket.tokens < cost) return false;
  bucket.tokens -= cost;
  return true;
}

房间容量上限必须显式设置并在信令层强制执行,而不是依赖客户端的自觉。超限时返回明确的错误码(如 ROOM_FULL),前端据此给出可理解的提示,而不是让用户卡在「正在连接」。

8. 灰度发布与协议版本兼容

信令协议一旦上线就难以强制升级,因为客户端版本碎片化严重(移动端尤甚)。灰度期间新旧客户端会同时在线,协议必须向后兼容。

三条实践准则:

  • 只增不减:新增字段而不删除或改变已有字段的语义。v 字段用于声明发送方版本,接收方按自己的版本做降级处理。
  • 能力协商:在 join 时交换能力集(支持的编解码、是否支持 Simulcast、协议版本区间),服务端据此决定给客户端下发哪种格式的消息。
  • 双写过渡:需要改变语义时,服务端同时下发新旧两种格式,客户端按自己认识的字段解析,观察一段时间后再停止旧格式。
// 服务端按客户端版本选择消息格式
function encodeOfferFor(client, sdp) {
  if (client.protocolVersion >= 3) {
    return { v: 3, type: "offer", payload: { sdp, trickle: true, iceRestart: false } };
  }
  return { v: 1, type: "offer", sdp };  // 旧格式,平铺字段
}

灰度发布时还要注意:信令实例的新旧版本必须能互相转发消息。因为一个房间可能同时有连到新实例和旧实例的成员,消息格式的转换必须在转发路径上完成,否则跨版本房间会直接不可用。

9. 可观测性与排障

信令层的排障难点在于「连接建立失败」的原因分散在多个环节。因此必须把每个阶段打上时间戳与结果:

// 每个阶段打点,失败时记录 failStage,上报后即可定位到具体环节
const trace = { connectStart: performance.now(), wsOpen: null, joinAck: null,
                offerSent: null, answerRecv: null, iceConnected: null, failStage: null };

推荐监控的指标:

指标目标说明
信令连接成功率> 99.5%含重连
join 到 offer 的耗时P95 < 300 ms反映信令处理速度
实例间转发延迟P99 < 50 ms反映消息总线健康
房间成员表大小与预期一致检测僵尸 peer
每实例连接数< 设计上限 80%容量水位

排障时最常见的三个问题:一是「候选收到了但连不上」,根源多是 sdpMid 与 sdpMLineIndex 在转发时被丢弃;二是「同一房间两人收不到对方消息」,根源是分片订阅没建立或房间哈希不一致;三是「重连后黑屏」,根源是补发逻辑返回了 null 但客户端没有重建连接。

10. 容量规划与故障演练

容量规划要回答两个问题:单实例能承载多少连接,以及扩容的触发条件是什么。

单实例连接上限 = min(fd 上限 / 2, 可用内存 / 每连接内存(约 30 KB), 核数 × 每核连接数)

以 8 核、16 GB、fd 上限 100 万为例,理论上限约 50 万连接,但实际应按 5 到 10 万规划,留出故障时接收其他实例流量的余量。扩容触发条件建议设为「单实例连接数达 70%」或「CPU 持续 5 分钟超 60%」。

故障演练要覆盖的场景:

  • 随机 kill 一个信令实例,验证客户端能否在 3 秒内重连并恢复房间状态。
  • 断开 Redis,验证实例间转发失败时是否有降级路径(如粘性路由兜底)。
  • 模拟某个房间瞬间涌入 500 个成员,验证背压与广播合并是否生效。
  • 让某个 SFU 节点拒绝服务,验证调度器能否把新成员导向其他节点。

这些演练的组织方式可以参考 混沌工程与故障注入 中的方法论:先定义稳态指标,再注入故障,最后验证指标是否在可接受范围内恢复。

权衡取舍

取舍点粘性路由共享存储 + 消息总线
实现复杂度低高
扩容粒度按房间按连接
热点房间单实例打满天然分散
实例故障影响整房间中断仅连接中断,可重连
消息延迟最低(无跨实例)多一跳总线
依赖组件无Redis / NATS

判断标准很清晰:房间规模小、数量多、无超级热点,粘性路由足够;存在万人级大房间或对故障隔离有硬要求,必须上共享存储。

常见坑清单

  • 房间成员表不设 TTL,实例崩溃后僵尸 peer 永久占用房间名额。
  • 用 Date.now() 作为消息序号,多实例下时钟不同步导致补发错乱。
  • 补发逻辑未处理「缺口超出缓冲」的情况,客户端拿到空数组后一直黑屏。
  • 所有实例订阅同一频道再过滤,消息量随实例数线性放大。
  • 依赖 Redis Pub/Sub 的可靠投递,实例重启期间的关键消息静默丢失。
  • 调度器只看就近不看房间亲和,导致被迫启用 SFU 级联。
  • 广播不做合并,成员频繁进出造成 O(N²) 的消息风暴。
  • 房间容量不设上限,单个房间耗尽实例资源。
  • 灰度期间新旧协议格式不兼容,跨版本房间直接不可用。
  • 信令转发时丢弃 sdpMid 与 sdpMLineIndex,候选无法绑定到 m-line。

小结

信令服务的水平扩展本质上是一个状态问题:状态放在实例里就必须粘性路由,放到共享存储里就必须解决一致性与消息路由。两条路都能走通,选择取决于房间规模分布与故障隔离要求。

协议设计的质量决定了扩展的上限。一份自描述、幂等、可版本化的协议,能让路由、补发、灰度这些能力自然生长出来;反之,一个依赖连接上下文的裸转发实现,扩到第二个实例就会失效。

落地时的最小可行组合是:Redis 存房间成员与 peer 反查,Pub/Sub 分片频道做跨实例路由,环形缓冲做断线补发,调度器按「房间亲和优先、就近次之」选择 SFU 节点。这套组合能覆盖绝大多数业务规模,剩下的就是持续的容量观测与故障演练。媒体侧的节点调度与级联细节,可以继续参考 SFU 与 MCU 架构选型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 低延迟直播:LL-HLS 与 WebRTC 直播
  3. 实时音视频录制与转码