跳到主要内容

信令协议设计

信令协议设计

WebRTC 标准把信令"留白"。这篇文档回答一个工程问题:在自己的产品里,怎么把房间、Offer/Answer、ICE 候选、断线重连、限流这些东西落成一个能上线、能扩容、能排障的协议。

不讨论选 WebSocket 还是 Socket.IO,那只是传输;本文聚焦协议本身的形状

1. 信令的最小职责边界

信令只做 4 件事,多一件都属于"业务层",不应混入信令层:

职责是否必须说明
房间成员管理谁在、谁加入、谁掉线
转发 SDP服务端不解析,盲转
转发 ICE 候选高频小包,需要限流
鉴权 / 准入JWT 或一次性 token
业务消息(举手、聊天)用 DataChannel 或独立 RPC
录制控制、白板同步独立通道,避免阻塞协商

把举手、弹幕这种业务消息塞到信令通道,会让限流策略难做(业务高频包淹没协商低频包),也会让信令服务无法专注做横向扩展。

2. 房间模型:从最小到可扩展

最小的房间模型是一张内存表:Map<roomId, Set<peerId>>。但生产上要回答 4 个问题:

  1. 一个房间能不能跨多台信令机器?
  2. peerId 由谁分配,重连后能否复用?
  3. 谁是房主,房主走了怎么办?
  4. 房间消失的条件是什么?

横向扩展的最常见做法:每台信令节点本地保存自己服务的 peer 连接,房间状态走 Redis pub/sub,跨机消息通过频道 room:{roomId} 广播。

// 节点本地
const localPeers = new Map(); // peerId -> WebSocket

// 跨节点:订阅房间频道
redisSub.subscribe(`room:${roomId}`, (payload) => {
const { to, message } = JSON.parse(payload);
const ws = localPeers.get(to);
if (ws && ws.readyState === 1) ws.send(JSON.stringify(message));
});

// 转发消息:先看本地有没有,没有再走 Redis
function relay(toPeerId, message) {
const local = localPeers.get(toPeerId);
if (local) return local.send(JSON.stringify(message));
redisPub.publish(`room:${message.roomId}`, JSON.stringify({ to: toPeerId, message }));
}

3. 消息类型:一张表说清

信令消息只分两类:控制类(房间生命周期)和 协商类(SDP / ICE)。控制类必须有响应,协商类是单向通知。

type方向必填字段说明
joinC → SroomId, token, peerId?鉴权 + 加入房间
joinedS → CpeerId, peers[], iceServers加入成功,附 TURN 凭据
peer-joinedS → CpeerId新成员通知
peer-leftS → CpeerId, reason成员离开
offerC → S → Cfrom, to, sdp, seq转发 SDP Offer
answerC → S → Cfrom, to, sdp, seq转发 SDP Answer
candidateC → S → Cfrom, to, candidate, sdpMid, sdpMLineIndexICE 候选
leaveC → S-主动离开
ping / pong双向ts心跳(应用层)
errorS → Ccode, message, reqId?错误回执

关键设计点

  • 所有协商消息必须带 fromto,服务端只做路由,不做解析。
  • offer / answer 必须带 seq(递增整数),用于防止旧 offer 在网络抖动后到达,覆盖新状态。
  • candidateoffer / answer 不要绑在一起发,要支持 Trickle ICE。

3.1 消息封装的最小契约

type SignalingMessage =
| { type: 'join'; roomId: string; token: string; peerId?: string }
| { type: 'joined'; peerId: string; peers: string[]; iceServers: RTCIceServer[] }
| { type: 'peer-joined'; peerId: string }
| { type: 'peer-left'; peerId: string; reason: 'leave' | 'timeout' | 'kicked' }
| { type: 'offer'; from: string; to: string; sdp: string; seq: number }
| { type: 'answer'; from: string; to: string; sdp: string; seq: number }
| { type: 'candidate'; from: string; to: string; candidate: RTCIceCandidateInit }
| { type: 'leave' }
| { type: 'ping'; ts: number }
| { type: 'pong'; ts: number }
| { type: 'error'; code: number; message: string; reqId?: string };

类型联合的好处:客户端 switch (msg.type) 时 TypeScript 自动收窄字段,少写一堆 as

4. Offer/Answer 状态机:把它显式化

JSEP 的状态机本身已经够清晰,但信令层还要叠一层"消息时序"状态机。常见 bug 都出在两层状态不一致:浏览器还在 have-local-offer,应用却收到了第二个远端 offer。

工程实现里,每个 peerId 配对维护一份 negotiationState

interface NegotiationState {
localSeq: number; // 自己发出去的 offer 序号
remoteSeq: number; // 处理过的最大远端 seq
pending: 'idle' | 'wait-answer' | 'answering';
lastOfferAt: number; // 用于超时判断
}

function onRemoteOffer(state: NegotiationState, msg: { sdp: string; seq: number }) {
if (msg.seq <= state.remoteSeq) return; // 丢弃过期 offer
state.remoteSeq = msg.seq;
state.pending = 'answering';
// ...setRemoteDescription / createAnswer / send(answer)
}

seq 单调递增是底线。否则在 4G 切 Wi-Fi 这种瞬时双链路场景,旧 offer 后到会把新协商覆盖掉。

5. Trickle ICE 与候选缓冲

候选发送速度不可控:浏览器一秒能吐出十几个,TURN 候选可能在 setLocalDescription 之后才到。两个工程问题:

  1. 远端候选先到:对端的 ICE 比 SDP 早到(信令路径不同),addIceCandidate 会失败。
  2. 本地候选过早:自己还没 setLocalDescription,候选已经在 icecandidate 里冒出来。
const remoteCandidateBuffer = [];

async function onRemoteOffer(sdp) {
await pc.setRemoteDescription({ type: 'offer', sdp });
for (const c of remoteCandidateBuffer) await pc.addIceCandidate(c);
remoteCandidateBuffer.length = 0;
}

async function onRemoteCandidate(candidate) {
if (!pc.remoteDescription) {
remoteCandidateBuffer.push(candidate);
} else {
try { await pc.addIceCandidate(candidate); }
catch (e) { /* 协商已经切换,忽略 */ }
}
}

反过来:本端候选在收到 answer 前就可以发出去,对端如果先收到 offer,就能立即开始连通性检查。这就是 Trickle ICE 的全部价值。

6. 断线重连:要的是"会话粘性",不是"重新加入"

WebSocket 断开 ≠ WebRTC 断开。两条链路独立:

状态信令链路媒体链路处理
WS 断开,PC connected静默重连 WS,不动 PC
WS 断开,PC disconnected⚠️WS 优先恢复,PC 等 5s 自愈
WS 断开,PC failedWS 重连后做 ICE Restart

不要因为 WebSocket 断开就 pc.close()。媒体可能还能跑十几秒,等信令恢复就能继续。

6.1 恢复协议设计

// 客户端连接时带上原 sessionId
ws.send({ type: 'join', roomId, token, peerId: cachedPeerId, sessionId });

// 服务端收到
function handleJoin(msg) {
const old = sessionStore.get(msg.sessionId);
if (old && old.peerId === msg.peerId && Date.now() - old.disconnectedAt < 30_000) {
// 恢复会话:不广播 peer-joined,对端不重协商
rebindSession(msg.sessionId, ws);
return;
}
// 全新会话
createSession(msg);
}

服务端要给每个 peer 维护一个最长 30 秒的"墓碑窗口",断线后 30 秒内同 sessionId 重连算同一个 peer,对端无感。超时才广播 peer-left

6.2 客户端的指数退避

class SignalingClient {
private retry = 0;
private connect() {
const ws = new WebSocket(this.url);
ws.onopen = () => { this.retry = 0; };
ws.onclose = () => {
const delay = Math.min(30_000, 1000 * 2 ** this.retry) + Math.random() * 1000;
this.retry++;
setTimeout(() => this.connect(), delay);
};
}
}

退避上限 30 秒、加随机 jitter,避免后端重启时所有客户端同步雪崩。

7. 限流:信令服务被打挂的最常见路径

不限流的信令服务在公网上活不过一天。三个维度的限流必须都加上:

维度阈值参考触发后行为
单连接 QPS50 msg/s断开连接
单连接 candidate 数200 / 30s丢弃后续候选
单 IP 并发连接20拒绝新连接
单房间消息总量1000 msg/s房间限流

参考实现(令牌桶):

class TokenBucket {
constructor(rate, burst) { this.rate = rate; this.tokens = burst; this.burst = burst; this.last = Date.now(); }
consume(n = 1) {
const now = Date.now();
this.tokens = Math.min(this.burst, this.tokens + (now - this.last) / 1000 * this.rate);
this.last = now;
if (this.tokens < n) return false;
this.tokens -= n;
return true;
}
}

const buckets = new WeakMap();
ws.on('message', (raw) => {
let bucket = buckets.get(ws);
if (!bucket) { bucket = new TokenBucket(50, 100); buckets.set(ws, bucket); }
if (!bucket.consume()) return ws.close(1008, 'rate limit');
// ...处理消息
});

重点candidate 单独一个桶,不能和 offer / answer 共用。否则 ICE 候选风暴会把协商消息也限掉。

8. 鉴权:JWT 短期 + TURN 短期凭据

信令鉴权和 TURN 凭据是两件事,但都应该走"短期 token":

// 服务端签发:用户登录后调用
function issueRoomToken(userId: string, roomId: string) {
return jwt.sign(
{ sub: userId, room: roomId, exp: Math.floor(Date.now()/1000) + 600 },
SECRET,
);
}

// 客户端 join
ws.send({ type: 'join', roomId, token });

// 服务端验证
function onJoin(ws, msg) {
try {
const claims = jwt.verify(msg.token, SECRET);
if (claims.room !== msg.roomId) throw new Error('room mismatch');
if (claims.exp < Date.now()/1000) throw new Error('expired');
// 同时签发 TURN 短期凭据返回给客户端
ws.send({ type: 'joined', iceServers: makeTurnCreds(claims.sub) });
} catch (e) {
ws.send({ type: 'error', code: 401, message: e.message });
ws.close(4401);
}
}

不要让客户端自己拼 TURN 凭据,也不要把 TURN 长期密码下发到前端。详见 coturn部署实战.md 的"短期凭据"章节。

9. 消息编码:JSON 还是 二进制

场景推荐理由
Demo / 1v1JSON over WS调试快,肉眼可读
多人会议(< 50 人)JSON over WS限流后流量可控
大房间(> 100 人)MessagePack / Protobuf over WS候选转发占主要带宽
物联网 / 跨网关MQTT + JSON与 IoT 共栈

SDP 永远是文本。不要 base64 编码后塞进二进制协议里"省字节",省不到 5%,还增加了调试成本。

10. 完整的最小信令服务(Node + ws)

import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';

const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // roomId -> Map<peerId, ws>

wss.on('connection', (ws) => {
let peerId = null;
let roomId = null;

ws.on('message', (raw) => {
let msg; try { msg = JSON.parse(raw); } catch { return ws.close(1003); }

if (msg.type === 'join') {
const claims = jwt.verify(msg.token, process.env.JWT_SECRET);
peerId = msg.peerId || claims.sub + ':' + Math.random().toString(36).slice(2, 8);
roomId = msg.roomId;
let room = rooms.get(roomId);
if (!room) { room = new Map(); rooms.set(roomId, room); }
room.set(peerId, ws);
ws.send(JSON.stringify({
type: 'joined', peerId,
peers: [...room.keys()].filter(p => p !== peerId),
iceServers: makeIceServers(claims.sub),
}));
for (const [p, peerWs] of room) {
if (p !== peerId) peerWs.send(JSON.stringify({ type: 'peer-joined', peerId }));
}
return;
}

if (!peerId) return ws.close(4401);

if (msg.type === 'offer' || msg.type === 'answer' || msg.type === 'candidate') {
const room = rooms.get(roomId);
const target = room?.get(msg.to);
if (!target) return; // 对端已离开
target.send(JSON.stringify({ ...msg, from: peerId }));
return;
}

if (msg.type === 'ping') return ws.send(JSON.stringify({ type: 'pong', ts: msg.ts }));
});

ws.on('close', () => {
if (!peerId || !roomId) return;
const room = rooms.get(roomId);
room?.delete(peerId);
for (const peerWs of room?.values() || []) {
peerWs.send(JSON.stringify({ type: 'peer-left', peerId, reason: 'leave' }));
}
if (room && room.size === 0) rooms.delete(roomId);
});
});

function makeIceServers(userId) {
// 见 coturn部署实战.md:短期凭据生成
return [{ urls: 'stun:stun.example.com' }, /* ... */];
}

不到 60 行的实现,覆盖了 1v1 ~ 中等多人房间的全部需求。但生产部署需要补:限流、心跳超时、Redis pub/sub、监控指标、日志脱敏。

11. 反模式

反模式后果替代
信令服务解析 SDP 改字段浏览器升级后 SDP 结构变化导致全网失败盲转,要改用 RTCRtpTransceiver 的 API
offer/answer/candidate 塞进同一条消息失去 Trickle ICE,建连慢 1-2 秒分成三种消息分别发
WebSocket 断了立刻 pc.close()媒体被白白杀掉,用户感知严重抖动信令重连优先,30s 内不重建 PC
candidate 不限流DDoS 几个房间就能拖垮整个集群单连接令牌桶 + 单 IP 并发限制
peerId = userId一个用户多设备登录互相覆盖peerId = userId + ':' + sessionId
客户端自己生成 TURN 凭据长期密码泄露,TURN 被白嫖服务端签发短期 HMAC 凭据
信令消息没有 seq网络抖动后旧 offer 覆盖新协商每对 peer 维护单调递增 seq

12. 权威资料