信令协议设计
信令协议设计
WebRTC 标准把信令"留白"。这篇文档回答一个工程问题:在自己的产品里,怎么把房间、Offer/Answer、ICE 候选、断线重连、限流这些东西落成一个能上线、能扩容、能排障的协议。
不讨论选 WebSocket 还是 Socket.IO,那只是传输;本文聚焦协议本身的形状。
1. 信令的最小职责边界
信令只做 4 件事,多一件都属于"业务层",不应混入信令层:
| 职责 | 是否必须 | 说明 |
|---|---|---|
| 房间成员管理 | ✅ | 谁在、谁加入、谁掉线 |
| 转发 SDP | ✅ | 服务端不解析,盲转 |
| 转发 ICE 候选 | ✅ | 高频小包,需要限流 |
| 鉴权 / 准入 | ✅ | JWT 或一次性 token |
| 业务消息(举手、聊天) | ❌ | 用 DataChannel 或独立 RPC |
| 录制控制、白板同步 | ❌ | 独立通道,避免阻塞协商 |
把举手、弹幕这种业务消息塞到信令通道,会让限流策略难做(业务高频包淹没协商低频包),也会让信令服务无法专注做横向扩展。
2. 房间模型:从最小到可扩展
最小的房间模型是一张内存表:Map<roomId, Set<peerId>>。但生产上要回答 4 个问题:
- 一个房间能不能跨多台信令机器?
- peerId 由谁分配,重连后能否复用?
- 谁是房主,房主走了怎么办?
- 房间消失的条件是什么?
横向扩展的最常见做法:每台信令节点本地保存自己服务的 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 | 方向 | 必填字段 | 说明 |
|---|---|---|---|
join | C → S | roomId, token, peerId? | 鉴权 + 加入房间 |
joined | S → C | peerId, peers[], iceServers | 加入成功,附 TURN 凭据 |
peer-joined | S → C | peerId | 新成员通知 |
peer-left | S → C | peerId, reason | 成员离开 |
offer | C → S → C | from, to, sdp, seq | 转发 SDP Offer |
answer | C → S → C | from, to, sdp, seq | 转发 SDP Answer |
candidate | C → S → C | from, to, candidate, sdpMid, sdpMLineIndex | ICE 候选 |
leave | C → S | - | 主动离开 |
ping / pong | 双向 | ts | 心跳(应用层) |
error | S → C | code, message, reqId? | 错误回执 |
关键设计点:
- 所有协商消息必须带
from和to,服务端只做路由,不做解析。 offer/answer必须带seq(递增整数),用于防止旧 offer 在网络抖动后到达,覆盖新状态。candidate与offer/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 之后才到。两个工程问题:
- 远端候选先到:对端的 ICE 比 SDP 早到(信令路径不同),
addIceCandidate会失败。 - 本地候选过早:自己还没
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 failed | ❌ | ❌ | WS 重连后做 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. 限流:信令服务被打挂的最常见路径
不限流的信令服务在公网上活不过一天。三个维度的限流必须都加上:
| 维度 | 阈值参考 | 触发后行为 |
|---|---|---|
| 单连接 QPS | 50 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 / 1v1 | JSON 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. 权威资料
- IETF RFC 8829 JSEP: https://www.rfc-editor.org/rfc/rfc8829
- W3C WebRTC 1.0 §4.7 Perfect Negotiation: https://www.w3.org/TR/webrtc/#perfect-negotiation-example
- IETF RFC 8835 Transports for WebRTC: https://www.rfc-editor.org/rfc/rfc8835
- IETF RFC 7064 URI for STUN: https://www.rfc-editor.org/rfc/rfc7064
- IETF RFC 7065 URI for TURN: https://www.rfc-editor.org/rfc/rfc7065
- protoo 信令库(mediasoup 用):https://protoo.versatica.com/
- 核对日期:2026-06-22