前端知库

WebRTC 的 perfect negotiation 如何避免协商碰撞?

解释双方同时触发协商时为什么会出现 offer collision,使用 polite/impolite 角色、rollback、makingOffer 和 ignoreOffer 建立可恢复的协商流程,并覆盖 ICE 候选、重连、权限和清理边界。

参考答案

简答

  • 双方同时发起协商会产生 offer collision。
  • perfect negotiation 给两端分配 polite 和 impolite 角色:polite 方回滚并接受对方的 offer,impolite 方忽略冲突的 offer。
  • 用 makingOffer 和 ignoreOffer 判断冲突,现代浏览器设置远端 offer 时会隐式回滚。
  • ICE restart 与重新连接不同;信令、权限和资源清理仍由应用负责。

在最简单的 WebRTC 演示中,一方创建 offer,另一方创建 answer,看起来像严格的请求响应。但真实应用会在很多时刻触发重新协商:用户打开摄像头、共享屏幕、添加数据通道、切换发送方向,或者网络恢复后进行 ICE restart。如果双方恰好同时创建并发送 offer,就会出现 offer collision,两个连接都可能等待对方的 answer,甚至进入 have-local-offer 与 have-remote-offer 无法继续的状态。

“让某一方永远先发”并不能彻底解决问题,因为消息延迟、用户操作和网络恢复会改变时序。更稳妥的做法是让双方运行同一套协商逻辑,只给每个连接分配两个角色:

  • polite(礼让方):发生碰撞时愿意回滚自己的本地 offer,接受对方的 offer;
  • impolite(不礼让方):发生碰撞时忽略收到的 offer,继续完成自己已经开始的协商。

角色只解决“谁在碰撞时退让”,不代表谁拥有更高业务权限,也不能代替房间鉴权。

先理解碰撞条件

negotiationneeded 表示连接需要重新协商,但它不是“立刻发送 offer”的保证。事件可能因为多个变更被合并,也可能在应用尚未完成上一次协商时再次触发。发送 offer 前应维护 makingOffer 标记,并在收到远端描述时检查连接状态:

JavaScript
const peer = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.example.test' }],
});

const polite = /* 由房间内的稳定角色分配决定 */ true;
let makingOffer = false;
let ignoreOffer = false;

peer.addEventListener('negotiationneeded', async () => {
  try {
    makingOffer = true;
    await peer.setLocalDescription();
    signal({ description: peer.localDescription });
  } catch (error) {
    reportNegotiationError(error);
  } finally {
    makingOffer = false;
  }
});

peer.addEventListener('icecandidate', ({ candidate }) => {
  signal({ candidate });
});

这里的 setLocalDescription() 不传参数时,会根据当前状态创建合适的 offer 或 answer。它必须在 negotiationneeded 的异步处理里完成,并将完整的本地描述通过信令发送。signal 只是业务层的占位函数,可以用 WebSocket、WebTransport 或其他经过鉴权的通道实现;WebRTC 本身不提供房间和信令服务器。

收到远端描述时先判断是否冲突

收到一个 offer 时,下面这个判断表达了“当前是否已经有本地 offer,且这次 offer 不是我们可以接受的正常结果”:

JavaScript
async function receiveDescription(description) {
  const readyForOffer = !makingOffer &&
    (peer.signalingState === 'stable' || peer.signalingState === 'have-remote-offer');
  const offerCollision = description.type === 'offer' && !readyForOffer;

  ignoreOffer = !polite && offerCollision;
  if (ignoreOffer) return;

  if (offerCollision) {
    await peer.setLocalDescription({ type: 'rollback' });
  }

  await peer.setRemoteDescription(description);

  if (description.type === 'offer') {
    await peer.setLocalDescription();
    signal({ description: peer.localDescription });
  }
}

礼让方在碰撞时使用 { type: 'rollback' } 撤销自己的本地 offer,回到可以接受远端 offer 的状态,然后设置远端描述并生成 answer。不礼让方直接忽略碰撞 offer。ignoreOffer 还应影响后续 ICE 候选:如果忽略了某个 offer,属于它的候选也不能被无条件加入,否则 addIceCandidate() 可能因没有对应的远端描述而报错。

JavaScript
// icecandidate 监听已在上文注册,这里只处理接收端
async function receiveCandidate(candidate) {
  try {
    await peer.addIceCandidate(candidate);
  } catch (error) {
    if (!ignoreOffer) throw error;
  }
}

这段示例假设信令层已经保证描述和候选属于同一个会话,并且候选到达顺序可处理。真实网络中候选可能先于远端描述到达,应像基础 WebRTC 题中那样暂存,待 setRemoteDescription() 成功后按顺序加入。不能因为采用 perfect negotiation 就忽略候选的时序问题。

为什么 rollback 比手动清空状态可靠

signalingState 是浏览器对 SDP 协商状态的约束。手动把自己的变量改成“空闲”并不会改变 RTCPeerConnection 的内部状态。现行规范和现代浏览器在 have-local-offer 时调用 setRemoteDescription(offer) 会隐式执行 rollback,示例中显式 rollback 主要为了兼容旧实现并让意图更清楚。rollback 让浏览器撤销尚未完成的本地描述,并恢复到稳定状态,再接受新的远端 offer。

rollback 只能处理尚未完成的协商。它不能撤销已经应用的媒体轨道、权限决定或业务状态,也不能修复服务端把旧房间消息误发到新连接的问题。每条信令消息都应带会话标识、单调递增序号或可验证的连接代际,重连后清理旧连接的监听器和待处理消息。

重新连接与 ICE restart 不是同一个动作

iceConnectionState 或 connectionState 进入 disconnected 可能只是短暂网络抖动;立即关闭连接会丢失正在进行的媒体和数据。进入 failed、检测到网络接口变化,或确认候选路径已经失效时,通常才考虑 ICE restart 或重新建立连接。ICE restart 会更新 ICE 凭据并需要重新协商,仍应走同一套碰撞处理逻辑:

JavaScript
async function restartIce() {
  if (peer.connectionState === 'closed') return;
  peer.restartIce();
  // restartIce 会促使后续的 negotiationneeded;不要在这里并行手写第二个 offer。
}

restartIce() 不等于无条件立即连接成功,也不保证 TURN 一定可用。生产环境需要监控候选类型、往返时间、丢包和失败原因,必要时切换有鉴权和容量规划的 TURN 服务。getStats() 的网络地址和媒体指标属于诊断数据,日志中应控制权限和保留期限。

信令、权限和资源生命周期仍要由应用负责

perfect negotiation 只解决 SDP 协商竞争,不能解决以下问题:

  • 房间成员是否有权接收或发送媒体;
  • 用户拒绝摄像头或麦克风后,UI 是否仍能完成纯音频或只读观看;
  • 屏幕共享结束、设备拔出、标签页进入后台后,轨道和预览是否正确停止;
  • 用户离开房间后,WebSocket、定时器、RTCPeerConnection、MediaStreamTrack 和待处理候选是否全部清理;
  • 重连期间旧信令消息是否会污染新连接。

结束通话时应显式关闭信令和 PeerConnection,并停止不再使用的轨道:

JavaScript
function closePeerConnection(peer, stream, removeSignalListener) {
  removeSignalListener?.();
  for (const track of stream?.getTracks() ?? []) {
    track.stop();
  }
  peer.close();
}

peer.close() 不会替应用停止所有媒体采集,也不会自动销毁页面上的 <video> 预览或业务定时器。重复关闭应是幂等的,异步信令回调在关闭后收到消息时也要安全返回。

如何验证协商逻辑

单次两人通话成功不能证明协商实现可靠。至少要制造以下时序:

  1. 两端同时添加轨道,确认其中一端礼让、另一端继续,并最终回到 stable;
  2. 在上一次 answer 尚未到达时连续添加多个轨道,确认不会并行发送互相覆盖的 offer;
  3. 让 ICE candidate 早于远端描述到达,确认会暂存而不是丢失;
  4. 连接进入 disconnected 后恢复网络,确认不会重复创建多个 PeerConnection;
  5. 关闭页面或离开房间时,确认旧信令不能修改新连接;
  6. 拒绝媒体权限、没有可用摄像头和 TURN 不可达时,确认用户仍能得到明确错误和退出路径。

面试中完整的回答不应只说“监听 negotiationneeded”。关键是把协商状态、碰撞时的回滚策略、候选的关联、重连代际和资源生命周期放在同一个状态模型里。perfect negotiation 让双方都能处理任意一方发起的变更,但它仍然需要可靠的信令顺序、房间鉴权和对失败路径的明确设计。