WebRTC 是浏览器与其他实时通信端点协作的一组 API 和协议,支持音视频及应用数据。它可以直连,也可以经 TURN 中继,或连接 SFU/MCU 等媒体服务器;“不经过服务器”不是使用 WebRTC 的必要条件。
把一次通话拆开更容易定位问题:应用先找到对端并获得授权,协商媒体与传输参数,验证可用路径与端点密钥,随后持续编码、传送和播放。信令成功、ICE 成功、收到媒体和成功播放是不同的里程碑。
| 速查 | 对应机制 |
| 谁与谁通话、如何交换 offer | 应用信令与身份授权 |
| 哪些媒体、方向、编解码器 | SDP offer/answer、transceiver |
| 经哪个地址对传包 | ICE 检查、选择与重启 |
| 媒体密钥如何得到 | DTLS-SRTP 与证书指纹绑定 |
| 文件/游戏消息如何传 | DataChannel、SCTP 与可靠性选项 |
| 画面卡顿在哪里 | 编码、网络、抖动缓冲、解码与播放统计 |
协议栈:DTLS 握手不等于所有媒体都装进 DTLS
媒体使用 RTP/RTCP 的安全形式 SRTP/SRTCP,DTLS 握手协商 SRTP 保护配置,并通过 exporter 导出方向密钥与 salt。媒体包随后按 SRTP 格式保护,不是每个 RTP 包再装进一个 DTLS application-data 记录。应用数据通道则使用 SCTP over DTLS。DTLS-SRTP RFC 5764、Data Channels RFC 8831
exporter 有规定标签、上下文和输出组织,不能写成随意对 DTLS 某个 secret 执行一次 SRTP_KDF(secret)。也不能把 DTLS 1.2 的 ClientKeyExchange/ChangeCipherSpec 时序当作所有版本通用握手。
SDP 是会话描述格式,承载媒体类型、codec、方向、ICE 凭据、DTLS 指纹等信息。它不直接运输视频帧;ICE 是候选路径检查与选择机制,不是所有业务包外面的另一层 IP 头。
信令与协商:控制好状态,才能接受后续 candidate
WebRTC 不强制应用使用某个通用信令传输,可以采用 HTTPS、WebSocket 或已有会话服务;但信令仍需身份认证、房间权限、消息顺序与会话关联。攻击者若能替换对端描述或证书指纹,单纯“媒体已加密”并不足以证明正在与正确的人通信。安全架构 RFC 8827
基本 offer/answer 过程可按四个状态变化理解:A 设置本地 offer 并交给 B;B 设置远端 offer,生成并设置本地 answer;A 接收并设置远端 answer。候选收集与这些异步步骤可以交叠。对端 candidate 若早于适用的远端描述到达,应用需排队并在描述生效后按正确媒体段/ICE 代际添加。
| 状态 | 回答的问题 | 不能推出 |
| signalingState | offer/answer 是否仍待完成 | 网络路径已可用 |
| iceGatheringState | 本地候选收集进展 | 对端收到了全部候选 |
| iceConnectionState | ICE 检查与连通状态 | codec 正确或已收到帧 |
| connectionState | ICE/DTLS 等整体连接状态 | 视频元素已正常播放 |
双方同时发起 offer 会产生协商碰撞,应用需要一致的冲突处理策略,例如区分礼让方并执行相应回滚;不能把所有 setRemoteDescription 失败都归结为网络不通。WebRTC API 与协商
ICE:候选地址不是连通证明
host 候选来自本机网络,server-reflexive 候选反映 STUN 观察到的映射,relay 候选来自 TURN 分配,peer-reflexive 候选可在检查过程中发现。浏览器可能为隐私使用 mDNS 名称表示某些 host 地址,因此日志中的候选不一定全是裸 IP。
两端把兼容候选配对,通过带 ICE 凭据的 STUN 检查确认可达性,控制方按规则提名选定候选对。收到一个外部 IP 或拿到 TURN 分配不等于该候选已经成为业务路径。ICE RFC 8445
Trickle ICE 允许候选边收集边发送、边检查,降低等待全部收集完成的时间。它需要处理候选结束信号和每轮 ICE 的关联,不能在重启后把旧候选无条件加到新会话。Trickle ICE RFC 8838
TURN 可以通过 UDP、TCP 或 TLS 等方式接收客户端连接,选哪一种取决于支持与网络条件。“UDP 不通就让 SRTP 原样改跑任意 TCP socket”不是浏览器暴露的通用回退接口。TURN 的控制连接、分配地址和对端媒体路径应分别观察,基础机制见端口与 NAT。
连接变差时,短暂 disconnected 不一定已经永久失败;持续失败可能需要 ICE restart,以新的 ICE 凭据重新协商路径。只在本地调用重启、却不把新描述交给对端,不能完成恢复。
信任边界:TURN、SFU 与帧级加密
TURN 中继通常只转发端点之间的加密数据,不持有这两个端点间的 SRTP 内容密钥。常规 SFU 是每个客户端的 WebRTC 对端,终止对应 DTLS-SRTP,再选择媒体流转给其他端点。“SFU 不解码视频”表示它通常不重建像素并重新编码,不表示它天然无法取得媒体明文。
若需求是让 SFU 也不能解读媒体内容,需要在媒体帧层增加端到端保护,并解决成员密钥分发、成员加入退出和密钥轮换。SFrame 定义相应帧保护构造;它不自动提供完整群组成员管理,也不会隐藏所有路由所需元数据。SFrame RFC 9605
延迟是预算:有时值得重传,有时来不及
实时媒体可以使用丢包反馈、RTX 重传、FEC 或丢包隐藏等手段,并不是禁止重传。选择取决于缺失包到播放截止还剩多少时间,以及重传的网络和排队代价。30 fps 的帧间隔约 33 ms,也不意味着所有包从发送到播放的总期限只有 33 ms。WebRTC RTP 使用 RFC 8834
例如采集与编码 35 ms、发送排队 10 ms、单向网络 25 ms、抖动缓冲 60 ms、解码与显示 25 ms,总预算 155 ms。抖动缓冲吸收网络变化,却增加延迟;降到 20 ms 能减少预算,同时也让迟到包更容易错过播放。
stages_ms = {'capture_encode': 35, 'send_queue': 10, 'network': 25,
'jitter_buffer': 60, 'decode_display': 25}
assert sum(stages_ms.values()) == 155
def retry_fits(slack, feedback_to_sender, retransmit_to_receiver, decode):
return feedback_to_sender + retransmit_to_receiver + decode < slack
assert retry_fits(80, 25, 25, 10)
assert not retry_fits(40, 25, 25, 10)
print('给定播放预算下的重传可用窗口通过')
拥塞控制根据反馈约束发送速率,编码器调节码率、分辨率或帧率,pacer 控制发送节奏。GCC 是常见实现体系,不是 WebRTC 规范要求所有浏览器只能使用同一算法。扩大的发送队列会吃掉延迟预算,即使平均吞吐看起来足够。
实时数据,什么时候值得等一次重传?
保持往返网络时间50ms和解码10ms,将剩余时间从80ms拉到40ms,重传从可用变成来不及。这里比较的是发现丢失后的剩余预算,不能直接拿帧间隔替代它。
tfeedback+tretransmit+tdecode<tplayout−tnow
正在呈现知识画面
实时数据,什么时候值得等一次重传?是否来得及由剩余deadline决定,不由UDP或实时这两个标签决定。缓冲给恢复留下空间,也增加播放延迟。
{"id":"media-retransmit-deadline","title":"实时数据,什么时候值得等一次重传?","summary":"是否来得及由剩余deadline决定,不由UDP或实时这两个标签决定。缓冲给恢复留下空间,也增加播放延迟。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"slack\" data-i18n=\"slack\"></label><output for=\"slack\" id=\"slack-value\"></output><input id=\"slack\" type=\"range\" min=\"20\" max=\"200\" value=\"80\" step=\"1\"></div><div class=\"control\"><label for=\"feedback\" data-i18n=\"feedback\"></label><output for=\"feedback\" id=\"feedback-value\"></output><input id=\"feedback\" type=\"range\" min=\"5\" max=\"80\" value=\"25\" step=\"1\"></div><div class=\"control\"><label for=\"retry\" data-i18n=\"retry\"></label><output for=\"retry\" id=\"retry-value\"></output><input id=\"retry\" type=\"range\" min=\"5\" max=\"80\" value=\"25\" step=\"1\"></div><div class=\"control\"><label for=\"decode\" data-i18n=\"decode\"></label><output for=\"decode\" id=\"decode-value\"></output><input id=\"decode\" type=\"range\" min=\"1\" max=\"40\" value=\"10\" step=\"1\"></div><div id=\"time\" class=\"intervals\"></div><div class=\"metrics\"><div><span data-i18n=\"result\"></span><strong id=\"result\"></strong></div></div><p id=\"equation\" class=\"formula\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n.metrics strong{font-size:18px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function draw(){const s=+$('#slack').value,f=+$('#feedback').value,r=+$('#retry').value,d=+$('#decode').value,total=f+r+d;for(const id of ['slack','feedback','retry','decode'])set(id+'-value',$('#'+id).value);intervals('time',[{start:0,end:s,label:'deadline '+s},{start:0,end:f,label:'feedback '+f,cls:'gold'},{start:f,end:f+r,label:'retransmit '+r,cls:'gold'},{start:f+r,end:total,label:'decode '+d,cls:'gold'}],200);set('result',total<s?'IN TIME':'TOO LATE');set('equation',f+' + '+r+' + '+d+' = '+total+' < '+s+' : '+String(total<s));$('#result').dataset.fits=String(total<s);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"decode":"解码余量(ms)","feedback":"反馈单程(ms)","heading":"实时数据,什么时候值得等一次重传?","intro":"直接调整剩余播放时间、反馈、重传和解码耗时,比较两条时间线。","reset":"重置","result":"能否赶上本次播放","retry":"重传单程(ms)","scope":"从发现缺包时刻开始,串行反馈→重传→解码;不含额外排队/重传再次丢失,严格要求完成时间早于播放deadline。","scopeLabel":"模型范围与约定","slack":"距播放剩余(ms)"}}
多人拓扑:上行省下的流量去了哪里
Mesh 中每人向其他 N−1 人发送;SFU 中发送端上传一个或多个编码层,服务器按订阅关系复制给接收者;MCU 通常解码、合成再编码,使接收端可接收合成结果。规模上限取决于码率、设备和画面布局,不存在协议规定的“Mesh 最多四人”。
假设六人每人发送 1.5 Mbit/s 视频,所有人都看其他五人,不算音频与封装:Mesh 每人上行 7.5 Mbit/s;单层 SFU 每人上行 1.5 Mbit/s,但 SFU 总下行仍有 45 Mbit/s。SFU 降低客户端重复上传,并没有凭空消除所有接收副本。
n, rate = 6, 1.5
assert n * (n - 1) // 2 == 15
assert (n - 1) * rate == 7.5
assert n * rate == 9
assert n * (n - 1) * rate == 45
assert sum([0.2, 0.6, 1.5]) == 2.3
print('Mesh、SFU 与多档上传的流量口径通过')
Simulcast 同时生成多路编码表示;SVC 使用可扩展编码层及其依赖关系,两者不能直接等同为“三个互不依赖的视频文件”。SFU 选层还要考虑关键帧与依赖,不能任意丢弃上层依赖的基础数据。Simulcast RFC 8853
WebRTC 媒体连接持有拥塞、密钥与传输状态,不适合直接当作 HLS 静态分段缓存;规模化可采用区域媒体节点、选择性订阅、分发层与转协议等组合,不应限定为“只能 SFU 级联”。
SFU省掉的上行,为什么服务器下行还在增长?
六人全互看时,SFU接收6份上传却发送30份副本。把每人订阅数降到1,服务器下行随之降到6份,而每人的单层上传不变。带宽节省发生在哪一段,应按这个复制关系计算。
正在呈现知识画面
SFU省掉的上行,为什么服务器下行还在增长?SFU把重复上传转移为服务器复制,不会消除接收者需要的副本。选择性订阅改变复制数,压缩算法不是这个公式的变量。
{"id":"sfu-copy-accounting","title":"SFU省掉的上行,为什么服务器下行还在增长?","summary":"SFU把重复上传转移为服务器复制,不会消除接收者需要的副本。选择性订阅改变复制数,压缩算法不是这个公式的变量。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"n\" data-i18n=\"n\"></label><output for=\"n\" id=\"n-value\"></output><input id=\"n\" type=\"range\" min=\"2\" max=\"12\" value=\"6\" step=\"1\"></div><div class=\"control\"><label for=\"k\" data-i18n=\"k\"></label><output for=\"k\" id=\"k-value\"></output><input id=\"k\" type=\"range\" min=\"1\" max=\"11\" value=\"5\" step=\"1\"></div><div class=\"control\"><label for=\"rate\" data-i18n=\"rate\"></label><output for=\"rate\" id=\"rate-value\"></output><input id=\"rate\" type=\"range\" min=\"0.5\" max=\"5\" value=\"1.5\" step=\"0.5\"></div><div id=\"copies\" class=\"intervals\"></div><div class=\"metrics\"><div><span data-i18n=\"mesh\"></span><strong id=\"mesh\"></strong></div><div><span data-i18n=\"uplink\"></span><strong id=\"uplink\"></strong></div><div><span data-i18n=\"egress\"></span><strong id=\"egress\"></strong></div></div><p id=\"equation\" class=\"formula\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n.metrics strong{font-size:19px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function draw(){const n=+$('#n').value;$('#k').max=n-1;const k=Math.min(+$('#k').value,n-1),r=+$('#rate').value;set('n-value',n);set('k-value',k);set('rate-value',r);intervals('copies',[{start:0,end:n,label:'SFU ingress: '+n+' streams'},{start:0,end:n*k,label:'SFU egress: '+(n*k)+' copies',cls:'gold'}],132);set('mesh',(k*r).toFixed(1));set('uplink',r.toFixed(1));set('egress',(n*k*r).toFixed(1));set('equation','SFU out = N×k×r = '+n+'×'+k+'×'+r+' = '+(n*k*r).toFixed(1)+' Mbit/s');$('#egress').dataset.value=n*k*r;}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"egress":"SFU总下行","heading":"SFU省掉的上行,为什么服务器下行还在增长?","intro":"改变参会人数、订阅人数和单流码率,分别计算客户端与SFU的副本。","k":"每人订阅其他人的数量 k","mesh":"对等Mesh每人上行","n":"参会人数 N","rate":"每流码率(Mbit/s)","reset":"重置","scope":"每人发送一个编码层,所有人订阅相同数量k≤N−1;忽略音频、封装、simulcast和中继,码率为Mbit/s。","scopeLabel":"模型范围与约定","uplink":"SFU每人上行"}}
DataChannel:顺序与可靠性是两个维度
DataChannel 保留消息边界,可配置 ordered,以及 maxRetransmits 或 maxPacketLifeTime 的部分可靠策略;两个限制不能同时设置。无序不等于不可靠,可靠也不等于所有 channel 共用一个应用消息顺序。Data Channels
文件传输需要可靠性及应用分块、校验和恢复;游戏位置更新可能更在乎新状态而允许丢旧包。多 SCTP 流能隔离部分顺序约束,但仍共享拥塞资源;大消息、实现的交错支持和发送缓冲都会影响其他流,不能写成“多 stream 就完全没有队头阻塞”。
发送时按 bufferedAmount、低水位事件与协商出的最大消息大小实施背压。send() 接受数据只表示进入发送处理,不表示对端业务已持久化;需要业务确认时应另建消息 ID 与应答规则。
从 getStats 判断卡在哪里
先把统计对象关联到同一 transport、候选对和 SSRC,再比较时间差;累计 counters 不能直接当成当前码率。对象更换、计数重置或字段不可用时,要重新建立基线。WebRTC Stats
例如同一 outbound-rtp 对象 bytesSent 在两秒内从 1,000,000 增至 1,500,000,对应这项统计口径的 2 Mbit/s。它不是自动包含每层 IP/UDP/TURN 开销的网卡速率。接收侧 jitterBufferDelay / jitterBufferEmittedCount 可用于计算累计平均排队时间,观察区间变化时应对两个累计量分别求差再相除。
| 现象 | 可以继续观察 |
| SDP 完成但 ICE failed | 候选是否交换完整、实际检查结果、TURN 可达性 |
| ICE 可用而 DTLS failed | 指纹、角色、握手与安全策略 |
| outbound 没有增长 | 轨道状态、协商方向、采集与编码 |
| inbound 字节增长但无画面 | 解码帧、codec、关键帧、播放元素与自动播放限制 |
| 画面延迟逐渐增长 | 发送队列、RTT、抖动缓冲与解码耗时 |
| 只在特定网络变差 | 被选中的候选对与实际中继承载 |
这些证据让“WebRTC 不通”拆成可以验证的阶段。应用日志中的 room ID、协商代际和时间戳应能与统计对应,而不是只保存一个最终 connectionState。