错误处理与异常恢复:连接断开处理、消息格式错误处理、房间异常清理

说实话,信令服务器这东西,平时跑得好好的没人夸你。一旦出问题,那就是连环爆炸。我见过太多项目,信令逻辑写得漂漂亮亮,结果一上线就崩——为什么?因为压根没想过「如果客户端突然断网怎么办」、「如果收到一堆乱码消息怎么办」、「如果房间里的用户全跑了,房间还挂着怎么办」。

这一章,我们就来聊聊这些「没人愿意想,但必须处理」的事。我个人习惯是:先想崩溃场景,再写业务逻辑。顺序反了,后面全是坑。

连接断开处理

WebRTC 的信令连接,说白了就是一条 WebSocket 长连接。客户端一断,服务器得立刻感知,并且做清理。你想想看,用户手机掉进马桶,网络瞬间断了,服务器这边还傻傻地以为他在房间里,给他转发消息——这不就乱套了吗?

我在项目中遇到过一种情况:客户端浏览器标签页被关了,但 WebSocket 的 close 事件没触发(某些老旧浏览器或者代理层搞的鬼)。结果呢?房间里的其他用户一直收不到「有人离开」的消息,还以为对方还在。嗯,这其实是个很隐蔽的 bug。

我的做法是:心跳机制 + 超时踢出。客户端每隔 30 秒发一个 ping,服务器回一个 pong。如果连续 3 次没收到 ping,就认为连接已死,主动清理。

核心原则:不要依赖 WebSocket 的 close 事件做唯一判断。一定要有心跳兜底。

代码实现大概长这样:

// 服务端心跳检测
const HEARTBEAT_TIMEOUT = 90_000; // 90秒无响应视为断开

client.on('pong', () => {
  client.lastPong = Date.now();
});

// 每10秒检查一次
setInterval(() => {
  const now = Date.now();
  for (const [id, ws] of clients) {
    if (now - ws.lastPong > HEARTBEAT_TIMEOUT) {
      handleDisconnect(id, 'heartbeat timeout');
    }
  }
}, 10_000);

断开之后要做什么?三步走:

  1. 从房间成员列表移除——别让他继续占坑
  2. 通知房间内其他人——广播一条 peer_disconnected 消息
  3. 检查房间是否为空——如果空了,考虑是否要销毁房间
小技巧:断开通知最好延迟 1-2 秒发送。因为有时候客户端只是短暂断连(比如 WiFi 切换),马上又连回来了。延迟一下,避免频繁的「离开-加入」通知骚扰其他用户。

消息格式错误处理

客户端发过来的消息,你永远不知道是什么鬼样子。我见过有人把 JSON 写成了 Python 字典格式,也见过直接发二进制流的。信令服务器不是垃圾桶,不能什么都往里塞。

我的原则是:严格校验,温柔拒绝。也就是说,格式不对就返回错误码,但不要直接断开连接。除非恶意攻击,否则给客户端一次重试的机会。

常见的消息格式错误有这几类:

错误类型 示例 处理方式
JSON 解析失败 {type: "join", room: "abc"}(单引号) 返回 {error: "invalid_json"}
缺少必填字段 {"type": "join"}(没带 room) 返回 {error: "missing_field", field: "room"}
type 值不合法 {"type": "fly"}(不存在的指令) 返回 {error: "unknown_type"}
字段类型错误 {"type": "join", "room": 123}(room 应为字符串) 返回 {error: "type_mismatch", field: "room"}

我曾经遇到过最离谱的事:某个客户端 SDK 把信令消息的 type 字段拼写成了 typo,结果服务器一直返回 unknown_type,客户端也不处理错误,就这么死循环了。所以后来我加了一条规则:连续 5 次发送非法消息,直接踢掉。防呆不防傻。

注意:错误消息本身也要是标准 JSON 格式。不要返回纯文本或者 HTML,否则客户端解析又得崩一次。

校验代码示例:

function validateMessage(msg) {
  if (typeof msg !== 'object' || msg === null) {
    return { valid: false, error: 'not_an_object' };
  }
  if (!msg.type || typeof msg.type !== 'string') {
    return { valid: false, error: 'missing_or_invalid_type' };
  }
  const allowedTypes = ['join', 'leave', 'offer', 'answer', 'ice_candidate'];
  if (!allowedTypes.includes(msg.type)) {
    return { valid: false, error: 'unknown_type' };
  }
  return { valid: true };
}

房间异常清理

房间这东西,创建起来容易,清理起来麻烦。我见过不少信令服务器,房间越积越多,内存越吃越高,最后 OOM 挂掉。为什么?因为只创建不销毁。

什么时候该销毁一个房间?我的经验是:

  • 房间内所有用户都离开了——这是最直接的场景
  • 房间内只剩一个人,且超过 5 分钟没有新用户加入——这个人可能只是忘了关页面
  • 房间创建后 24 小时无人使用——定时清理僵尸房间

说白了,房间就是一个临时资源。用完就扔,别留着过年。

清理房间时要做的事:

  1. 关闭房间内所有残留的 WebSocket 连接(如果有的话)
  2. 从房间列表中移除该房间
  3. 释放关联的内存数据(比如 SDP 信息、ICE 候选等)
  4. 记录日志,方便排查问题
一个血的教训:我曾经在清理房间时忘了释放 SDP 缓存,结果房间虽然删了,但内存还在涨。后来加了个房间级别的引用计数,才彻底解决。

下面这张图,是我个人总结的信令服务器错误处理全流程,你可以参考一下:

信令服务器错误处理与异常恢复流程 收到客户端消息 消息格式校验 业务逻辑处理 返回错误消息 心跳检测 保持连接 断开清理 房间为空? 销毁房间 正常流程 异常处理

最后说一句:错误处理不是锦上添花,是保命用的。你想想看,一个没有错误处理的信令服务器,就像没有安全气囊的车——平时开着没事,撞一次就完了。别偷懒,该写的 try-catch、该做的校验、该跑的定时清理,一个都不能少。

我的习惯:每次上线前,我会故意模拟几种异常场景(断网、发乱码、快速进出房间),看看服务器能不能扛住。扛不住就改,改到稳了再上线。这叫「先打预防针,再出门」。