【WebRtc】-ICE Candidate 与 STUN/TURN 原理详解

WebRTC 能点对点传输媒体流,但"找到对方在哪里"是最难的部分。ICE 就是解决这个问题的机制。


一、问题背景:为什么 P2P 连接难?

现实网络中,设备几乎都在 NAT(网络地址转换)后面:

css 复制代码
手机A                  运营商NAT               互联网              路由器NAT              手机B
192.168.1.5  ------>  公网IP: 1.2.3.4  ------>  互联网  ------>  公网IP: 5.6.7.8  ------>  192.168.0.10

问题:

  • 手机A不知道自己的公网地址是什么
  • 手机B不知道手机A的公网地址
  • NAT 默认会丢弃外部发来的、未经"预热"的数据包

ICE(Interactive Connectivity Establishment)就是系统性地找到"双方都能互通的网络路径"的协议。


二、ICE Candidate 是什么?

ICE Candidate(候选地址)是"我可以通过这个地址接受连接"的声明,分三类:

1. Host Candidate(本机地址)

直接用本机网卡 IP,只有同一局域网才能用。

makefile 复制代码
candidate:1234 1 udp 2113937151 192.168.1.5 54321 typ host

2. Server Reflexive Candidate(STUN 反射地址)

通过 STUN 服务器探测到的公网地址,大多数情况下能穿透 NAT。

makefile 复制代码
candidate:2345 1 udp 1677729535 1.2.3.4 54321 typ srflx raddr 192.168.1.5 rport 54321

字段解读:

  • 1.2.3.4 54321:公网侧地址(STUN 服务器看到的)
  • typ srflx:server reflexive 类型
  • raddr/rport:本机原始地址(related address)

3. Relay Candidate(TURN 中继地址)

通过 TURN 服务器中继,所有流量过服务器,最慢但最可靠,NAT 穿透全失败时的兜底。

makefile 复制代码
candidate:3456 1 udp 33562367 9.8.7.6 3478 typ relay raddr 1.2.3.4 rport 54321

三、STUN 工作原理

STUN(Session Traversal Utilities for NAT) 是一个极简协议,只做一件事:告诉你自己的公网地址。

vbscript 复制代码
手机A                          STUN 服务器(stun:stun.l.google.com:19302)
  |                                    |
  |--- "我是谁?" (STUN Binding Request) ->|
  |                                    |
  |<-- "你的公网地址是 1.2.3.4:54321" ---|
  |    (STUN Binding Response)         |

拿到公网地址后,WebRTC 引擎把它封装成 srflx 类型的 Candidate,通过信令发给对端。

局限:STUN 只解决"地址发现"问题,不转发数据。对称型 NAT(Symmetric NAT)下,STUN 地址也无法被对端直连,这时必须用 TURN。


四、TURN 工作原理

TURN(Traversal Using Relays around NAT) 是真正的中继服务器,双方都连接到 TURN 服务器,数据经过 TURN 转发。

css 复制代码
手机A  ←------→  TURN Server  ←------→  手机B
         (所有媒体数据经此中转)

代价:延迟增加、带宽消耗在服务器,通常需要付费。

TURN 在 pc_config 里以 ICE Server 形式配置,含认证凭据:

java 复制代码
// RoomParametersFetcher.requestTurnServers()
PeerConnection.IceServer turnServer =
    PeerConnection.IceServer.builder("turn:xxx.example.com:3478")
        .setUsername("user123")
        .setPassword("credential456")
        .createIceServer();

代码中的逻辑(RoomParametersFetcher 第 130-148 行):

java 复制代码
// 如果 pc_config 里没有 TURN,再单独请求一次 TURN 服务器
if (!isTurnPresent && !roomJson.optString("ice_server_url").isEmpty()) {
    List<PeerConnection.IceServer> turnServers = requestTurnServers(...);
    iceServers.addAll(turnServers);
}

五、ICE 协商完整流程

第一步:收集本地候选地址(Gathering)

创建 PeerConnection 后,WebRTC 引擎自动开始采集所有候选地址:

java 复制代码
// PeerConnectionClient.createPeerConnectionInternal()
PeerConnection.RTCConfiguration rtcConfig =
    new PeerConnection.RTCConfiguration(signalingParameters.iceServers); // 传入 STUN/TURN 列表

rtcConfig.continualGatheringPolicy =
    PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; // 持续采集(网络切换时重新采集)
    
peerConnection = factory.createPeerConnection(rtcConfig, pcObserver);

采集顺序:Host → srflx(向 STUN 服务器查询)→ relay(向 TURN 服务器申请)

每采集到一个候选地址,就立刻通过回调上报:

java 复制代码
// PCObserver(PeerConnectionClient 内部类)
@Override
public void onIceCandidate(final IceCandidate candidate) {
    executor.execute(() -> events.onIceCandidate(candidate));
    // → CallActivity → WebSocketRTCClient.sendLocalIceCandidate()
    // → 通过信令发给对端
}

第二步:通过信令交换候选地址

每采集到一个 candidate 就立刻发给对端(Trickle ICE),不等全部收集完:

java 复制代码
// WebSocketRTCClient.sendLocalIceCandidate()
JSONObject json = new JSONObject();
jsonPut(json, "type", "candidate");
jsonPut(json, "label", candidate.sdpMLineIndex);   // 媒体行索引(0=audio, 1=video)
jsonPut(json, "id", candidate.sdpMid);             // 媒体 ID
jsonPut(json, "candidate", candidate.sdp);          // 完整 candidate 字符串

第三步:添加对端候选地址(有时序要求)

对端 candidate 到来时,不能立刻加入,要等 本地和远端 SDP 都设置完成 后才能调用 addIceCandidate

这就是 queuedRemoteCandidates 的作用:

java 复制代码
// PeerConnectionClient
public void addRemoteIceCandidate(final IceCandidate candidate) {
    executor.execute(() -> {
        if (queuedRemoteCandidates != null) {
            queuedRemoteCandidates.add(candidate);  // SDP 未就绪,先缓存
        } else {
            peerConnection.addIceCandidate(candidate, ...);  // SDP 就绪,直接加
        }
    });
}

// setRemoteDescription 完成后,统一 drain
private void drainCandidates() {
    for (IceCandidate candidate : queuedRemoteCandidates) {
        peerConnection.addIceCandidate(candidate, ...);
    }
    queuedRemoteCandidates = null;  // 此后新来的 candidate 直接加
}

第四步:连通性检测(Connectivity Check)

双方拿到对端候选地址后,WebRTC 引擎内部用 STUN Binding Request 对每对候选地址组合(candidate pair)进行连通性探测:

scss 复制代码
手机A (1.2.3.4:54321)  ---STUN ping--->  手机B (5.6.7.8:12345)
手机A (1.2.3.4:54321)  <--STUN pong---  手机B (5.6.7.8:12345)   ✓ 可用

所有 candidate pair 按优先级排序(host > srflx > relay),选出最优的可通路径。

第五步:ICE 状态变迁

markdown 复制代码
NEW → CHECKING → CONNECTED → COMPLETED
                           ↘ FAILED(所有路径都不通)
                           ↘ DISCONNECTED(连通后又断了)

代码中的状态监听(PCObserver.onIceConnectionChange):

java 复制代码
if (newState == IceConnectionState.CONNECTED) {
    events.onIceConnected();      // → CallActivity 显示"已连接"
} else if (newState == IceConnectionState.DISCONNECTED) {
    events.onIceDisconnected();
} else if (newState == IceConnectionState.FAILED) {
    reportError("ICE connection failed.");
}

六、ICE 优先级与选路策略

候选地址优先级(数字越大越优先):

类型 优先级 原因
host ~2113937151 直连,延迟最低
srflx ~1677729535 NAT 穿透,次优
relay ~33562367 经中继,延迟最高

WebRTC 引擎同时检测多个 candidate pair,使用最高优先级的可通路径,同时保留备用路径(网络切换时快速恢复)。


七、配置总结(本项目中的关键参数)

java 复制代码
// PeerConnectionClient.createPeerConnectionInternal()
rtcConfig.tcpCandidatePolicy = TcpCandidatePolicy.DISABLED;        // 不使用 TCP candidate(延迟高)
rtcConfig.bundlePolicy      = BundlePolicy.MAXBUNDLE;               // 音视频共用一条连接
rtcConfig.rtcpMuxPolicy     = RtcpMuxPolicy.REQUIRE;               // RTCP 和 RTP 共用端口
rtcConfig.continualGatheringPolicy = GATHER_CONTINUALLY;            // 网络变化时持续重新采集
rtcConfig.keyType           = KeyType.ECDSA;                        // DTLS 加密用 ECDSA 证书
rtcConfig.sdpSemantics      = SdpSemantics.UNIFIED_PLAN;           // 现代 SDP 格式

八、一句话总结

组件 作用
ICE Candidate 描述"我可以从这个地址接受连接"
STUN 帮你发现自己的公网地址,生成 srflx candidate
TURN 当直连失败时,作为中继转发所有媒体数据
ICE 协商 双方交换所有 candidate,逐一探测可通路径,选出最优的
相关推荐
l1258651 小时前
# LangGraph Memory机制深度解析:短期记忆与长期记忆的工程实践
前端·人工智能·python·langchain·bootstrap
码视野2 小时前
基于 Spring Boot + Vue3 的【大学英语四六级 (CET-4/6) 作文智能评分与句式润色系统】设计与实现(含PRD/三端高保真源码/大屏)
java·前端·人工智能·spring boot·后端·vue3
大模型码小白2 小时前
AI 对话流性能调优:万级消息的虚拟滚动落地
java·大数据·前端·javascript·人工智能·算法·机器学习
xm_xm_xm_13 小时前
react17版本以前类组件常用操作
前端·javascript·react.js
ly76894 小时前
Web 性能优化实战:从 Core Web Vitals 到工程化性能治理
前端·性能优化
冬奇Lab4 小时前
一天一个开源项目(第200篇):next-forge - 生产级 Next.js SaaS 启动模板
前端·前端框架·开源
Profile排查笔记4 小时前
指纹浏览器怎么设置 IP?代理配置、检测与排错流程
前端·人工智能·后端·自动化
lzhdim4 小时前
13、JavaScript事件循环机制 - JavaScript学习系列文章
开发语言·前端·javascript·学习·ecmascript
liangshanbo12154 小时前
Vue 3 <script setup> 的本质原理是什么?它与普通 setup()有何区别?
前端·javascript·vue.js