ICE / STUN / TURN 与 NAT 穿透原理

系统讲解 WebRTC 的连通性基石:NAT 映射行为如何决定穿透难度、STUN Binding 请求与 XOR-MAPPED-ADDRESS 的工作机制、host/srflx/prflx/relay 四类候选的收集过程、连通性检查与候选配对优先级,以及 TURN 中继的部署与 Trickle ICE 实践。

音视频能否连通,本质上是两个位于各自 NAT 之后的端点能否找到一条互相可达的路径。WebRTC 把这件事交给了 ICE 框架,而 ICE 又依赖 STUN 与 TURN 两个协议。三者经常被混为一谈,实际上分工明确:STUN 负责「问出我在公网看来是什么地址」,TURN 负责「实在直连不上时帮我中转」,ICE 负责「把所有可能的路径都试一遍,挑最好的那条」。

本文从 NAT 的行为讲起,逐层拆到候选收集、连通性检查与中继部署。读完你应该能判断一次连接失败究竟卡在哪一步,也能理解为什么生产环境几乎一定要自建 TURN。

一、NAT 与连通性难题

1.1 NAT 的四种映射行为

NAT 的核心问题在于:内网主机发出的包,经过 NAT 后源地址被改写为公网地址与端口,而对端看到的是改写后的地址。不同 NAT 对「同一内网地址映射到哪个公网端口」的处理方式不同,直接决定了穿透难度。

NAT 类型映射行为过滤行为穿透难度
Full Cone内网地址固定映射到一个公网端口任意外部地址都能发包进来最易
Restricted Cone同上只有内网先发过包的 IP 能进来较易
Port Restricted Cone同上只有内网先发过包的 IP 与端口能进来中等
Symmetric每个目标地址映射到不同公网端口严格最难,常需 TURN

关键区别在于 Symmetric NAT:内网主机向 STUN 服务器探测得到的公网端口,与它向对端发包时使用的公网端口不是同一个。因此 STUN 探测出的地址对对方无效,直连几乎不可能,必须回落到 TURN 中继。

1.2 为什么需要「先探测再协商」

由于端点无法自省自己在 NAT 后的公网表示,只能借助一个第三方服务器来观察。这就是 STUN 的角色:它站在公网,把看到的源地址告诉端点。ICE 则把「我自己的、我探测到的、我中继的」所有地址作为候选全部列出,交给双方逐一尝试。

二、STUN 协议原理

2.1 消息结构

STUN 消息由 20 字节头部与若干属性(TLV)组成。头部固定字段如下:

字段长度含义
Message Type2 字节如 Binding Request 0x0001、Success Response 0x0101
Message Length2 字节属性部分总长度,不含头部
Magic Cookie4 字节固定 0x2112A442,用于区分 STUN 与旧协议
Transaction ID12 字节请求与响应配对

Binding 请求中通常携带 SOFTWARE、PRIORITY、ICE-CONTROLLED / ICE-CONTROLLING 等属性。响应里最关键的是 XOR-MAPPED-ADDRESS:它把观察到的公网地址用 Magic Cookie 异或编码,避免被某些 NAT 设备「聪明地」改写。

2.2 一次 Binding 交互

下面的 Node.js 片段演示如何手工构造一个 Binding 请求并解析 XOR-MAPPED-ADDRESS,这有助于理解 ICE 底层在做什么:

import dgram from "node:dgram";
import crypto from "node:crypto";

const MAGIC_COOKIE = 0x2112a442;
const BINDING_REQUEST = 0x0001;

function buildBindingRequest(transactionId) {
  const buf = Buffer.alloc(20);
  buf.writeUInt16BE(BINDING_REQUEST, 0);
  buf.writeUInt16BE(0, 2); // 无属性
  buf.writeUInt32BE(MAGIC_COOKIE, 4);
  transactionId.copy(buf, 8);
  return buf;
}

function parseXorMappedAddress(buf) {
  let offset = 20;
  while (offset + 4 <= buf.length) {
    const type = buf.readUInt16BE(offset);
    const len = buf.readUInt16BE(offset + 2);
    const value = buf.subarray(offset + 4, offset + 4 + len);
    if (type === 0x0020) {
      const family = value[1];
      if (family === 0x01) {
        const port = value.readUInt16BE(2) ^ (MAGIC_COOKIE >> 16);
        const ip = value.readUInt32BE(4) ^ MAGIC_COOKIE;
        return {
          port,
          ip: [ip >>> 24, (ip >> 16) & 0xff, (ip >> 8) & 0xff, ip & 0xff].join("."),
        };
      }
    }
    offset += 4 + len + ((4 - (len % 4)) % 4); // 属性 4 字节对齐
  }
  return null;
}

const socket = dgram.createSocket("udp4");
const transactionId = crypto.randomBytes(12);

socket.on("message", (msg) => {
  console.log("公网映射:", parseXorMappedAddress(msg));
  socket.close();
});

socket.send(buildBindingRequest(transactionId), 3478, "stun.l.google.com", () => {
  console.log("Binding 请求已发送");
});

三、ICE 候选的收集

3.1 四类候选

ICE 会把端点所有可能的地址整理成候选,按获取方式分为四类:

候选类型来源典型地址成本
host本机网卡地址192.168.1.5最低,仅同网段可达
srflx经 STUN 探测的公网地址203.0.113.7:54321低,NAT 后直连
prflx连通性检查中被动发现运行时才知低,对端反射
relayTURN 中继地址198.51.100.9:49152最高,占用中继带宽

候选的优先级由 type preference、local preference 与 component ID 组合计算,公式为 priority = (2^24) * typePref + (2^8) * localPref + (256 - componentID)。RFC 8445 给出的推荐 type preference 是 host 126、prflx 110、srflx 100、relay 0。因此默认总是优先尝试直连,中继作为兜底。

3.2 在浏览器中观察候选

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.example.com:3478" },
    {
      urls: "turn:turn.example.com:3478?transport=udp",
      username: "user",
      credential: "secret",
    },
  ],
  iceTransportPolicy: "all", // 设为 "relay" 可强制走 TURN,用于验证中继
});

pc.onicecandidate = ({ candidate }) => {
  if (!candidate) {
    console.log("候选收集结束");
    return;
  }
  console.log("候选:", candidate.type, candidate.protocol, candidate.address, candidate.port);
};

candidate.type 与 candidate.protocol 是排障时最有用的两个字段,能立刻看出是否收集到了 srflx 或 relay 候选。

3.3 候选优先级的计算示例

理解优先级公式有助于解释「为什么同一台机器上某些候选总是先被尝试」。假设 component ID 对 RTP 为 1、对 RTCP 为 2,local preference 取默认值 65535,则不同类型候选的优先级大致如下:

候选类型type preference计算出的优先级(近似)尝试顺序
host1262130706431最先
prflx1101862270975次之
srflx1001694498815再次
relay016777215最后

可以看到 relay 的优先级比 host 低了两个数量级,这正是 ICE 「优先直连、中继兜底」策略在数值上的体现。若某次连接最终选中了 relay 候选,说明所有直连路径都失败了,应当去排查 NAT 类型或防火墙策略。

// 在候选事件中直接读取浏览器计算好的优先级
pc.onicecandidate = ({ candidate }) => {
  if (!candidate) return;
  console.log(
    `${candidate.type.padEnd(6)} priority=${candidate.priority} ` +
    `${candidate.protocol} ${candidate.address}:${candidate.port}`
  );
};

四、连通性检查与候选配对

收集只是第一步,真正的连通性检查(Connectivity Checks)由双方对候选配对逐对发起 STUN Binding 请求完成。这一过程有几个容易忽略的细节:

  • 检查请求走的是媒体路径本身(同一个五元组),而不是另开一条连接,因此检查成功即代表媒体能通。
  • 请求携带 USE-CANDIDATE 属性的一方是 controlling 角色,通常是发起 Offer 的一方。
  • 双方各自维护候选对列表并按优先级排序,成功的候选对中最优先的一对成为选中对。
  • 一旦某对成功并进入 succeeded,ICE 状态机推进到 connected,媒体开始发送。

在浏览器里观察状态迁移:

pc.oniceconnectionstatechange = () => {
  console.log("ICE 状态:", pc.iceConnectionState);
  if (pc.iceConnectionState === "failed") {
    // 触发 ICE 重启,重新收集候选
    pc.restartIce();
  }
};
pc.onicegatheringstatechange = () => {
  console.log("收集状态:", pc.iceGatheringState); // new -> gathering -> complete
};

五、TURN 中继

5.1 Allocate 与中继地址

TURN 的核心流程是客户端向 TURN 服务器发送 Allocate 请求,服务器分配一个中继地址(relayed transport address)并返回。此后客户端把媒体发往该中继地址,服务器再转发给对端。为了让中继高效,TURN 提供两种承载方式:

方式机制开销
Send / Data Indication每条消息带指示头较高,逐包解析
ChannelData用 2 字节 channel number 标记较低,适合媒体流

媒体流通常协商到 ChannelData 模式,因为它的头部开销远小于 Indication 模式。

5.2 凭证与安全

TURN 凭证不应硬编码在客户端。生产环境推荐使用临时凭证(time-limited credential),即 username 为过期时间戳、credential 为用共享密钥对其做 HMAC 的结果。这样即使凭证泄露,也只在一小段时间内有效。

// 服务端签发临时 TURN 凭证
import crypto from "node:crypto";

function makeTurnCredential(secret, ttlSeconds = 3600) {
  const username = String(Math.floor(Date.now() / 1000) + ttlSeconds);
  const credential = crypto
    .createHmac("sha1", secret)
    .update(username)
    .digest("base64");
  return { username, credential };
}

六、Trickle ICE

传统 ICE 要等候选全部收集完毕才发送 SDP,而收集 relay 候选可能耗时数百毫秒甚至更久。Trickle ICE 让候选随收集随发送,双方可以提前开始连通性检查,从而显著缩短首帧时间。

// 发起方:本地描述设置后立即发送 SDP,候选随后单独发
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: pc.localDescription });

pc.onicecandidate = ({ candidate }) => {
  if (candidate) signaling.send({ type: "candidate", candidate });
};

// 接收方:需要缓冲早到的候选,等 remoteDescription 就绪后再添加
const pending = [];
pc.addEventListener("signalingstatechange", async () => {
  if (pc.remoteDescription) {
    for (const c of pending.splice(0)) await pc.addIceCandidate(c);
  }
});

七、ICE 重启与排障实战

7.1 什么时候需要 ICE 重启

ICE 重启(ICE Restart)会保留已建立的 RTCPeerConnection,但丢弃旧的候选对,重新收集候选并做连通性检查。它在以下场景必须使用:

  • 网络切换,例如手机从 Wi-Fi 切到蜂窝,旧候选对全部失效。
  • 对端 IP 发生变化,例如 NAT 重映射。
  • iceConnectionState 长时间停留在 disconnected 且无法恢复。
pc.oniceconnectionstatechange = async () => {
  if (pc.iceConnectionState === "failed") {
    const offer = await pc.createOffer({ iceRestart: true });
    await pc.setLocalDescription(offer);
    signaling.send({ type: "offer", sdp: pc.localDescription });
  }
};

关键点是 createOffer({ iceRestart: true }):不传这个参数时,浏览器可能复用旧的 ICE 凭证,导致重启无效。同时接收方必须能正确处理带新 ice-ufrag / ice-pwd 的 Offer。

7.2 状态机取值与含义

排障时先看状态,再看候选。iceConnectionState 的几个取值含义如下:

状态含义常见原因
new尚未开始检查候选还没开始配对
checking正在做连通性检查正常中间态
connected至少一对成功媒体已可发送
completed所有检查完成正常终态
disconnected暂时失联网络抖动,可自愈
failed检查全部失败需 ICE 重启或人工介入
closed已关闭主动关闭

disconnected 与 failed 的区别常被误用:前者是暂时的,ICE 会自己重试;后者才是需要主动干预的信号。

7.3 用 webrtc-internals 定位问题

Chrome 内置的 chrome://webrtc-internals 会记录每一次会话的完整事件流,包括创建的 Offer/Answer、收发的候选、状态迁移与 RTP 统计。排查连通性问题的标准流程是:

  1. 打开该页面,复现一次失败的连接。
  2. 查看 icecandidate 事件里候选的类型分布,确认是否收集到了 srflx 与 relay。
  3. 查看 iceConnectionState 的迁移轨迹,定位卡在哪一步。
  4. 查看 selectedCandidatePair,确认最终选中了哪一对,是直连还是中继。

如果候选列表里只有 host 类型,说明 STUN 服务器不可达或被防火墙拦截;如果有 relay 候选却仍然 failed,问题多半出在 TURN 凭证或中继带宽上。

八、TURN 部署的工程要点

  • 带宽与并发是主要成本,TURN 中继的流量按双向计费,大规模场景需评估是否值得优化直连率。
  • 优先部署 UDP 3478,同时提供 TCP 443 与 TLS,应对只放行 HTTPS 的企业网络。
  • 多节点 TURN 需要负载均衡,且同一会话的往返包要落到同一节点,否则中继地址不匹配。
  • 监控 relay 候选的使用比例,它直接反映直连成功率;比例过高说明 STUN 或网络策略有问题。

九、常见坑清单

  • 只配置 STUN 不配置 TURN,Symmetric NAT 用户永远连不上。
  • TURN 用 TCP 承载媒体,延迟与抖动明显高于 UDP,仅在必要时回落。
  • 忘记给候选补充 sdpMid / sdpMLineIndex,导致候选无法匹配 m-line。
  • 未处理 iceConnectionState === "failed",网络切换后连接无法恢复。
  • TURN 凭证长期有效且硬编码,一旦泄露可被滥用中继带宽。
  • 把 iceTransportPolicy 设为 relay 后忘记改回,导致所有流量都走中继。
  • 忽略 ICE 收集超时,某些网络下收集永不完成,需设置超时兜底。

小结

NAT 穿透的复杂度来自 NAT 行为的多样性,而 ICE 用「全列候选、逐一尝试、优先直连」的策略把这个复杂度封装了起来。STUN 让端点知道自己对外的样子,TURN 在直连失败时兜底,Trickle ICE 则把建连时间压到最低。生产环境的可靠性几乎完全取决于 TURN 的部署质量,这一点值得在项目早期就投入。理解了候选类型与状态机,排障时就能迅速定位是收集、检查还是中继出了问题。相关信令流程见 WebRTC 架构与信令设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播