17、信令优化:WebSocket重连、消息队列、心跳机制
信令服务器,说白了就是 WebRTC 的「电话总机」。没有它,两个终端根本找不到对方。但移动端的网络环境有多恶劣,做过的人都懂——地铁过隧道、电梯里、信号切换……连接随时可能断。
我早期做的一个项目,信令一断,用户直接卡死在「正在连接」界面,体验极差。后来我花了整整一周重构信令层,核心就三件事:WebSocket 重连、消息队列、心跳机制。今天我把这套方案完整拆给你看。
核心思路:信令优化不是让连接永不中断,而是让中断「无感」。
17.1 为什么信令层需要「三件套」?
先看一个典型场景:用户正在视频通话,突然进了电梯。WebSocket 断开,信令发不出去了。如果没有重连机制,通话直接挂断。如果没有消息队列,重连后丢失的消息再也补不回来。如果没有心跳,你可能过了 30 秒才发现连接断了——这 30 秒里用户已经骂娘了。
所以这三者缺一不可:
- WebSocket 重连:自动恢复断开的连接
- 消息队列:缓存断连期间的消息,重连后补发
- 心跳机制:快速感知连接状态,避免「假连接」
嗯,说白了就是给信令层加了一层「保险」。下面我一个个拆开讲。
17.2 WebSocket 重连:别让用户手动刷新
我见过最粗暴的做法:WebSocket 一断开,直接弹窗让用户「点击重试」。这放在移动端就是自杀式设计。正确的做法是自动重连,而且要有策略。
17.2.1 指数退避重连
为什么要用指数退避?你想想看,如果网络彻底断了,你每秒重连一次,手机电量撑不过半小时。指数退避的意思是:第一次重连等 1 秒,第二次等 2 秒,第三次等 4 秒……直到最大间隔(比如 30 秒)。
我在项目中遇到过一个问题:用户从地下室出来,网络恢复后重连成功了,但因为退避间隔太大,等了 30 秒才连上。后来我加了网络状态监听——一旦检测到网络恢复,立即重置退避计时器,马上重连。
// 指数退避重连示例
class ReconnectManager {
private baseDelay = 1000; // 1秒
private maxDelay = 30000; // 30秒
private retryCount = 0;
private timer: number | null = null;
startReconnect() {
const delay = Math.min(
this.baseDelay * Math.pow(2, this.retryCount),
this.maxDelay
);
console.log(`第 ${this.retryCount + 1} 次重连,等待 ${delay}ms`);
this.timer = setTimeout(() => {
this.attemptConnect();
}, delay);
this.retryCount++;
}
onNetworkAvailable() {
// 网络恢复,立即重连
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.retryCount = 0;
this.attemptConnect();
}
onConnected() {
this.retryCount = 0;
// 重连成功,清理状态
}
}
我的习惯:重连次数超过 5 次后,给用户一个「手动重试」按钮兜底。别让程序无限重连下去,有时候用户想主动关掉 App。
17.2.2 重连后的状态恢复
重连成功只是第一步。接下来要恢复信令状态:重新加入房间、重新订阅消息、重新发送本地媒体描述。我习惯在重连回调里执行一个 restoreSession() 方法,把之前保存的会话信息全部恢复。
这里有个坑:重连后不要立即发 SDP,先发一个 reconnect 信令,告诉服务器「我回来了」。服务器会返回当前房间状态,你再决定是重新协商还是继续之前的会话。
17.3 消息队列:别丢任何一条信令
WebSocket 断开期间,用户可能还在操作——比如静音、切换摄像头、发送文字消息。这些操作都会产生信令。如果直接丢弃,重连后状态就不一致了。
消息队列就是干这个的:发不出去的消息先存着,等连上了再补发。
17.3.1 队列设计
我用的方案很简单:一个数组,存消息对象。每条消息有 id、type、payload、timestamp。发送时先检查 WebSocket 状态,如果断开就入队,如果连接正常就直接发。
class MessageQueue {
private queue: QueuedMessage[] = [];
private isFlushing = false;
enqueue(msg: QueuedMessage) {
this.queue.push(msg);
// 如果正在重连,等重连完成后再 flush
}
async flush() {
if (this.isFlushing) return;
this.isFlushing = true;
while (this.queue.length > 0) {
const msg = this.queue.shift()!;
try {
await this.sendMessage(msg);
} catch (e) {
// 发送失败,重新入队
this.queue.unshift(msg);
break;
}
}
this.isFlushing = false;
}
private sendMessage(msg: QueuedMessage): Promise<void> {
return new Promise((resolve, reject) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
resolve();
} else {
reject(new Error('WebSocket not open'));
}
});
}
}
我曾经踩过的坑:消息队列不能无限增长。如果断连超过 5 分钟,队列里可能积压几百条消息。重连后一口气全发出去,服务器直接被打挂。我的做法是:队列超过 50 条就丢弃最旧的消息,只保留最新的 50 条。
17.3.2 消息去重
重连后补发消息,可能会和服务器已有的消息重复。比如用户静音操作,断连前发了一次,服务器收到了但没回 ACK。重连后你又发了一次,服务器就收到了两次静音指令。
解决方案:每条消息带一个 id(UUID),服务器做幂等处理——相同的 id 只处理一次。客户端收到 ACK 后,才从队列里移除这条消息。
17.4 心跳机制:别被「假连接」骗了
WebSocket 的 onclose 事件不一定可靠。我遇到过这种情况:手机网络切换,WebSocket 状态还是 OPEN,但实际已经发不出数据了。这就是「假连接」。
心跳机制就是用来戳破这个假象的。客户端每隔一段时间发一个 ping,服务器回复 pong。如果连续几次没收到 pong,就判定连接已断开,触发重连。
17.4.1 心跳实现
class HeartbeatManager {
private pingInterval = 10000; // 10秒发一次ping
private pongTimeout = 5000; // 5秒内没收到pong就算超时
private pingTimer: number | null = null;
private pongTimer: number | null = null;
private missedPongs = 0;
private maxMissedPongs = 3;
start() {
this.sendPing();
this.pingTimer = setInterval(() => {
this.sendPing();
}, this.pingInterval);
}
private sendPing() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
// 设置pong超时
this.pongTimer = setTimeout(() => {
this.missedPongs++;
if (this.missedPongs >= this.maxMissedPongs) {
// 连续3次没收到pong,断开重连
ws.close();
reconnectManager.startReconnect();
}
}, this.pongTimeout);
}
}
onPong() {
this.missedPongs = 0;
if (this.pongTimer) {
clearTimeout(this.pongTimer);
this.pongTimer = null;
}
}
stop() {
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.pongTimer) clearTimeout(this.pongTimer);
}
}
我个人的习惯:心跳间隔不要设太短。10 秒一次足够,太频繁反而浪费带宽和电量。另外,心跳消息不要带太多数据,一个 { type: 'ping' } 就够了。
17.4.2 服务端心跳
心跳是双向的。服务端也要主动检测客户端是否存活。如果客户端 30 秒没发任何消息(包括心跳),服务端就主动断开连接,释放资源。
这里有个细节:心跳消息不要和业务消息混在一起。我见过有人把心跳塞进业务消息里,结果业务逻辑一复杂,心跳超时判断就乱了。分开处理,各管各的。
17.5 三件套的协作流程
光讲理论不够,我画了一张图,把三者的协作关系理清楚:
流程其实很简单:
- 心跳模块定时检测连接是否正常
- 一旦发现连接断开,触发重连模块
- 重连期间,所有信令进入消息队列缓存
- 重连成功后,消息队列按顺序 Flush 发送
- 服务端做幂等处理,避免重复消息
17.6 移动端特有的优化
移动端和 PC 端不一样,有几个点要特别注意:
| 场景 | 问题 | 优化方案 |
|---|---|---|
| App 切到后台 | WebSocket 被系统挂起 | 监听 visibilitychange,切回前台时立即检查连接状态 |
| 网络切换(WiFi→4G) | WebSocket 连接失效 | 监听 online/offline 事件,网络变化时主动重连 |
| 低电量模式 | 系统限制后台网络 | 降低心跳频率(从10秒改为30秒),减少电量消耗 |
| 弱网环境 | 心跳超时频繁触发重连 | 动态调整心跳超时时间,弱网下放宽到10秒 |
我曾经踩过的坑:在 iOS 上,App 切到后台后 WebSocket 会被系统断开。我一开始没处理,结果用户切回来发现连接断了,但界面还显示「已连接」。后来我加了 visibilitychange 监听,切回来立即发一个心跳,如果没回应就触发重连。
17.7 总结
信令优化说白了就三件事:断了能重连、丢了能补发、假连接能识破。这三者配合好了,移动端的信令稳定性可以提升一个档次。
我个人的经验是:不要追求 100% 不中断,那不现实。追求的是「中断了用户无感」。重连快一点、消息不丢、状态能恢复——做到这三点,用户根本不会注意到信令层出过问题。
嗯,下一节我们聊聊更进阶的话题。但今天的内容,够你重构信令层了。