直连建不起来、通话频繁掉线?用 Cloudflare TURN 补齐 8 个生产级坑点的实战指南
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
WebRTC 直连被 NAT 挡住的场景,用 Cloudflare TURN 做中继兜底。本文从凭证签发与刷新、TURN 端口选择、ICE 重启到 WebRTC 掉线排查,补齐 8 个生产级坑点,帮你把通话打通并长期稳住。
为什么直连会失败:NAT 与防火墙怎么卡住 P2P
先说一个很真实的场景:两个用户都开着通话页面,本机自测一切正常,可一到真实网络,RTCPeerConnection就卡在checking上,iceconnectionstatechange迟迟不走到connected。八成不是你代码写错了,是网络在作怪:
- 对称 NAT:每次发出去都换一个不同的内网端口,对端的回应根本回不来;
- 企业防火墙:把 WebRTC 常用口(3478、5349、443)拦了,或只放行 HTTPS 出站;
- 运营商级 NAT(CGNAT):手机网络尤其典型,一大群人共用一个出口 IP,P2P 基本无望。
这类环境里,P2P 直连要么建不起来,要么建起来也脆。解法就是让流量改走一台"中立的中继"。Cloudflare TURN 跑在它全球 anycast 网络上,客户端就近接入、无需手动选区选点,正是为这种"直连必挂"的场景兜底。
经验法则:直连优先、中继兜底。绝大多数场景你不想让全部流量都绕远路,只在直连失败时才落到 TURN——延迟和成本都最划算。
一张图看懂 STUN 与 TURN 的分工
把 STUN 和 TURN 的职责拆清楚,你才知道为什么两个都要塞给浏览器:
两者同时放进iceServers数组交给RTCPeerConnection,后面的择优完全交给 ICE 协商:STUN 负责把客户端藏在 NAT 后的公网地址"照"出来,让双方先尝试直连;一旦某个网络环境直连走不通,ICE 就自动回落到 TURN 中继,流量经turn.cloudflare.com转发。你不需要自己判断"该不该走中继",把两个都交给浏览器即可。
三分钟跑通最小可用接入
三步就能拉起一个能用的接入,顺序很关键:先在后端备好凭证,再让前端来取。
| 步骤 | 在哪做 | 干什么 |
|---|---|---|
| 1. 创建 TURN Key | Cloudflare API | POST /accounts/{account_id}/calls/turn_keys,拿uid和key(密钥只在创建时返回一次,立刻存好) |
| 2. Worker 签发临时凭证 | 你的 Worker | 收到请求后带密钥调凭证生成端点,过滤 53 后把username/credential返回 |
| 3. 客户端组装 iceServers | 浏览器 | 拉取你的接口,拼上 STUN + TURN,交给RTCPeerConnection |
前端组装iceServers的最小片段:
async function getIceServers() { const { username, credential } = await fetch('/api/turn-credentials').then(r => r.json()); return [ { urls: 'stun:stun.cloudflare.com:3478' }, { urls: ['turn:turn.cloudflare.com:3478?transport=udp', 'turn:turn.cloudflare.com:3478?transport=tcp', 'turns:turn.cloudflare.com:5349?transport=tcp', 'turns:turn.cloudflare.com:443?transport=tcp'], username, credential, credentialType: 'password' } ]; } new RTCPeerConnection({ iceServers: await getIceServers() });三个要点:
username/credential一定来自你的后端,浏览器自己拿不到密钥;- STUN 那行永远叠加一个公开服务器,保证"发现公网候选"这条链路始终在;
- TURN 的
urls放多个传输方式(udp / tcp / tls),让 ICE 自己挑能通的那个。
TURN 端口怎么选:一张表看懂取舍
浏览器端推荐的尝试顺序,本质是"延迟优先、可靠性兜底":
| 优先级 | 端口 / 传输 | 适用网络 |
|---|---|---|
| 1 | 3478/udp | 首选,延迟最低 |
| 2 | 3478/tcp | UDP 被封时的回退 |
| 3 | 5349/tls | 企业防火墙场景最稳 |
| 4 | 443/tls | 备用 TLS 口,防火墙友好 |
必须记住:端口 53 在浏览器里必须过滤,而且过滤要放在服务端。Chrome 和 Firefox 会静默拦截 53 端口的流量——它不报错,只是无声无息地连不上,排查起来最磨人。凭证生成接口的响应里天然就带着turn:turn.cloudflare.com:53?transport=udp这类地址,所以别指望前端兜底,在你自己的 Worker 里先滤掉、再排好序:
function toBrowserUrls(urls: string[]): string[] { return urls .filter(u => !u.includes(':53')) // 浏览器拦 53,服务端先滤掉 .sort((a, b) => { if (a.includes('transport=udp')) return -1; if (b.includes('transport=udp')) return 1; if (a.includes('transport=tcp') && !a.startsWith('turns:')) return -1; if (b.includes('transport=tcp') && !b.startsWith('turns:')) return 1; return 0; }); }为什么在服务端滤?因为响应里的 53 地址对非浏览器客户端(原生 App)是可用的,只在浏览器里是废的。服务端统一过滤,前端零心智负担,也避免旧客户端把 53 又透传出去。
TURN 凭证刷新怎么做才不会掉线
凭证是"临时"的,这是最大的隐藏坑。
- TTL 上限 48 小时(172800 秒),超过 API 直接拒单。长通话一定要在到期前续上;
- 刷新时机:以
ttl * 1000 - 60000(提前 1 分钟)作为刷新间隔,别卡在到期那一秒; - 续用要配合
setConfiguration():它只更新iceServers,不会触发 ICE 重启;真掉线了还得配合restartIce()。
服务端的凭证缓存(一个TURNCredentialsManager)大致这么做:
- 内存里存一份
{ username, credential, urls, expiresAt }; - 命中且未过期直接返回,省得每个客户端都去打生成端点;
- 写缓存时就把 53 过滤掉,一次搞定;
expiresAt = now + ttl*1000 - 60000,给刷新留窗口;- 顺手做一道
ttl > 172800的防御性校验,和 API 侧约束对齐。
缓存有效期比真实 TTL提前 1 分钟,是这套模式最容易漏掉的一行——漏了就会出现"缓存里看着是好的、实际已过期"的边界掉线。
掉线自救四步
网络切换、TURN 维护、凭证过期之后,iceConnectionState会掉进failed。生产代码里,别只console.log一下就躺平,按这四步恢复:
pc.addEventListener('iceconnectionstatechange', async () => { if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { await refreshTURNCredentials(pc); // 1. 刷新凭证 pc.restartIce(); // 2. 触发 ICE 重启 const offer = await pc.createOffer({ iceRestart: true }); // 3. 重启型 offer await pc.setLocalDescription(offer); await sendToPeer(offer); // 4. 经信令发给对方 } });需要触发 ICE 重启的四类场景,一个都不能少:
- TURN 服务器维护(Cloudflare 网络上偶发);
- 网络拓扑变化(anycast 路由调整);
- 长会话(> 1 小时)中的凭证刷新;
- 连接失败(
iceConnectionState === 'failed')。
更稳的做法是把
failed和disconnected都纳入恢复条件——移动网络切换时往往先到disconnected,只盯failed会漏掉一大波掉线。
高频翻车清单
| 错误做法 | 正确做法 |
|---|---|
ttl: 604800(7 天) | 改ttl: 86400(24h),超 48h 直接被 API 拒 |
硬编码turn:141.101.90.1:3478 | 用域名turn:turn.cloudflare.com:3478,IP 变更有 14 天通知期 |
浏览器端保留:53的 URL | 服务端filter(!u.includes(':53')) |
| 凭证到期不刷新 | setInterval提前 1 分钟刷新 |
| 掉线只打日志 | failed/disconnected时刷新凭证 +restartIce() |
把TURN_KEY_SECRET下放客户端 | 只在服务端签发,客户端请求/api/turn-credentials |
限额、成本与安全底线
单分配限额(按用户分配,不是账户级):
| 维度 | 限额 | 超限后果 |
|---|---|---|
| 唯一 IP | > 5 个新 IP/秒 | 丢包 |
| 包速率 | 5–10k pps(入/出) | 丢包 |
| 数据速率 | 50–100 Mbps(入/出) | 丢包 |
成本:与 Cloudflare Calls SFU 搭配使用时 TURN免费;单独用则按$0.05/GB 出站计费。想省钱,优先走"直连优先",少让流量绕中继。
安全清单(上线前逐项过一遍):
- 凭证只在服务端生成,绝不下发密钥;
TURN_KEY_SECRET进 wrangler secrets,不进vars;- TTL ≤ 预期会话时长,且 ≤ 48 小时;
- 凭证生成端点做限流;
- 签发前先做客户端认证;
- 为被攻陷的会话提供凭证吊销(
/credentials/revoke,返回 204,计费立即停); - 不硬编码 IP,或建立 DNS 监控;
- 浏览器客户端过滤 53 端口。
什么时候该把流量交给 SFU
按业务对连通性和效率的取舍,用两个策略开关控制 ICE 行为:
| 场景 | 配置 | 理由 |
|---|---|---|
| 视频会议 | iceTransportPolicy: 'all' | 先试 P2P 直连,失败才走中继,省成本 |
| IoT / 可预测连通 | iceTransportPolicy: 'relay' | 强制全走 TURN,连通性可预期 |
| 屏幕共享 | bundlePolicy: 'max-bundle' | 多路媒体聚合到一条传输,降开销 |
如果直接用 Cloudflare Calls SFU,TURN 会在需要时自动启用,客户端不用自己编排"TURN + SFU"的协调——建个 session 就行。
几个容易忽略的部署边界:
- IPv6:客户端到 TURN 支持 IPv4/IPv6,但中继地址只分配 IPv4(不支持 RFC 6156),TCP 中继(RFC 6062)也不支持——IPv6 客户端能接入,中继流量仍走 IPv4;
- TLS:1.1 / 1.2 / 1.3 都支持。TLS 1.3 推荐
AEAD-AES128-GCM-SHA256、AEAD-AES256-GCM-SHA384、AEAD-CHACHA20-POLY1305-SHA256;TLS 1.2 推荐ECDHE-ECDSA-AES128-GCM-SHA256、ECDHE-RSA-AES128-GCM-SHA256。
进一步阅读
仓库里的完整参考(相对仓库根目录):
- 服务概览与端口清单:references/turn/README.md
- 凭证生成/吊销、Key 管理、类型与 TTL 约束:references/turn/api.md
- Worker 搭建、wrangler.jsonc、环境变量、IP 白名单:references/turn/configuration.md
- 实现模式与用例:references/turn/patterns.md
- 常见错误、限额、安全与排障:references/turn/gotchas.md
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考